diff --git a/doc/api/resources/invoices.rst b/doc/api/resources/invoices.rst index 9732b128bb..a155405502 100644 --- a/doc/api/resources/invoices.rst +++ b/doc/api/resources/invoices.rst @@ -97,6 +97,7 @@ lines list of objects The actual invo ├ gross_value money (string) Price including taxes ├ tax_value money (string) Tax amount included ├ tax_name string Name of used tax rate (e.g. "VAT") +├ tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`. └ tax_rate decimal (string) Used tax rate foreign_currency_display string If the invoice should also show the total and tax amount in a different currency, this contains the @@ -126,6 +127,10 @@ internal_reference string Customer's refe The ``event`` attribute has been added. The organizer-level endpoint has been added. +.. versionchanged:: 2024.8 + + The ``tax_code`` attribute has been added. + List of all invoices -------------------- @@ -203,6 +208,7 @@ List of all invoices "gross_value": "23.00", "tax_value": "0.00", "tax_name": "VAT", + "tax_code": "S/standard", "tax_rate": "0.00" } ], @@ -342,6 +348,7 @@ Fetching individual invoices "gross_value": "23.00", "tax_value": "0.00", "tax_name": "VAT", + "tax_code": "S/standard", "tax_rate": "0.00" } ], diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 6cc91c0a2e..255fc6d6c2 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -84,6 +84,7 @@ fees list of objects List of fees in ├ tax_rate decimal (string) VAT rate applied for this fee ├ tax_value money (string) VAT included in this fee ├ tax_rule integer The ID of the used tax rule (or ``null``) +├ tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`. └ canceled boolean Whether or not this fee has been canceled. downloads list of objects List of ticket download options for order-wise ticket downloading. This might be a multi-page PDF or a ZIP @@ -159,6 +160,10 @@ cancellation_date datetime Time of order c The ``cancellation_date`` attribute has been added and can also be used as an ordering key. +.. versionchanged:: 2025.1 + + The ``tax_code`` attribute has been added. + .. _order-position-resource: Order position resource @@ -195,6 +200,7 @@ voucher_budget_use money (string) Amount of money are changed *after* the order was created. Can be ``null``. tax_rate decimal (string) VAT rate applied for this position tax_value money (string) VAT included in this position +tax_code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`. tax_rule integer The ID of the used tax rule (or ``null``) secret string Secret code printed on the tickets for validation addon_to integer Internal ID of the position this position is an add-on for (or ``null``) @@ -255,6 +261,10 @@ pdf_data object Data object req The attribute ``print_logs`` has been added. +.. versionchanged:: 2025.1 + + The ``tax_code`` attribute has been added. + .. _order-payment-resource: Order payment resource @@ -406,6 +416,7 @@ List of all orders "tax_rate": "0.00", "tax_value": "0.00", "tax_rule": null, + "tax_code": null, "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": null, "subevent": null, @@ -645,6 +656,7 @@ Fetching individual orders "tax_rate": "0.00", "tax_rule": null, "tax_value": "0.00", + "tax_code": null, "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": null, "subevent": null, @@ -1613,6 +1625,7 @@ List of all order positions "tax_rate": "0.00", "tax_rule": null, "tax_value": "0.00", + "tax_code": null, "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "discount": null, "pseudonymization_id": "MQLJvANO3B", @@ -1739,6 +1752,7 @@ Fetching individual positions "tax_rate": "0.00", "tax_rule": null, "tax_value": "0.00", + "tax_code": null, "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": null, "subevent": null, diff --git a/doc/api/resources/taxrules.rst b/doc/api/resources/taxrules.rst index 8c3f2902ce..feb68dbcb5 100644 --- a/doc/api/resources/taxrules.rst +++ b/doc/api/resources/taxrules.rst @@ -1,3 +1,8 @@ +.. spelling:word-list:: + + EN16931 + DSFinV-K + .. _rest-taxrules: Tax rules @@ -18,6 +23,7 @@ id integer Internal ID of name multi-lingual string The tax rules' name internal_name string An optional name that is only used in the backend rate decimal (string) Tax rate in percent +code string Codified reason for tax rate (or ``null``), see :ref:`rest-taxcodes`. price_includes_tax boolean If ``true`` (default), tax is assumed to be included in the specified product price eu_reverse_charge boolean **DEPRECATED**. If ``true``, EU reverse charge rules @@ -42,6 +48,42 @@ custom_rules object Dynamic rules s The ``custom_rules`` attribute has been added. +.. versionchanged:: 2023.8 + + The ``code`` attribute has been added. + +.. _rest-taxcodes: + +Tax codes +--------- + +For integration with external systems, such as electronic invoicing or bookkeeping systems, the tax rate itself is often +not sufficient information. For example, there could be many different reasons why a sale has a tax rate of 0 %, but the +external handling of the transaction depends on which reason applies. Therefore, pretix allows to supply a codified +reason that allows us to understand what the specific legal situation is. These tax codes are modeled after a combination +of the code lists from the European standard EN16931 and the German standard DSFinV-K. + +The following codes are supported: + +- ``S/standard`` -- Standard VAT rate in the merchant country +- ``S/reduced`` -- Reduced VAT rate in the merchant country +- ``S/averaged`` -- Averaged VAT rate in the merchant country (known use case: agricultural businesses in Germany) +- ``AE`` -- Reverse charge +- ``O`` -- Services outside of scope of tax +- ``E`` -- Exempt from tax (no reason given) +- ``E/`` -- Exempt from tax, where ```` is one of the codes listed in the `VATEX code list`_ version 5.0. +- ``Z`` -- Zero-rated goods +- ``G`` -- Free export item, VAT not charged +- ``K`` -- VAT exempt for EEA intra-community supply of goods and services +- ``L`` -- Canary Islands general indirect tax +- ``M`` -- Tax for production, services and importation in Ceuta and Melilla +- ``B`` -- Transferred (VAT), only in Italy + +The code set in the ``code`` attribute of the tax rule is used by default. When ``eu_reverse_charge`` is active, the +code is replaced by ``AE`` for reverse charge sales and by ``O`` for non-EU sales. When configuring custom rules, you +should actively set a ``"code"`` key on each rule. Only for ``"action": "reverse"`` we automatically apply the code +``AE``, in all other cases the default ``code`` of the tax rule is selected. + Endpoints --------- @@ -74,6 +116,7 @@ Endpoints "id": 1, "name": {"en": "VAT"}, "internal_name": "VAT", + "code": "S/standard", "rate": "19.00", "price_includes_tax": true, "eu_reverse_charge": false, @@ -115,6 +158,7 @@ Endpoints "id": 1, "name": {"en": "VAT"}, "internal_name": "VAT", + "code": "S/standard", "rate": "19.00", "price_includes_tax": true, "eu_reverse_charge": false, @@ -164,6 +208,7 @@ Endpoints "id": 1, "name": {"en": "VAT"}, "internal_name": "VAT", + "code": "S/standard", "rate": "19.00", "price_includes_tax": true, "eu_reverse_charge": false, @@ -212,6 +257,7 @@ Endpoints "id": 1, "name": {"en": "VAT"}, "internal_name": "VAT", + "code": "S/standard", "rate": "20.00", "price_includes_tax": true, "eu_reverse_charge": false, @@ -258,3 +304,4 @@ Endpoints :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 +.. _VATEX code list: https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists \ No newline at end of file diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 160e0ce43b..c231c04bab 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -679,8 +679,8 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): class Meta: model = TaxRule - fields = ('id', 'name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', - 'keep_gross_if_rate_changes', 'custom_rules') + fields = ('id', 'name', 'rate', 'code', '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 b1e0102c6a..6b32a8db51 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -512,11 +512,12 @@ class OrderPositionSerializer(I18nAwareModelSerializer): 'company', 'street', 'zipcode', 'city', 'country', 'state', 'discount', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'print_logs', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat', 'canceled', - 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use') + 'print_logs', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', 'pdf_data', 'seat', + 'canceled', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use') read_only_fields = ( 'id', 'order', 'positionid', 'item', 'variation', 'price', 'voucher', 'tax_rate', 'tax_value', 'secret', - 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', - 'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use' + 'addon_to', 'subevent', 'checkins', 'downloads', 'answers', 'tax_rule', 'tax_code', 'pseudonymization_id', + 'pdf_data', 'seat', 'canceled', 'discount', 'valid_from', 'valid_until', 'blocked', 'voucher_budget_use' ) def __init__(self, *args, **kwargs): @@ -642,7 +643,8 @@ class OrderPaymentDateField(serializers.DateField): class OrderFeeSerializer(I18nAwareModelSerializer): class Meta: model = OrderFee - fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', 'canceled') + fields = ('id', 'fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule', + 'tax_code', 'canceled') class PaymentURLField(serializers.URLField): @@ -1676,7 +1678,7 @@ class InlineInvoiceLineSerializer(I18nAwareModelSerializer): class Meta: model = InvoiceLine fields = ('position', 'description', 'item', 'variation', 'subevent', 'attendee_name', 'event_date_from', - 'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_name', 'fee_type', + 'event_date_to', 'gross_value', 'tax_value', 'tax_rate', 'tax_code', 'tax_name', 'fee_type', 'fee_internal_type', 'event_location') diff --git a/src/pretix/base/migrations/0274_tax_codes.py b/src/pretix/base/migrations/0274_tax_codes.py new file mode 100644 index 0000000000..ceaee82ef7 --- /dev/null +++ b/src/pretix/base/migrations/0274_tax_codes.py @@ -0,0 +1,41 @@ +# Generated by Django 4.2.8 on 2024-07-02 10:34 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "pretixbase", + "0273_remove_checkinlist_auto_checkin_sales_channels", + ), + ] + + operations = [ + migrations.AddField( + model_name="invoiceline", + name="tax_code", + field=models.CharField(max_length=190, null=True), + ), + migrations.AddField( + model_name="orderfee", + name="tax_code", + field=models.CharField(max_length=190, null=True), + ), + migrations.AddField( + model_name="orderposition", + name="tax_code", + field=models.CharField(max_length=190, null=True), + ), + migrations.AddField( + model_name="taxrule", + name="code", + field=models.CharField(max_length=190, null=True), + ), + migrations.AddField( + model_name="transaction", + name="tax_code", + field=models.CharField(max_length=190, null=True), + ), + ] diff --git a/src/pretix/base/modelimport_orders.py b/src/pretix/base/modelimport_orders.py index bce3a1c85b..ed92664af7 100644 --- a/src/pretix/base/modelimport_orders.py +++ b/src/pretix/base/modelimport_orders.py @@ -441,6 +441,7 @@ class Price(DecimalColumnMixin, ImportColumn): position.price = p.gross position.tax_rule = position.item.tax_rule position.tax_rate = p.rate + position.tax_code = p.code position.tax_value = p.tax diff --git a/src/pretix/base/models/invoices.py b/src/pretix/base/models/invoices.py index 9d2be8f6d4..6919f44e0b 100644 --- a/src/pretix/base/models/invoices.py +++ b/src/pretix/base/models/invoices.py @@ -362,6 +362,7 @@ class InvoiceLine(models.Model): tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00')) tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00')) tax_name = models.CharField(max_length=190) + tax_code = models.CharField(max_length=190, null=True, blank=True) subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT) event_date_from = models.DateTimeField(null=True) event_date_to = models.DateTimeField(null=True) diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 47295b0d1d..ab72939783 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -837,7 +837,7 @@ class Item(LoggedModel): if not self.tax_rule: t = TaxedPrice(gross=price - bundled_sum, net=price - bundled_sum, tax=Decimal('0.00'), - rate=Decimal('0.00'), name='') + rate=Decimal('0.00'), name='', code=None) else: t = self.tax_rule.tax(price, base_price_is=base_price_is, invoice_address=invoice_address, override_tax_rate=override_tax_rate, currency=currency or self.event.currency, @@ -845,6 +845,7 @@ class Item(LoggedModel): if bundled_sum: t.name = "MIXED!" + t.code = None t.gross += bundled_sum t.net += bundled_sum_net t.tax += bundled_sum_tax @@ -1258,7 +1259,7 @@ class ItemVariation(models.Model): if not self.item.tax_rule: t = TaxedPrice(gross=price, net=price, tax=Decimal('0.00'), - rate=Decimal('0.00'), name='') + rate=Decimal('0.00'), name='', code=None) else: t = self.item.tax_rule.tax(price, base_price_is=base_price_is, currency=currency, override_tax_rate=override_tax_rate, @@ -1280,6 +1281,7 @@ class ItemVariation(models.Model): t.net += bprice.net - compare_price.net t.tax += bprice.tax - compare_price.tax t.name = "MIXED!" + t.code = None return t diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 80f506bf84..1e8f44d613 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -62,9 +62,10 @@ from django.db.models.signals import post_delete from django.dispatch import receiver from django.urls import reverse from django.utils.crypto import get_random_string, salted_hmac -from django.utils.encoding import escape_uri_path +from django.utils.encoding import escape_uri_path, force_str from django.utils.formats import date_format from django.utils.functional import cached_property +from django.utils.hashable import make_hashable from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_countries.fields import Country @@ -1256,7 +1257,7 @@ class Order(LockModel, LoggedModel): keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys()) create = [] for k in keys: - positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype = k + positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k d = target_transaction_count[k] - current_transaction_count[k] if d: create.append(Transaction( @@ -1272,6 +1273,7 @@ class Order(LockModel, LoggedModel): tax_rate=taxrate, tax_rule_id=taxruleid, tax_value=taxvalue, + tax_code=taxcode, fee_type=feetype, internal_type=internaltype, )) @@ -2313,6 +2315,10 @@ class OrderFee(models.Model): on_delete=models.PROTECT, null=True, blank=True ) + tax_code = models.CharField( + max_length=190, + null=True, blank=True, + ) tax_value = models.DecimalField( max_digits=13, decimal_places=2, verbose_name=_('Tax value') @@ -2340,6 +2346,16 @@ class OrderFee(models.Model): self._transaction_key_reset() return super().refresh_from_db(using, fields) + def get_tax_code_display(self): + from pretix.base.models.tax import get_tax_code_labels + + if self.tax_code: + choices_dict = get_tax_code_labels() + return force_str( + choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True + ) + return "" + def _transaction_key_reset(self): self.__initial_transaction_key = Transaction.key(self) self.__initial_canceled = self.canceled @@ -2370,9 +2386,11 @@ class OrderFee(models.Model): if self.tax_rule: tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True) self.tax_rate = tax.rate + self.tax_code = tax.code self.tax_value = tax.tax else: self.tax_value = Decimal('0.00') + self.tax_code = None self.tax_rate = Decimal('0.00') def save(self, *args, **kwargs): @@ -2381,6 +2399,7 @@ class OrderFee(models.Model): if self.tax_rate is None: self._calculate_tax() + self.order.touch() if not self.get_deferred_fields(): @@ -2468,6 +2487,10 @@ class OrderPosition(AbstractPosition): on_delete=models.PROTECT, null=True, blank=True ) + tax_code = models.CharField( + max_length=190, + null=True, blank=True, + ) tax_value = models.DecimalField( max_digits=13, decimal_places=2, verbose_name=_('Tax value') @@ -2525,6 +2548,16 @@ class OrderPosition(AbstractPosition): models.UniqueConstraint("organizer", "secret", name="orderposition_organizer_secret_uniq") ] + def get_tax_code_display(self): + from pretix.base.models.tax import get_tax_code_labels + + if self.tax_code: + choices_dict = get_tax_code_labels() + return force_str( + choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True + ) + return "" + @cached_property def sort_key(self): return self.addon_to.positionid if self.addon_to else self.positionid, self.addon_to_id or 0, self.positionid @@ -2697,11 +2730,13 @@ class OrderPosition(AbstractPosition): if self.tax_rule: tax = self.tax_rule.tax(self.price, invoice_address=ia, base_price_is='gross', force_fixed_gross_price=True) self.tax_rate = tax.rate + self.tax_code = tax.code self.tax_value = tax.tax if tax.gross != self.price: raise ValueError('Invalid tax calculation') else: self.tax_value = Decimal('0.00') + self.tax_code = None self.tax_rate = Decimal('0.00') def save(self, *args, **kwargs): @@ -2972,6 +3007,10 @@ class Transaction(models.Model): on_delete=models.PROTECT, null=True, blank=True ) + tax_code = models.CharField( + max_length=190, + null=True, blank=True, + ) tax_value = models.DecimalField( max_digits=13, decimal_places=2, verbose_name=_('Tax value') @@ -2992,17 +3031,27 @@ class Transaction(models.Model): raise ValidationError('Should set either item or fee type') return super().save(*args, **kwargs) + def get_tax_code_display(self): + from pretix.base.models.tax import get_tax_code_labels + + if self.tax_code: + choices_dict = get_tax_code_labels() + return force_str( + choices_dict.get(make_hashable(self.tax_code), self.tax_code), strings_only=True + ) + return "" + @staticmethod def key(obj): if isinstance(obj, Transaction): return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, - obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type) + obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) elif isinstance(obj, OrderPosition): return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, - obj.tax_rule_id, obj.tax_value, None, None) + obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code) elif isinstance(obj, OrderFee): return (None, None, None, None, obj.value, obj.tax_rate, - obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type) + obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) raise ValueError('invalid state') # noqa @property @@ -3166,6 +3215,7 @@ class CartPosition(AbstractPosition): if line_price.gross != self.line_price_gross or line_price.rate != self.tax_rate: self.line_price_gross = line_price.gross self.tax_rate = line_price.rate + self.tax_code = line_price.code self.save(update_fields=['line_price_gross', 'tax_rate']) @property diff --git a/src/pretix/base/models/tax.py b/src/pretix/base/models/tax.py index b6b9ef36a5..af4c5ed481 100644 --- a/src/pretix/base/models/tax.py +++ b/src/pretix/base/models/tax.py @@ -21,6 +21,7 @@ # import json from decimal import Decimal +from typing import Optional import jsonschema from django.contrib.staticfiles import finders @@ -30,8 +31,9 @@ from django.db import models from django.utils.deconstruct import deconstructible from django.utils.formats import localize from django.utils.functional import lazy +from django.utils.hashable import make_hashable from django.utils.html import format_html -from django.utils.translation import gettext_lazy as _, pgettext +from django.utils.translation import gettext_lazy as _, pgettext, pgettext_lazy from i18nfield.fields import I18nCharField from i18nfield.strings import LazyI18nString @@ -42,7 +44,7 @@ from pretix.helpers.countries import FastCountryField class TaxedPrice: - def __init__(self, *, gross: Decimal, net: Decimal, tax: Decimal, rate: Decimal, name: str): + def __init__(self, *, gross: Decimal, net: Decimal, tax: Decimal, rate: Decimal, name: str, code: Optional[str]): if net + tax != gross: raise ValueError('Net value and tax value need to add to the gross value') self.gross = gross @@ -50,6 +52,7 @@ class TaxedPrice: self.tax = tax self.rate = rate self.name = name + self.code = code def __repr__(self): return '{} + {}% = {}'.format(localize(self.net), localize(self.rate), localize(self.gross)) @@ -72,6 +75,7 @@ class TaxedPrice: tax=newgross - newnet, rate=self.rate, name=self.name, + code=self.code, ) def __mul__(self, other): @@ -85,6 +89,7 @@ class TaxedPrice: tax=newgross - newnet, rate=self.rate, name=self.name, + code=self.code, ) def __eq__(self, other): @@ -93,7 +98,8 @@ class TaxedPrice: self.net == other.net and self.tax == other.tax and self.rate == other.rate and - self.name == other.name + self.name == other.name and + self.code == other.code ) @@ -102,7 +108,8 @@ TAXED_ZERO = TaxedPrice( net=Decimal('0.00'), tax=Decimal('0.00'), rate=Decimal('0.00'), - name='' + name='', + code=None, ) EU_COUNTRIES = { @@ -125,6 +132,152 @@ VAT_ID_COUNTRIES = EU_COUNTRIES | {'CH', 'NO'} format_html_lazy = lazy(format_html, str) +TAX_CODE_LISTS = ( + # Sources: + # https://ec.europa.eu/digital-building-blocks/sites/display/DIGITAL/Registry+of+supporting+artefacts+to+implement+EN16931#RegistryofsupportingartefactstoimplementEN16931-Codelists#RegistryofsupportingartefactstoimplementEN16931-Codelists + # https://docs.peppol.eu/poacc/billing/3.0/codelist/vatex/ + # https://docs.peppol.eu/poacc/billing/3.0/codelist/UNCL5305/ + # https://www.bzst.de/DE/Unternehmen/Aussenpruefungen/DigitaleSchnittstelleFinV/digitaleschnittstellefinv_node.html#js-toc-entry2 + # + # !! When changed, also update tax-rules-custom.schema.json and doc/api/resources/taxrules.rst !! + ( + _("Standard rates"), + ( + # Standard rate in any country, such as 19% in Germany or 20% in Austria + # DSFinV-K mapping: 1 + ("S/standard", pgettext_lazy("tax_code", "Standard rate")), + + # Reduced rate in any country, such as 7% in Germany or both 10% and 13% in Austria + # DSFinV-K mapping: 2 + ("S/reduced", pgettext_lazy("tax_code", "Reduced rate")), + + # Averaged rate, for example Germany § 24 (1) Nr. 3 UStG "für die übrigen Umsätze" in agricultural and silvicultural businesses + # DSFinV-K mapping: 3 + ("S/averaged", pgettext_lazy("tax_code", "Averaged rate (other revenue in a agricultural and silvicultural business)")), + + # We ignore the German special case of the actual silvicultural products as they won't be sold through pretix (DSFinV-K mapping: 4) + ) + ), + ( + _("Reverse charge"), + ( + ("AE", pgettext_lazy("tax_code", "Reverse charge")), + ) + ), + ( + _("Tax free"), + ( + # DSFinV-K mapping: 5 + ("O", pgettext_lazy("tax_code", "Services outside of scope of tax")), + + # DSFinV-K mapping: 6 + ("E", pgettext_lazy("tax_code", "Exempt from tax (no reason given)")), + + # DSFinV-K mapping: 6 + ("Z", pgettext_lazy("tax_code", "Zero-rated goods")), + + # DSFinV-K mapping: 5 + ("G", pgettext_lazy("tax_code", "Free export item, VAT not charged")), + + # DSFinV-K mapping: 6? + ("K", pgettext_lazy("tax_code", "VAT exempt for EEA intra-community supply of goods and services")), + ) + ), + ( + _("Special cases"), + ( + ("L", pgettext_lazy("tax_code", "Canary Islands general indirect tax")), + ("M", pgettext_lazy("tax_code", "Tax for production, services and importation in Ceuta and Melilla")), + ("B", pgettext_lazy("tax_code", "Transferred (VAT), only in Italy")), + ) + ), + ( + _("Exempt with specific reason"), + ( + ("E/VATEX-EU-79-C", + pgettext_lazy("tax_code", "Exempt based on article 79, point c of Council Directive 2006/112/EC")), + *[ + ( + f"E/VATEX-EU-132-1{letter.upper()}", + lazy( + lambda let: pgettext( + "tax_code", + "Exempt based on article {article}, section {section} ({letter}) of Council " + "Directive 2006/112/EC" + ).format(article="132", section="1", letter=let), + str + )(letter) + ) for letter in ("a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q") + ], + *[ + ( + f"E/VATEX-EU-143-1{letter.upper()}", + lazy( + lambda let: pgettext( + "tax_code", + "Exempt based on article {article}, section {section} ({letter}) of Council " + "Directive 2006/112/EC" + ).format(article="143", section="1", letter=let), + str + )(letter) + ) for letter in ("a", "b", "c", "d", "e", "f", "fa", "g", "h", "i", "j", "k", "l") + ], + *[ + ( + f"E/VATEX-EU-148-{letter.upper()}", + lazy( + lambda let: pgettext( + "tax_code", + "Exempt based on article {article}, section ({letter}) of Council " + "Directive 2006/112/EC" + ).format(article="148", letter=let), + str + )(letter) + ) for letter in ("a", "b", "c", "d", "e", "f", "g") + ], + *[ + ( + f"E/VATEX-EU-151-1{letter.upper()}", + lazy( + lambda let: pgettext( + "tax_code", + "Exempt based on article {article}, section {section} ({letter}) of Council " + "Directive 2006/112/EC" + ).format(article="151", section="1", letter=let), + str + )(letter) + ) for letter in ("a", "aa", "b", "c", "d", "e") + ], + ("E/VATEX-EU-309", + pgettext_lazy("tax_code", "Exempt based on article 309 of Council Directive 2006/112/EC")), + ("E/VATEX-EU-D", + pgettext_lazy("tax_code", "Intra-Community acquisition from second hand means of transport")), + ("E/VATEX-EU-F", + pgettext_lazy("tax_code", "Intra-Community acquisition of second hand goods")), + ("E/VATEX-EU-I", + pgettext_lazy("tax_code", "Intra-Community acquisition of works of art")), + ("E/VATEX-EU-J", + pgettext_lazy("tax_code", "Intra-Community acquisition of collectors items and antiques")), + ("E/VATEX-FR-FRANCHISE", + pgettext_lazy("tax_code", "France domestic VAT franchise in base")), + ("E/VATEX-FR-CNWVAT", + pgettext_lazy("tax_code", "France domestic Credit Notes without VAT, due to supplier forfeit of VAT for discount")), + ) + ), +) + + +def get_tax_code_labels(): + flat = [] + for choice, value in TAX_CODE_LISTS: + if isinstance(value, (list, tuple)): + flat.extend(value) + else: + flat.append((choice, value)) + + return dict(make_hashable(flat)) + + def is_eu_country(cc): cc = str(cc) return cc in EU_COUNTRIES @@ -173,6 +326,14 @@ class TaxRule(LoggedModel): help_text=_('Should be short, e.g. "VAT"'), max_length=190, ) + code = models.CharField( + verbose_name=_('Tax code'), + help_text=_('If you help us understand what this tax rules legally is, we can use this information for ' + 'eInvoices, exporting to accounting system, etc.'), + null=True, blank=True, + max_length=190, + choices=TAX_CODE_LISTS, + ) rate = models.DecimalField( max_digits=10, decimal_places=2, @@ -250,6 +411,16 @@ class TaxRule(LoggedModel): if self.eu_reverse_charge and not self.home_country: raise ValidationError(_('You need to set your home country to use the reverse charge feature.')) + if self.rate != Decimal("0.00") and self.code and (self.code.split("/")[0] in ("O", "E", "Z", "G", "K", "AE")): + raise ValidationError({ + "code": _("A combination of this tax code with a non-zero tax rate does not make sense.") + }) + + if self.rate == Decimal("0.00") and self.code and (self.code.split("/")[0] in ("S", "L", "M", "B")): + raise ValidationError({ + "code": _("A combination of this tax code with a zero tax rate does not make sense.") + }) + def __str__(self): if self.price_includes_tax: s = _('incl. {rate}% {name}').format(rate=self.rate, name=self.name) @@ -276,8 +447,9 @@ class TaxRule(LoggedModel): return Decimal(rule.get('rate')) return Decimal(self.rate) - def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, invoice_address=None, - subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, force_fixed_gross_price=False): + def tax(self, base_price, base_price_is='auto', currency=None, override_tax_rate=None, override_tax_code=None, + invoice_address=None, subtract_from_gross=Decimal('0.00'), gross_price_is_tax_rate: Decimal = None, + force_fixed_gross_price=False): from .event import Event try: currency = currency or self.event.currency @@ -285,6 +457,13 @@ class TaxRule(LoggedModel): pass rate = Decimal(self.rate) + code = self.code + + if override_tax_code is not None: + code = override_tax_code + elif invoice_address: + code = self.tax_code_for(invoice_address) + if override_tax_rate is not None: rate = override_tax_rate elif invoice_address: @@ -317,11 +496,8 @@ class TaxRule(LoggedModel): if rate == Decimal('0.00'): gross = _limit_subtract(base_price, subtract_from_gross) return TaxedPrice( - net=gross, - gross=gross, - tax=Decimal('0.00'), - rate=rate, - name=self.name, + net=gross, gross=gross, tax=Decimal('0.00'), + rate=rate, name=self.name, code=code, ) if base_price_is == 'auto': @@ -346,7 +522,7 @@ class TaxRule(LoggedModel): return TaxedPrice( net=net, gross=gross, tax=gross - net, - rate=rate, name=self.name + rate=rate, name=self.name, code=code, ) @property @@ -427,6 +603,38 @@ class TaxRule(LoggedModel): return True return False + def tax_code_for(self, invoice_address): + if self._custom_rules: + rule = self.get_matching_rule(invoice_address) + if rule.get("code"): + return rule["code"] + if rule.get("action", "vat") == "reverse": + return "AE" + return self.code + + if not self.eu_reverse_charge: + # No reverse charge rules? Always apply VAT! + return self.code + + if not invoice_address or not invoice_address.country: + # No country specified? Always apply VAT! + return self.code + + if not is_eu_country(invoice_address.country): + # Non-EU country? "Non-taxable" since not in scope + return "O" + + if invoice_address.country == self.home_country: + # Within same EU country? Always apply VAT! + return self.code + + if invoice_address.is_business and invoice_address.vat_id and invoice_address.vat_id_validated: + # Reverse charge case + return "AE" + + # Consumer in different EU country / invalid VAT + return self.code + def _tax_applicable(self, invoice_address): if self._custom_rules: rule = self.get_matching_rule(invoice_address) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 8fe0f142b5..1ec6981055 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1513,6 +1513,7 @@ def get_fees(event, request, total, invoice_address, payments, positions): value=payment_fee, tax_rate=payment_fee_tax.rate, tax_value=payment_fee_tax.tax, + tax_code=payment_fee_tax.code, tax_rule=payment_fee_tax_rule )) diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index e9a0c7022a..f3de73a3c4 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -271,7 +271,9 @@ def build_invoice(invoice: Invoice) -> Invoice: event_date_from=p.subevent.date_from if invoice.event.has_subevents else invoice.event.date_from, event_date_to=p.subevent.date_to if invoice.event.has_subevents else invoice.event.date_to, event_location=location if invoice.event.settings.invoice_event_location else None, - tax_rate=p.tax_rate, tax_name=p.tax_rule.name if p.tax_rule else '' + tax_rate=p.tax_rate, + tax_code=p.tax_code, + tax_name=p.tax_rule.name if p.tax_rule else '' ) if p.tax_rule and p.tax_rule.is_reverse_charge(ia) and p.price and not p.tax_value: @@ -305,6 +307,7 @@ def build_invoice(invoice: Invoice) -> Invoice: ), tax_value=fee.tax_value, tax_rate=fee.tax_rate, + tax_code=fee.tax_code, tax_name=fee.tax_rule.name if fee.tax_rule else '', fee_type=fee.fee_type, fee_internal_type=fee.internal_type or None, @@ -491,13 +494,13 @@ def build_preview_invoice_pdf(event): InvoiceLine.objects.create( invoice=invoice, description=_("Sample product {}").format(i + 1), gross_value=tax.gross, tax_value=tax.tax, - tax_rate=tax.rate, tax_name=tax.name + tax_rate=tax.rate, tax_name=tax.name, tax_code=tax.code, ) else: for i in range(5): InvoiceLine.objects.create( invoice=invoice, description=_("Sample product A"), - gross_value=100, tax_value=0, tax_rate=0 + gross_value=100, tax_value=0, tax_rate=0, tax_code=None, ) return event.invoice_renderer.generate(invoice) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2e08f9316c..1536756dcc 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1721,16 +1721,17 @@ class OrderChangeManager: try: new_rate = tax_rule.tax_rate_for(ia) + new_code = tax_rule.tax_code_for(ia) except TaxRule.SaleNotAllowed: raise OrderError(error_messages['tax_rule_country_blocked']) # We use override_tax_rate to make sure .tax() doesn't get clever and re-adjusts the pricing itself - if new_rate != pos.tax_rate: + if new_rate != pos.tax_rate or new_code != pos.tax_code: if keep == 'net': new_tax = tax_rule.tax(pos.price - pos.tax_value, base_price_is='net', currency=self.event.currency, - override_tax_rate=new_rate) + override_tax_rate=new_rate, override_tax_code=new_code) else: new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, - override_tax_rate=new_rate) + override_tax_rate=new_rate, override_tax_code=new_code) self._totaldiff += new_tax.gross - pos.price self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._invoice_dirty = True @@ -2304,6 +2305,7 @@ class OrderChangeManager: op.position.price = op.price.gross op.position.tax_rate = op.price.rate op.position.tax_value = op.price.tax + op.position.tax_code = op.price.code op.position.save() elif isinstance(op, self.TaxRuleOperation): if isinstance(op.position, OrderPosition): @@ -2400,7 +2402,7 @@ class OrderChangeManager: elif isinstance(op, self.AddOperation): pos = OrderPosition.objects.create( item=op.item, variation=op.variation, addon_to=op.addon_to, - price=op.price.gross, order=self.order, tax_rate=op.price.rate, + price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_code=op.price.code, tax_value=op.price.tax, tax_rule=op.item.tax_rule, positionid=nextposid, subevent=op.subevent, seat=op.seat, used_membership=op.membership, valid_from=op.valid_from, valid_until=op.valid_until, diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 8fbd024af2..ee93900304 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -91,9 +91,11 @@ def get_price(item: Item, variation: ItemVariation = None, if custom_price_is_net: price = tax_rule.tax(max(custom_price, price.net), base_price_is='net', override_tax_rate=price.rate, + override_tax_code=price.code, invoice_address=invoice_address, subtract_from_gross=bundled_sum) else: price = tax_rule.tax(max(custom_price, price.gross), base_price_is='gross', override_tax_rate=price.rate, + override_tax_code=price.code, invoice_address=invoice_address, subtract_from_gross=bundled_sum) else: price = tax_rule.tax(price, invoice_address=invoice_address, subtract_from_gross=bundled_sum) @@ -146,10 +148,12 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu if custom_price_input_is_net: price = tax_rule.tax(max(custom_price_input, price.net), base_price_is='net', override_tax_rate=price.rate, - invoice_address=invoice_address, subtract_from_gross=bundled_sum) + override_tax_code=price.code, invoice_address=invoice_address, + subtract_from_gross=bundled_sum) else: price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate, - invoice_address=invoice_address, subtract_from_gross=bundled_sum) + override_tax_code=price.code, invoice_address=invoice_address, + subtract_from_gross=bundled_sum) else: price = tax_rule.tax(price_after_voucher, invoice_address=invoice_address, subtract_from_gross=bundled_sum, base_price_is='gross' if is_bundled else 'auto') diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 74723f502e..409456c7fc 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -63,6 +63,7 @@ from pretix.base.forms import ( ) from pretix.base.models import Event, Organizer, TaxRule, Team from pretix.base.models.event import EventFooterLink, EventMetaValue, SubEvent +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 ( @@ -1504,6 +1505,11 @@ class TaxRuleLineForm(I18nForm): ('require_approval', _('Order requires approval')), ], ) + code = forms.ChoiceField( + label=_("Tax code"), + choices=[("", _("Default tax code")), *TAX_CODE_LISTS], + required=False, + ) rate = forms.DecimalField( label=_('Deviating tax rate'), max_digits=10, decimal_places=2, @@ -1518,6 +1524,43 @@ class TaxRuleLineForm(I18nForm): }) ) + def __init__(self, *args, **kwargs): + self.parent_form = kwargs.pop("parent_form") + super().__init__(*args, **kwargs) + + def clean(self): + d = super().clean() + + parent_code = self.parent_form.cleaned_data.get("code") + parent_rate = self.parent_form.cleaned_data.get("rate") + + code = d.get("code") or parent_code + rate = d.get("rate") + if rate is None: + rate = parent_rate + + if d.get("action") in ("reverse", "no", "block") and d.get("rate"): + raise ValidationError(_("A combination of this calculation mode with a non-zero tax rate does not make sense.")) + + if d.get("action") == "reverse" and d.get("code") and code != "AE": + # Reverse charge but code is not reverse charge -- this is the one case we ignore if the "default code" + # is used because it is the one scenario we can auto-fix + raise ValidationError(_("This combination of calculation mode and tax code does not make sense.")) + + if d.get("action") == "no" and code and code.split("/")[0] in ("S", "AE", "L", "M", "B"): + # No VAT but code indicates VAT + raise ValidationError(_("This combination of calculation mode and tax code does not make sense.")) + + if d.get("action") == "vat" and code and rate != Decimal("0.00") and code.split("/")[0] in ("O", "E", "Z", "G", "K", "AE"): + # VAT, but code indicates exempt + raise ValidationError(_("A combination of this tax code with a non-zero tax rate does not make sense.")) + + if d.get("action") == "vat" and code and rate == Decimal("0.00") and code.split("/")[0] in ("S", "L", "M", "B"): + # no VAT, but code indicates non-exempt + raise ValidationError(_("A combination of this tax code with a zero tax rate does not make sense.")) + + return d + class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet): # compatibility shim for django-i18nfield library @@ -1529,8 +1572,16 @@ class I18nBaseFormSet(I18nFormSetMixin, forms.BaseFormSet): super().__init__(*args, **kwargs) +class BaseTaxRuleLineFormSet(I18nBaseFormSet): + + def __init__(self, *args, **kwargs): + self.parent_form = kwargs.pop('parent_form') + super().__init__(*args, **kwargs) + self.form_kwargs['parent_form'] = self.parent_form + + TaxRuleLineFormSet = formset_factory( - TaxRuleLineForm, formset=I18nBaseFormSet, + TaxRuleLineForm, formset=BaseTaxRuleLineFormSet, can_order=True, can_delete=True, extra=0 ) @@ -1538,7 +1589,16 @@ TaxRuleLineFormSet = formset_factory( class TaxRuleForm(I18nModelForm): class Meta: model = TaxRule - fields = ['name', 'rate', 'price_includes_tax', 'eu_reverse_charge', 'home_country', 'internal_name', 'keep_gross_if_rate_changes'] + fields = [ + 'name', + 'rate', + 'price_includes_tax', + 'code', + 'eu_reverse_charge', + 'home_country', + 'internal_name', + 'keep_gross_if_rate_changes' + ] class WidgetCodeForm(forms.Form): diff --git a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html index 47130a6b00..d4491ff005 100644 --- a/src/pretix/control/templates/pretixcontrol/event/tax_edit.html +++ b/src/pretix/control/templates/pretixcontrol/event/tax_edit.html @@ -26,6 +26,7 @@ {% bootstrap_field form.name layout="control" %} {% bootstrap_field form.internal_name layout="control" %} {% bootstrap_field form.rate addon_after="%" layout="control" %} + {% bootstrap_field form.code layout="control" %} {% bootstrap_field form.price_includes_tax layout="control" %}
@@ -52,6 +53,18 @@ {% trans "All of these rules will only apply if an invoice address is set." %} +
+
+ {% trans "Condition" %} +
+
+ {% trans "Calculation" %} +
+
+ {% trans "Reason" %} +
+
+
{{ formset.management_form }} {% bootstrap_formset_errors formset %} @@ -65,14 +78,17 @@
{% bootstrap_field formset.empty_form.country layout='inline' form_group_class="" %} -
-
{% bootstrap_field formset.empty_form.address_type layout='inline' form_group_class="" %}
{% bootstrap_field formset.empty_form.action layout='inline' form_group_class="" %} + {% bootstrap_field formset.empty_form.rate layout='inline' form_group_class="" %}
-
+
+ {% bootstrap_field formset.empty_form.code layout='inline' form_group_class="" %} + {% bootstrap_field formset.empty_form.invoice_text layout='inline' form_group_class="" %} +
+
-
- {% bootstrap_field formset.empty_form.invoice_text layout='inline' form_group_class="" %} -
-
- {% bootstrap_field formset.empty_form.rate layout='inline' form_group_class="" %} -
{% endescapescript %} @@ -100,14 +110,17 @@
{% bootstrap_field form.country layout='inline' form_group_class="" %} -
-
{% bootstrap_field form.address_type layout='inline' form_group_class="" %}
{% bootstrap_field form.action layout='inline' form_group_class="" %} + {% bootstrap_field form.rate layout='inline' form_group_class="" %}
-
+
+ {% bootstrap_field form.code layout='inline' form_group_class="" %} + {% bootstrap_field form.invoice_text layout='inline' form_group_class="" %} +
+
-
- {% bootstrap_field form.invoice_text layout='inline' form_group_class="" %} -
-
- {% bootstrap_field form.rate layout='inline' form_group_class="" %} -
{% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index 03d8b3398e..e77c792e28 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -202,6 +202,12 @@ + {{ position.tax_rate }}%) {% endif %} + {% if position.tax_code %} +
+ + {{ position.get_tax_code_display }} + + {% endif %}
{% bootstrap_field position.form.price addon_after=request.event.currency layout='inline' %} @@ -420,6 +426,12 @@ + {{ fee.tax_rate }}%) {% endif %} + {% if fee.tax_code %} +
+ + {{ fee.get_tax_code_display }} + + {% endif %}
{% bootstrap_field fee.form.value addon_after=request.event.currency layout='inline' %} diff --git a/src/pretix/control/templates/pretixcontrol/order/transactions.html b/src/pretix/control/templates/pretixcontrol/order/transactions.html index a286d2973d..7d327de15e 100644 --- a/src/pretix/control/templates/pretixcontrol/order/transactions.html +++ b/src/pretix/control/templates/pretixcontrol/order/transactions.html @@ -19,6 +19,7 @@ {% trans "Date" %} {% trans "Product" %} {% trans "Tax rate" %} + {% trans "Tax code" %} {% trans "Quantity" %} {% trans "Single price" %} {% trans "Total tax value" %} @@ -52,6 +53,7 @@ {% endif %} {{ t.tax_rate }} % + {{ t.get_tax_code_display }} {{ t.count }} × {{ t.price|money:request.event.currency }} {{ t.full_tax_value|money:request.event.currency }} @@ -64,8 +66,8 @@ {% trans "Sum" %} - - + + diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 9e1e064f31..b5d3631ea8 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -1197,17 +1197,22 @@ class TaxCreate(EventSettingsViewMixin, EventPermissionRequiredMixin, CreateView def post(self, request, *args, **kwargs): self.object = None - form = self.get_form() + form = self.form if form.is_valid() and self.formset.is_valid(): return self.form_valid(form) else: return self.form_invalid(form) + @cached_property + def form(self): + return self.get_form() + @cached_property def formset(self): return TaxRuleLineFormSet( data=self.request.POST if self.request.method == "POST" else None, event=self.request.event, + parent_form=self.form, ) def get_context_data(self, **kwargs): @@ -1248,17 +1253,22 @@ class TaxUpdate(EventSettingsViewMixin, EventPermissionRequiredMixin, UpdateView def post(self, request, *args, **kwargs): self.object = self.get_object(self.get_queryset()) - form = self.get_form() + form = self.form if form.is_valid() and self.formset.is_valid(): return self.form_valid(form) else: return self.form_invalid(form) + @cached_property + def form(self): + return self.get_form() + @cached_property def formset(self): return TaxRuleLineFormSet( data=self.request.POST if self.request.method == "POST" else None, event=self.request.event, + parent_form=self.form, initial=json.loads(self.object.custom_rules) if self.object.custom_rules else [] ) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 44a1e10f09..3a1ea6ff21 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -598,6 +598,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): tax=a.tax_value, name=a.item.tax_rule.name if a.item.tax_rule else "", rate=a.tax_rate, + code=a.item.tax_rule.code if a.item.tax_rule else None, ) else: v.initial_price = v.suggested_price @@ -612,6 +613,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): tax=a.tax_value, name=a.item.tax_rule.name if a.item.tax_rule else "", rate=a.tax_rate, + code=a.item.tax_rule.code if a.item.tax_rule else None, ) else: i.initial_price = i.suggested_price diff --git a/src/pretix/presale/forms/order.py b/src/pretix/presale/forms/order.py index a49a934908..38ae37267e 100644 --- a/src/pretix/presale/forms/order.py +++ b/src/pretix/presale/forms/order.py @@ -98,7 +98,8 @@ class OrderPositionChangeForm(forms.Form): new_price = get_price(i, v, voucher=instance.voucher, subevent=instance.subevent, invoice_address=invoice_address) current_price = TaxedPrice(tax=instance.tax_value, gross=instance.price, net=instance.price - instance.tax_value, - name=instance.tax_rule.name if instance.tax_rule else '', rate=instance.tax_rate) + name=instance.tax_rule.name if instance.tax_rule else '', rate=instance.tax_rate, + code=instance.tax_code) if new_price.gross < current_price.gross and event.settings.change_allow_user_price == 'gte': continue if new_price.gross <= current_price.gross and event.settings.change_allow_user_price == 'gt': diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 0073e013cb..eb6bd8512b 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1410,6 +1410,7 @@ class OrderChangeMixin: gross=a.price, tax=a.tax_value, name=a.item.tax_rule.name if a.item.tax_rule else "", + code=a.item.tax_rule.code if a.item.tax_rule else None, rate=a.tax_rate, ) else: @@ -1424,6 +1425,7 @@ class OrderChangeMixin: gross=a.price, tax=a.tax_value, name=a.item.tax_rule.name if a.item.tax_rule else "", + code=a.item.tax_rule.name if a.item.tax_rule else None, rate=a.tax_rate, ) else: diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index 985aa5d51e..ee1254b70d 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -765,8 +765,13 @@ fieldset.accordion-panel > legend { margin: 0; & > div { padding-top: 5px; - padding-bottom: 5px; } + & > div > div { + margin-bottom: 10px; + } +} +.tax-rule-lines .alert-danger { + margin: 10px 15px 5px; } .tax-rule-lines .tax-rule-line { border-bottom: 1px solid $input-border; diff --git a/src/pretix/static/schema/tax-rules-custom.schema.json b/src/pretix/static/schema/tax-rules-custom.schema.json index 5792c05bef..f88b7b8833 100644 --- a/src/pretix/static/schema/tax-rules-custom.schema.json +++ b/src/pretix/static/schema/tax-rules-custom.schema.json @@ -19,6 +19,75 @@ "description": "Action to take.", "enum": ["vat", "reverse", "no", "block", "require_approval"] }, + "code": { + "description": "Tax code to use instead of original tax code.", + "enum": [ + null, + "S/standard", + "S/reduced", + "S/averaged", + "AE", + "O", + "E", + "Z", + "G", + "K", + "L", + "M", + "B", + "E/VATEX-EU-79-C", + "E/VATEX-EU-132-1A", + "E/VATEX-EU-132-1B", + "E/VATEX-EU-132-1C", + "E/VATEX-EU-132-1D", + "E/VATEX-EU-132-1E", + "E/VATEX-EU-132-1F", + "E/VATEX-EU-132-1G", + "E/VATEX-EU-132-1H", + "E/VATEX-EU-132-1I", + "E/VATEX-EU-132-1J", + "E/VATEX-EU-132-1K", + "E/VATEX-EU-132-1L", + "E/VATEX-EU-132-1M", + "E/VATEX-EU-132-1N", + "E/VATEX-EU-132-1O", + "E/VATEX-EU-132-1P", + "E/VATEX-EU-132-1Q", + "E/VATEX-EU-143-1A", + "E/VATEX-EU-143-1B", + "E/VATEX-EU-143-1C", + "E/VATEX-EU-143-1D", + "E/VATEX-EU-143-1E", + "E/VATEX-EU-143-1F", + "E/VATEX-EU-143-1FA", + "E/VATEX-EU-143-1G", + "E/VATEX-EU-143-1H", + "E/VATEX-EU-143-1I", + "E/VATEX-EU-143-1J", + "E/VATEX-EU-143-1K", + "E/VATEX-EU-143-1L", + "E/VATEX-EU-148-A", + "E/VATEX-EU-148-B", + "E/VATEX-EU-148-C", + "E/VATEX-EU-148-D", + "E/VATEX-EU-148-E", + "E/VATEX-EU-148-F", + "E/VATEX-EU-148-G", + "E/VATEX-EU-151-1A", + "E/VATEX-EU-151-1AA", + "E/VATEX-EU-151-1B", + "E/VATEX-EU-151-1C", + "E/VATEX-EU-151-1D", + "E/VATEX-EU-151-1E", + "E/VATEX-EU-309", + "E/VATEX-EU-D", + "E/VATEX-EU-F", + "E/VATEX-EU-I", + "E/VATEX-EU-J", + "E/VATEX-FR-FRANCHISE", + "E/VATEX-FR-CNWVAT" + ] + }, "rate": { "description": "Tax rate in case of action=vat or action=require_approval (or null for default)", "type": ["string", "null"], diff --git a/src/tests/api/conftest.py b/src/tests/api/conftest.py index 8d537e7af5..672154f368 100644 --- a/src/tests/api/conftest.py +++ b/src/tests/api/conftest.py @@ -192,13 +192,13 @@ def subevent2(event2, meta_prop): @pytest.fixture @scopes_disabled() def taxrule(event): - return event.tax_rules.create(name="VAT", rate=19) + return event.tax_rules.create(name="VAT", rate=19, code="S/standard") @pytest.fixture @scopes_disabled() def taxrule0(event): - return event.tax_rules.create(name="VAT", rate=0) + return event.tax_rules.create(name="VAT", rate=0, code="E") @pytest.fixture diff --git a/src/tests/api/test_giftcards.py b/src/tests/api/test_giftcards.py index 4eeb5a0dcd..780b64f73e 100644 --- a/src/tests/api/test_giftcards.py +++ b/src/tests/api/test_giftcards.py @@ -151,6 +151,7 @@ def test_giftcard_detail_expand(token_client, organizer, event, giftcard): "voucher_budget_use": None, "tax_rate": "0.00", "tax_value": "0.00", + "tax_code": None, "secret": op.secret, "addon_to": None, "subevent": None, diff --git a/src/tests/api/test_invoices.py b/src/tests/api/test_invoices.py index 096452dda1..4043514eec 100644 --- a/src/tests/api/test_invoices.py +++ b/src/tests/api/test_invoices.py @@ -239,6 +239,7 @@ TEST_INVOICE_RES = { "gross_value": "23.00", "tax_value": "0.00", "tax_name": "", + "tax_code": None, "tax_rate": "0.00" }, { @@ -255,6 +256,7 @@ TEST_INVOICE_RES = { 'variation': None, "gross_value": "0.25", "tax_value": "0.05", + "tax_code": None, "tax_name": "", "tax_rate": "19.00" } diff --git a/src/tests/api/test_order_change.py b/src/tests/api/test_order_change.py index f40fb520c9..39ad72dc67 100644 --- a/src/tests/api/test_order_change.py +++ b/src/tests/api/test_order_change.py @@ -475,6 +475,7 @@ def test_order_create_invoice(token_client, organizer, event, order): 'gross_value': '23.00', 'tax_value': '0.00', 'tax_rate': '0.00', + 'tax_code': None, 'tax_name': '' }, { @@ -492,6 +493,7 @@ def test_order_create_invoice(token_client, organizer, event, order): 'gross_value': '0.25', 'tax_value': '0.05', 'tax_rate': '19.00', + 'tax_code': None, 'tax_name': '' } ], diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index fd0d5e807a..a164a50082 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -42,8 +42,13 @@ from pretix.base.models.orders import CartPosition, OrderFee, QuestionAnswer @pytest.fixture -def item(event): - return event.items.create(name="Budget Ticket", default_price=23) +def taxrule(event): + return event.tax_rules.create(rate=Decimal('19.00'), code="S/standard") + + +@pytest.fixture +def item(event, taxrule): + return event.items.create(name="Budget Ticket", default_price=23, tax_rule=taxrule) @pytest.fixture @@ -51,11 +56,6 @@ def item2(event2): return event2.items.create(name="Budget Ticket", default_price=23) -@pytest.fixture -def taxrule(event): - return event.tax_rules.create(rate=Decimal('19.00')) - - @pytest.fixture def medium(organizer): return organizer.reusable_media.create( @@ -414,6 +414,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques 'internal_type': '', 'tax_rate': '0.00', 'tax_value': '0.00', + 'tax_code': None, 'tax_rule': None, 'canceled': False } @@ -450,8 +451,9 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques 'attendee_email': None, 'voucher': voucher.pk, 'voucher_budget_use': '1.50', - 'tax_rate': '0.00', - 'tax_value': '0.00', + 'tax_rate': '19.00', + 'tax_value': '3.43', + 'tax_code': 'S/standard', 'addon_to': None, 'subevent': None, 'discount': None, @@ -466,7 +468,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques 'options': [opt.pk], 'option_identifiers': [opt.identifier]} ], - 'tax_rule': None, + 'tax_rule': item.tax_rule_id, 'pseudonymization_id': 'PREVIEW', 'seat': None, 'company': "FOOCORP", @@ -529,14 +531,14 @@ def test_order_create_positionids_addons_simulated(token_client, organizer, even {'id': 0, 'order': '', 'positionid': 1, 'item': item.pk, 'variation': None, 'price': '23.00', 'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None, 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, - 'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', 'discount': None, 'voucher_budget_use': None, - 'addon_to': None, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': None, + 'voucher': None, 'tax_rate': '19.00', 'tax_code': 'S/standard', 'tax_value': '3.67', 'discount': None, 'voucher_budget_use': None, + 'addon_to': None, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': item.tax_rule_id, 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None}, {'id': 0, 'order': '', 'positionid': 2, 'item': item.pk, 'variation': None, 'price': '23.00', 'attendee_name': 'Peter', 'attendee_name_parts': {'full_name': 'Peter', '_scheme': 'full'}, 'company': None, 'street': None, 'zipcode': None, 'city': None, 'country': None, 'state': None, 'attendee_email': None, - 'voucher': None, 'tax_rate': '0.00', 'tax_value': '0.00', 'discount': None, 'voucher_budget_use': None, - 'addon_to': 1, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': None, + 'voucher': None, 'tax_rate': '19.00', 'tax_code': 'S/standard', 'tax_value': '3.67', 'discount': None, 'voucher_budget_use': None, + 'addon_to': 1, 'subevent': None, 'checkins': [], 'print_logs': [], 'downloads': [], 'answers': [], 'tax_rule': item.tax_rule_id, 'pseudonymization_id': 'PREVIEW', 'seat': None, 'canceled': False, 'valid_from': None, 'valid_until': None, 'blocked': None} ] diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index deca4aee21..79d12951d5 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -50,7 +50,7 @@ def item2(event2): @pytest.fixture def taxrule(event): - return event.tax_rules.create(rate=Decimal('19.00')) + return event.tax_rules.create(rate=Decimal('19.00'), code="S/standard") @pytest.fixture @@ -111,9 +111,9 @@ def order(event, item, device, taxrule, question): amount=Decimal('23.00'), ) o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'), - tax_value=Decimal('0.05'), tax_rule=taxrule) + tax_value=Decimal('0.05'), tax_rule=taxrule, tax_code=taxrule.code) o.fees.create(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=Decimal('0.25'), tax_rate=Decimal('19.00'), - tax_value=Decimal('0.05'), tax_rule=taxrule, canceled=True) + tax_value=Decimal('0.05'), tax_rule=taxrule, tax_code=taxrule.code, canceled=True) InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'), vat_id="DE123", vat_id_validated=True, custom_field="Custom info") op = OrderPosition.objects.create( @@ -196,6 +196,7 @@ TEST_ORDERPOSITION_RES = { "tax_rate": "0.00", "tax_value": "0.00", "tax_rule": None, + "tax_code": None, "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "addon_to": None, "pseudonymization_id": "ABCDEFGHKL", @@ -297,6 +298,7 @@ TEST_ORDER_RES = { "description": "", "internal_type": "", "tax_rate": "19.00", + "tax_code": "S/standard", "tax_value": "0.05" } ], diff --git a/src/tests/api/test_reusable_media.py b/src/tests/api/test_reusable_media.py index c72e408c52..4a0ceea015 100644 --- a/src/tests/api/test_reusable_media.py +++ b/src/tests/api/test_reusable_media.py @@ -188,6 +188,7 @@ def test_medium_detail(token_client, organizer, event, medium, giftcard, custome "voucher_budget_use": None, "tax_rate": "0.00", "tax_value": "0.00", + "tax_code": None, "secret": op.secret, "addon_to": None, "subevent": None, diff --git a/src/tests/api/test_taxrules.py b/src/tests/api/test_taxrules.py index 807db12b98..a6d01dee3c 100644 --- a/src/tests/api/test_taxrules.py +++ b/src/tests/api/test_taxrules.py @@ -31,6 +31,7 @@ TEST_TAXRULE_RES = { 'keep_gross_if_rate_changes': False, 'name': {'en': 'VAT'}, 'rate': '19.00', + 'code': 'S/standard', 'price_includes_tax': True, 'eu_reverse_charge': False, 'home_country': '', diff --git a/src/tests/base/test_invoices.py b/src/tests/base/test_invoices.py index 981f24cf73..7004b1be1f 100644 --- a/src/tests/base/test_invoices.py +++ b/src/tests/base/test_invoices.py @@ -234,6 +234,7 @@ def test_reverse_charge_note(env): assert inv.foreign_currency_rate == Decimal("4.2408") assert inv.foreign_currency_rate_date == date.today() assert inv.foreign_currency_source == 'eu:ecb:eurofxref-daily' + assert inv.lines.first().tax_code == "AE" @pytest.mark.django_db @@ -249,6 +250,7 @@ def test_custom_tax_note(env): 'address_type': '', 'action': 'vat', 'rate': '20', + 'code': 'S/reduced', 'invoice_text': { 'de': 'Polnische Steuer anwendbar', 'en': 'Polish tax applies' @@ -268,6 +270,7 @@ def test_custom_tax_note(env): inv = generate_invoice(order) assert "Polish tax applies" in inv.additional_text + assert inv.lines.first().tax_code == "S/reduced" @pytest.mark.django_db diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 33cd4f057b..689832907c 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -1992,6 +1992,7 @@ class OrderChangeManagerTests(TestCase): assert nop.tax_value == Decimal('0.00') assert self.order.total == self.op1.price + self.op2.price + nop.price assert nop.positionid == 3 + assert self.order.transactions.filter(item=self.shirt).last().tax_code == "AE" @classscope(attr='o') def test_add_item_custom_price(self): @@ -2216,7 +2217,7 @@ class OrderChangeManagerTests(TestCase): self._enable_reverse_charge() self.tr7.custom_rules = json.dumps([ - {'country': 'AT', 'address_type': '', 'action': 'vat', 'rate': '100.00'} + {'country': 'AT', 'address_type': '', 'action': 'vat', 'rate': '100.00', 'code': 'S/reduced'} ]) self.tr7.save() @@ -2230,6 +2231,7 @@ class OrderChangeManagerTests(TestCase): assert self.order.total == Decimal('86.00') + fee.value assert self.order.transactions.count() == 7 + assert self.order.transactions.filter(item=op.item).last().tax_code == "S/reduced" @classscope(attr='o') def test_recalculate_country_rate_keep_gross(self): @@ -3270,6 +3272,7 @@ class OrderChangeManagerTests(TestCase): nop = self.order.positions.first() nop.tax_value = Decimal('0.00') nop.tax_rate = Decimal('0.00') + nop.tax_code = None nop.save() InvoiceAddress.objects.create( order=self.order, is_business=True, vat_id='ATU1234567', vat_id_validated=True, diff --git a/src/tests/base/test_taxrules.py b/src/tests/base/test_taxrules.py index d54971ecd6..b14cc25886 100644 --- a/src/tests/base/test_taxrules.py +++ b/src/tests/base/test_taxrules.py @@ -46,63 +46,82 @@ def event(): def test_from_gross_price(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=True + rate=Decimal('10.00'), + code='S/standard', + price_includes_tax=True ) tp = tr.tax(Decimal('100.00')) assert tp.gross == Decimal('100') assert tp.net == Decimal('90.91') assert tp.tax == Decimal('100.00') - Decimal('90.91') assert tp.rate == Decimal('10.00') + assert tp.code == 'S/standard' @pytest.mark.django_db def test_from_net_price(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False + rate=Decimal('10.00'), + code=None, + price_includes_tax=False, ) tp = tr.tax(Decimal('100.00')) assert tp.gross == Decimal('110.00') assert tp.net == Decimal('100.00') assert tp.tax == Decimal('10.00') assert tp.rate == Decimal('10.00') + assert tp.code is None @pytest.mark.django_db def test_reverse_charge_no_address(event): tr = TaxRule( - event=event, eu_reverse_charge=True, - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False, ) assert not tr.is_reverse_charge(None) assert tr._tax_applicable(None) + assert tr.tax_code_for(None) == "S/standard" @pytest.mark.django_db def test_reverse_charge_no_country(event): tr = TaxRule( - event=event, eu_reverse_charge=True, - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + rate=Decimal('10.00'), + price_includes_tax=False, + code="S/standard", ) ia = InvoiceAddress( ) assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @pytest.mark.django_db def test_reverse_charge_individual_same_country(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False, ) ia = InvoiceAddress( is_business=False, @@ -111,20 +130,26 @@ def test_reverse_charge_individual_same_country(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @pytest.mark.django_db def test_reverse_charge_individual_eu(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=False, @@ -133,20 +158,26 @@ def test_reverse_charge_individual_eu(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @pytest.mark.django_db def test_reverse_charge_individual_3rdc(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=False, @@ -155,20 +186,26 @@ def test_reverse_charge_individual_3rdc(event): 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_code_for(ia) == "O" 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='', + code='O', ) @pytest.mark.django_db def test_reverse_charge_business_same_country(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=True, @@ -177,20 +214,26 @@ def test_reverse_charge_business_same_country(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @pytest.mark.django_db def test_reverse_charge_business_eu(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=True, @@ -199,20 +242,26 @@ def test_reverse_charge_business_eu(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @pytest.mark.django_db def test_reverse_charge_business_3rdc(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=True, @@ -221,20 +270,26 @@ def test_reverse_charge_business_3rdc(event): 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_code_for(ia) == "O" 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='', + code='O', ) @pytest.mark.django_db def test_reverse_charge_valid_vat_id_business_same_country(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=True, @@ -245,20 +300,26 @@ def test_reverse_charge_valid_vat_id_business_same_country(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @pytest.mark.django_db def test_reverse_charge_valid_vat_id_business_eu(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=True, @@ -269,20 +330,26 @@ def test_reverse_charge_valid_vat_id_business_eu(event): assert tr.is_reverse_charge(ia) assert not tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('0.00') + assert tr.tax_code_for(ia) == "AE" 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='', + code='AE', ) @pytest.mark.django_db def test_reverse_charge_valid_vat_id_business_3rdc(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=True, @@ -293,20 +360,26 @@ def test_reverse_charge_valid_vat_id_business_3rdc(event): 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_code_for(ia) == "O" 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='', + code='O', ) @pytest.mark.django_db def test_reverse_charge_disabled(event): tr = TaxRule( - event=event, eu_reverse_charge=False, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False + event=event, + eu_reverse_charge=False, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False ) ia = InvoiceAddress( is_business=True, @@ -317,20 +390,26 @@ def test_reverse_charge_disabled(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @pytest.mark.django_db def test_custom_rules_override(event): tr = TaxRule( - event=event, eu_reverse_charge=True, home_country=Country('DE'), - rate=Decimal('10.00'), price_includes_tax=False, + event=event, + eu_reverse_charge=True, + home_country=Country('DE'), + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False, custom_rules=json.dumps([ {'country': 'ZZ', 'address_type': '', 'action': 'vat'} ]) @@ -344,12 +423,14 @@ def test_custom_rules_override(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @@ -357,9 +438,11 @@ def test_custom_rules_override(event): def test_custom_rules_in_order(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False, custom_rules=json.dumps([ - {'country': 'ZZ', 'address_type': '', 'action': 'vat'}, + {'country': 'ZZ', 'address_type': '', 'action': 'vat', 'code': 'S/reduced'}, {'country': 'ZZ', 'address_type': '', 'action': 'reverse'} ]) ) @@ -372,12 +455,14 @@ def test_custom_rules_in_order(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/reduced" 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='', + code="S/reduced", ) @@ -385,9 +470,11 @@ def test_custom_rules_in_order(event): def test_custom_rules_any_country(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False, custom_rules=json.dumps([ - {'country': 'ZZ', 'address_type': '', 'action': 'no'}, + {'country': 'ZZ', 'address_type': '', 'action': 'no', 'code': 'O'}, ]) ) ia = InvoiceAddress( @@ -397,12 +484,14 @@ def test_custom_rules_any_country(event): 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_code_for(ia) == "O" 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='', + code="O", ) @@ -410,9 +499,11 @@ def test_custom_rules_any_country(event): def test_custom_rules_eu_country(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False, custom_rules=json.dumps([ - {'country': 'EU', 'address_type': '', 'action': 'no'}, + {'country': 'EU', 'address_type': '', 'action': 'no', 'code': 'Z'}, ]) ) ia = InvoiceAddress( @@ -422,18 +513,21 @@ def test_custom_rules_eu_country(event): 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_code_for(ia) == "Z" ia = InvoiceAddress( is_business=True, country=Country('US') ) assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) @@ -441,9 +535,11 @@ def test_custom_rules_eu_country(event): def test_custom_rules_specific_country(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + code="S/standard", + price_includes_tax=False, custom_rules=json.dumps([ - {'country': 'AT', 'address_type': '', 'action': 'no'}, + {'country': 'AT', 'address_type': '', 'action': 'no', 'code': 'Z'}, ]) ) ia = InvoiceAddress( @@ -453,12 +549,14 @@ def test_custom_rules_specific_country(event): 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_code_for(ia) == "Z" 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='', + code="Z", ) ia = InvoiceAddress( @@ -474,6 +572,7 @@ def test_custom_rules_specific_country(event): tax=Decimal('10.00'), rate=Decimal('10.00'), name='', + code="S/standard", ) @@ -481,7 +580,8 @@ def test_custom_rules_specific_country(event): def test_custom_rules_specific_state(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + 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'}, @@ -501,6 +601,7 @@ def test_custom_rules_specific_state(event): tax=Decimal('10.00'), rate=Decimal('10.00'), name='', + code=None, ) ia = InvoiceAddress( @@ -517,6 +618,7 @@ def test_custom_rules_specific_state(event): tax=Decimal('30.00'), rate=Decimal('30.00'), name='', + code=None, ) ia = InvoiceAddress( @@ -533,6 +635,7 @@ def test_custom_rules_specific_state(event): tax=Decimal('20.00'), rate=Decimal('20.00'), name='', + code=None, ) ia = InvoiceAddress( @@ -549,6 +652,7 @@ def test_custom_rules_specific_state(event): tax=Decimal('0.00'), rate=Decimal('0.00'), name='', + code=None, ) @@ -556,7 +660,8 @@ def test_custom_rules_specific_state(event): def test_custom_rules_individual(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + price_includes_tax=False, custom_rules=json.dumps([ {'country': 'ZZ', 'address_type': 'individual', 'action': 'no'}, ]) @@ -574,6 +679,7 @@ def test_custom_rules_individual(event): tax=Decimal('0.00'), rate=Decimal('0.00'), name='', + code=None, ) ia = InvoiceAddress( @@ -589,6 +695,7 @@ def test_custom_rules_individual(event): tax=Decimal('10.00'), rate=Decimal('10.00'), name='', + code=None, ) @@ -596,7 +703,8 @@ def test_custom_rules_individual(event): def test_custom_rules_business(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + price_includes_tax=False, custom_rules=json.dumps([ {'country': 'ZZ', 'address_type': 'business', 'action': 'no'}, ]) @@ -614,6 +722,7 @@ def test_custom_rules_business(event): tax=Decimal('0.00'), rate=Decimal('0.00'), name='', + code=None, ) ia = InvoiceAddress( @@ -629,6 +738,7 @@ def test_custom_rules_business(event): tax=Decimal('10.00'), rate=Decimal('10.00'), name='', + code=None, ) @@ -636,7 +746,9 @@ def test_custom_rules_business(event): def test_custom_rules_vat_id(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + price_includes_tax=False, + code="S/standard", custom_rules=json.dumps([ {'country': 'EU', 'address_type': 'business_vat_id', 'action': 'reverse'}, ]) @@ -648,12 +760,14 @@ def test_custom_rules_vat_id(event): assert not tr.is_reverse_charge(ia) assert tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('10.00') + assert tr.tax_code_for(ia) == "S/standard" 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='', + code="S/standard", ) ia = InvoiceAddress( @@ -665,12 +779,14 @@ def test_custom_rules_vat_id(event): assert tr.is_reverse_charge(ia) assert not tr._tax_applicable(ia) assert tr.tax_rate_for(ia) == Decimal('0.00') + assert tr.tax_code_for(ia) == "AE" 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='', + code='AE', ) @@ -678,7 +794,8 @@ def test_custom_rules_vat_id(event): def test_custom_rules_country_rate(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + price_includes_tax=False, custom_rules=json.dumps([ {'country': 'EU', 'address_type': 'business_vat_id', 'action': 'vat', 'rate': '100.00'}, ]) @@ -696,6 +813,7 @@ def test_custom_rules_country_rate(event): tax=Decimal('10.00'), rate=Decimal('10.00'), name='', + code=None, ) ia = InvoiceAddress( is_business=True, @@ -712,6 +830,7 @@ def test_custom_rules_country_rate(event): tax=Decimal('100.00'), rate=Decimal('100.00'), name='', + code=None, ) @@ -719,7 +838,8 @@ def test_custom_rules_country_rate(event): def test_custom_rules_country_rate_keep_gross_if_rate_changes(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + price_includes_tax=False, keep_gross_if_rate_changes=True, custom_rules=json.dumps([ {'country': 'EU', 'address_type': 'business_vat_id', 'action': 'vat', 'rate': '100.00'}, @@ -738,6 +858,7 @@ def test_custom_rules_country_rate_keep_gross_if_rate_changes(event): tax=Decimal('10.00'), rate=Decimal('10.00'), name='', + code=None, ) ia = InvoiceAddress( is_business=True, @@ -754,6 +875,7 @@ def test_custom_rules_country_rate_keep_gross_if_rate_changes(event): tax=Decimal('55.00'), rate=Decimal('100.00'), name='', + code=None, ) @@ -761,7 +883,8 @@ def test_custom_rules_country_rate_keep_gross_if_rate_changes(event): def test_custom_rules_country_rate_subtract_from_gross(event): tr = TaxRule( event=event, - rate=Decimal('10.00'), price_includes_tax=False, + rate=Decimal('10.00'), + price_includes_tax=False, custom_rules=json.dumps([ {'country': 'EU', 'address_type': 'business_vat_id', 'action': 'vat', 'rate': '100.00'}, ]) @@ -781,6 +904,7 @@ def test_custom_rules_country_rate_subtract_from_gross(event): tax=Decimal('81.82'), rate=Decimal('100.00'), name='', + code=None, )