mirror of
https://github.com/pretix/pretix.git
synced 2026-05-07 15:34:02 +00:00
New data model for default tax rule and new options for cancellation fees (#4962)
* New data model for default tax rule * Remove misleading empty label when field is not optional * Allow to split cancellation fee * Fix API and tests * Update migration * Update src/tests/api/test_taxrules.py Co-authored-by: luelista <weller@rami.io> * Update src/tests/api/test_taxrules.py Co-authored-by: luelista <weller@rami.io> * Review note * Update src/pretix/base/models/tax.py Co-authored-by: luelista <weller@rami.io> * Flip API behaviour for default * Fix failing tests * Fix failing test * Split migration --------- Co-authored-by: luelista <weller@rami.io>
This commit is contained in:
24
src/pretix/base/migrations/0282_taxrule_default.py
Normal file
24
src/pretix/base/migrations/0282_taxrule_default.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0281_event_is_remote"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="taxrule",
|
||||
name="default",
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name="taxrule",
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(("default", True)),
|
||||
fields=("event",),
|
||||
name="one_default_per_event",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 4.2.17 on 2025-03-28 09:19
|
||||
from django.core.cache import cache
|
||||
from django.db import migrations, models
|
||||
from django.db.models import Count, Exists, OuterRef
|
||||
|
||||
|
||||
def set_default_tax_rate(app, schema_editor):
|
||||
Event = app.get_model('pretixbase', 'Event')
|
||||
Event_SettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
|
||||
TaxRule = app.get_model('pretixbase', 'TaxRule')
|
||||
|
||||
# Handling of events with tax_rate_default set
|
||||
for s in Event_SettingsStore.objects.filter(key="tax_rate_default"):
|
||||
updated = TaxRule.objects.filter(pk=s.value, event_id=s.object_id).update(default=True)
|
||||
if updated:
|
||||
# Delete deprecated settings key
|
||||
s.delete()
|
||||
|
||||
# The default for new events is tax_rule_cancellation=none, but since we do not change behaviour
|
||||
# for existing events without warning, we create a settings entry that matches the old behaviour.
|
||||
Event_SettingsStore.objects.get_or_create(
|
||||
object_id=s.object_id,
|
||||
key="tax_rule_cancellation",
|
||||
defaults={"value": "default"},
|
||||
)
|
||||
|
||||
# We do not need to set tax_rule_payment here since "default" is the default
|
||||
|
||||
cache.delete('hierarkey_{}_{}'.format('event', s.object_id))
|
||||
|
||||
# Handling of events with tax_rate_default not set
|
||||
for e in Event.objects.only("pk").exclude(Exists(TaxRule.objects.filter(default=True, event_id=OuterRef("pk")))):
|
||||
fav_tax_rules = e.tax_rules.annotate(c=Count("item")).order_by("-c", "pk")[:1]
|
||||
if fav_tax_rules:
|
||||
fav_tax_rules[0].default = True
|
||||
fav_tax_rules[0].save()
|
||||
|
||||
# Previously, no tax rule was set for payments, so keep it this way
|
||||
Event_SettingsStore.objects.get_or_create(
|
||||
object=e,
|
||||
key="tax_rule_payment",
|
||||
defaults={"value": "none"},
|
||||
)
|
||||
cache.delete('hierarkey_{}_{}'.format('event', e.pk))
|
||||
|
||||
# We do not need to set tax_rule_cancellation, as "none" is the new system default
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0282_taxrule_default"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
set_default_tax_rate,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -1113,13 +1113,6 @@ class Event(EventMixin, LoggedModel):
|
||||
newname = default_storage.save(fname, fi)
|
||||
s.value = 'file://' + newname
|
||||
settings_to_save.append(s)
|
||||
elif s.key == 'tax_rate_default':
|
||||
try:
|
||||
if int(s.value) in tax_map:
|
||||
s.value = tax_map.get(int(s.value)).pk
|
||||
settings_to_save.append(s)
|
||||
except ValueError:
|
||||
pass
|
||||
elif s.key.startswith('payment_') and s.key.endswith('__restrict_to_sales_channels'):
|
||||
data = other.settings._unserialize(s.value, as_type=list)
|
||||
data = [ident for ident in data if ident in valid_sales_channel_identifers]
|
||||
@@ -1198,6 +1191,10 @@ class Event(EventMixin, LoggedModel):
|
||||
renderers[pp.identifier] = pp
|
||||
return renderers
|
||||
|
||||
@cached_property
|
||||
def cached_default_tax_rule(self):
|
||||
return self.tax_rules.filter(default=True).first()
|
||||
|
||||
@cached_property
|
||||
def ticket_secret_generators(self) -> dict:
|
||||
"""
|
||||
|
||||
@@ -2373,17 +2373,17 @@ class OrderFee(models.Model):
|
||||
self.fee_type, self.value
|
||||
)
|
||||
|
||||
def _calculate_tax(self, tax_rule=None):
|
||||
def _calculate_tax(self, tax_rule=None, invoice_address=None):
|
||||
if tax_rule:
|
||||
self.tax_rule = tax_rule
|
||||
|
||||
try:
|
||||
ia = self.order.invoice_address
|
||||
ia = invoice_address or self.order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
|
||||
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rate_default:
|
||||
self.tax_rule = self.order.event.settings.tax_rate_default
|
||||
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default":
|
||||
self.tax_rule = self.order.event.cached_default_tax_rule
|
||||
|
||||
if self.tax_rule:
|
||||
tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
|
||||
|
||||
@@ -377,9 +377,20 @@ class TaxRule(LoggedModel):
|
||||
'if configured above.'),
|
||||
)
|
||||
custom_rules = models.TextField(blank=True, null=True)
|
||||
default = models.BooleanField(
|
||||
verbose_name=_('Default'),
|
||||
default=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ('event', 'rate', 'id')
|
||||
constraints = [
|
||||
models.UniqueConstraint(
|
||||
fields=["event"],
|
||||
condition=models.Q(default=True),
|
||||
name="one_default_per_event",
|
||||
),
|
||||
]
|
||||
|
||||
class SaleNotAllowed(Exception):
|
||||
pass
|
||||
@@ -394,7 +405,7 @@ class TaxRule(LoggedModel):
|
||||
and not OrderFee.objects.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not OrderPosition.all.filter(tax_rule=self, order__event=self.event).exists()
|
||||
and not self.event.items.filter(tax_rule=self).exists()
|
||||
and self.event.settings.tax_rate_default != self
|
||||
and not (self.default and self.event.tax_rules.filter(~models.Q(pk=self.pk)).exists())
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -32,7 +32,7 @@ from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
Event, InvoiceAddress, Order, OrderFee, OrderPosition, OrderRefund,
|
||||
SubEvent, User, WaitingListEntry,
|
||||
SubEvent, TaxRule, User, WaitingListEntry,
|
||||
)
|
||||
from pretix.base.services.locking import LockTimeoutException
|
||||
from pretix.base.services.mail import SendMailException, mail
|
||||
@@ -40,6 +40,7 @@ from pretix.base.services.orders import (
|
||||
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
|
||||
)
|
||||
from pretix.base.services.tasks import ProfiledEventTask
|
||||
from pretix.base.services.tax import split_fee_for_taxes
|
||||
from pretix.celery_app import app
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.format import format_map
|
||||
@@ -268,14 +269,34 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
|
||||
fee += Decimal(keep_fee_percentage) / Decimal('100.00') * total
|
||||
fee = round_decimal(min(fee, o.payment_refund_sum), event.currency)
|
||||
if fee:
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=fee,
|
||||
order=o,
|
||||
tax_rule=o.event.settings.tax_rate_default,
|
||||
)
|
||||
f._calculate_tax()
|
||||
ocm.add_fee(f)
|
||||
tax_rule_zero = TaxRule.zero()
|
||||
if event.settings.tax_rule_cancellation == "default":
|
||||
fee_values = [(event.cached_default_tax_rule or tax_rule_zero, fee)]
|
||||
elif event.settings.tax_rule_cancellation == "split":
|
||||
fee_values = split_fee_for_taxes(positions, fee, event)
|
||||
else:
|
||||
fee_values = [(tax_rule_zero, fee)]
|
||||
|
||||
try:
|
||||
ia = o.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
|
||||
for tax_rule, price in fee_values:
|
||||
tax_rule = tax_rule or tax_rule_zero
|
||||
tax = tax_rule.tax(
|
||||
price, invoice_address=ia, base_price_is="gross"
|
||||
)
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=price,
|
||||
order=o,
|
||||
tax_rate=tax.rate,
|
||||
tax_code=tax.code,
|
||||
tax_value=tax.tax,
|
||||
tax_rule=tax_rule,
|
||||
)
|
||||
ocm.add_fee(f)
|
||||
|
||||
ocm.commit()
|
||||
refund_amount = o.payment_refund_sum - o.total
|
||||
|
||||
@@ -1534,7 +1534,10 @@ def get_fees(event, request, total, invoice_address, payments, positions):
|
||||
total_remaining -= to_pay
|
||||
|
||||
if payment_fee:
|
||||
payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero()
|
||||
if event.settings.tax_rule_payment == "default":
|
||||
payment_fee_tax_rule = event.cached_default_tax_rule or TaxRule.zero()
|
||||
else:
|
||||
payment_fee_tax_rule = TaxRule.zero()
|
||||
payment_fee_tax = payment_fee_tax_rule.tax(payment_fee, base_price_is='gross', invoice_address=invoice_address)
|
||||
fees.append(OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_PAYMENT,
|
||||
|
||||
@@ -62,6 +62,7 @@ from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
|
||||
from django_scopes import scopes_disabled
|
||||
|
||||
from pretix.api.models import OAuthApplication
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.email import get_email_context
|
||||
from pretix.base.i18n import get_language_without_region, language
|
||||
from pretix.base.media import MEDIA_TYPES
|
||||
@@ -96,6 +97,7 @@ from pretix.base.services.pricing import (
|
||||
)
|
||||
from pretix.base.services.quotas import QuotaAvailability
|
||||
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
|
||||
from pretix.base.services.tax import split_fee_for_taxes
|
||||
from pretix.base.signals import (
|
||||
order_approved, order_canceled, order_changed, order_denied, order_expired,
|
||||
order_expiry_changed, order_fee_calculation, order_paid, order_placed,
|
||||
@@ -486,7 +488,7 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
|
||||
|
||||
|
||||
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None,
|
||||
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None):
|
||||
cancellation_fee=None, keep_fees=None, cancel_invoice=True, comment=None, tax_mode=None):
|
||||
"""
|
||||
Mark this order as canceled
|
||||
:param order: The order to change
|
||||
@@ -506,6 +508,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
|
||||
if isinstance(cancellation_fee, str):
|
||||
cancellation_fee = Decimal(cancellation_fee)
|
||||
elif isinstance(cancellation_fee, (float, int)):
|
||||
cancellation_fee = round_decimal(cancellation_fee, order.event.currency)
|
||||
|
||||
tax_mode = tax_mode or order.event.settings.tax_rule_cancellation
|
||||
|
||||
if not order.cancel_allowed():
|
||||
raise OrderError(_('You cannot cancel this order.'))
|
||||
@@ -533,7 +539,9 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
m.save()
|
||||
|
||||
if cancellation_fee:
|
||||
positions = []
|
||||
for position in order.positions.all():
|
||||
positions.append(position)
|
||||
if position.voucher:
|
||||
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
|
||||
position.canceled = True
|
||||
@@ -546,18 +554,39 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
|
||||
if keep_fees and fee in keep_fees:
|
||||
new_fee -= fee.value
|
||||
else:
|
||||
positions.append(fee)
|
||||
fee.canceled = True
|
||||
fee.save(update_fields=['canceled'])
|
||||
|
||||
if new_fee:
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=new_fee,
|
||||
tax_rule=order.event.settings.tax_rate_default,
|
||||
order=order,
|
||||
)
|
||||
f._calculate_tax()
|
||||
f.save()
|
||||
tax_rule_zero = TaxRule.zero()
|
||||
if tax_mode == "default":
|
||||
fee_values = [(order.event.cached_default_tax_rule or tax_rule_zero, new_fee)]
|
||||
elif tax_mode == "split":
|
||||
fee_values = split_fee_for_taxes(positions, new_fee, order.event)
|
||||
else:
|
||||
fee_values = [(tax_rule_zero, new_fee)]
|
||||
|
||||
try:
|
||||
ia = order.invoice_address
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
ia = None
|
||||
|
||||
for tax_rule, price in fee_values:
|
||||
tax_rule = tax_rule or tax_rule_zero
|
||||
tax = tax_rule.tax(
|
||||
price, invoice_address=ia, base_price_is="gross"
|
||||
)
|
||||
f = OrderFee(
|
||||
fee_type=OrderFee.FEE_TYPE_CANCELLATION,
|
||||
value=price,
|
||||
order=order,
|
||||
tax_rate=tax.rate,
|
||||
tax_code=tax.code,
|
||||
tax_value=tax.tax,
|
||||
tax_rule=tax_rule,
|
||||
)
|
||||
f.save()
|
||||
|
||||
if cancellation_fee > order.total:
|
||||
raise OrderError(_('The cancellation fee cannot be higher than the total amount of this order.'))
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from decimal import Decimal
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import requests
|
||||
@@ -32,7 +34,9 @@ from zeep import Client, Transport
|
||||
from zeep.cache import SqliteCache
|
||||
from zeep.exceptions import Fault
|
||||
|
||||
from pretix.base.models.tax import cc_to_vat_prefix, is_eu_country
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.models import CartPosition, Event, OrderFee
|
||||
from pretix.base.models.tax import TaxRule, cc_to_vat_prefix, is_eu_country
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
error_messages = {
|
||||
@@ -229,3 +233,64 @@ def validate_vat_id(vat_id, country_code):
|
||||
return _validate_vat_id_NO(vat_id, country_code)
|
||||
|
||||
raise VATIDTemporaryError(f'VAT ID should not be entered for country {country_code}')
|
||||
|
||||
|
||||
def split_fee_for_taxes(positions: list, fee_value: Decimal, event: Event):
|
||||
"""
|
||||
Given a list of either OrderPosition, OrderFee or CartPosition objects and the total value
|
||||
of a fee, this will return a list of [(tax_rule, fee_value)] tuples that distributes the
|
||||
taxes over the same tax rules as the positions with a value representative to the value
|
||||
the tax rule.
|
||||
|
||||
Since the input fee_value is a gross value, we also split it by the gross percentages of
|
||||
positions. This will lead to the same result as if we split the net value by net percentages,
|
||||
but is easier to compute.
|
||||
"""
|
||||
d = defaultdict(lambda: Decimal("0.00"))
|
||||
tax_rule_zero = TaxRule.zero()
|
||||
trs = {}
|
||||
for p in positions:
|
||||
if isinstance(p, CartPosition):
|
||||
tr = p.item.tax_rule
|
||||
v = p.price
|
||||
elif isinstance(p, OrderFee):
|
||||
tr = p.tax_rule
|
||||
v = p.value
|
||||
else:
|
||||
tr = p.tax_rule
|
||||
v = p.price
|
||||
if not tr:
|
||||
tr = tax_rule_zero
|
||||
# use tr.pk as key as tax_rule_zero is not hashable
|
||||
d[tr.pk] += v
|
||||
trs[tr.pk] = tr
|
||||
|
||||
base_values = sorted([(trs[key], value) for key, value in d.items()], key=lambda t: t[0].rate)
|
||||
sum_base = sum(value for key, value in base_values)
|
||||
if sum_base:
|
||||
fee_values = [
|
||||
(key, round_decimal(fee_value * value / sum_base, event.currency))
|
||||
for key, value in base_values
|
||||
]
|
||||
sum_fee = sum(value for key, value in fee_values)
|
||||
|
||||
# If there are rounding differences, we fix them up, but always leaning to the benefit of the tax
|
||||
# authorities
|
||||
if sum_fee > fee_value:
|
||||
fee_values[0] = (
|
||||
fee_values[0][0],
|
||||
fee_values[0][1] + (fee_value - sum_fee),
|
||||
)
|
||||
elif sum_fee < fee_value:
|
||||
fee_values[-1] = (
|
||||
fee_values[-1][0],
|
||||
fee_values[-1][1] + (fee_value - sum_fee),
|
||||
)
|
||||
elif len(d) == 1:
|
||||
# Rare edge case: All positions are 0-valued, but have a common tax rate. Could happen e.g. with a discount
|
||||
# that reduces all positions to 0, but not the shipping fees. Let's use that tax rate!
|
||||
fee_values = [(list(trs.values())[0], fee_value)]
|
||||
else:
|
||||
# All positions are zero-valued, and we have no clear tax rate, so we use the default tax rate.
|
||||
fee_values = [(event.cached_default_tax_rule or tax_rule_zero, fee_value)]
|
||||
return fee_values
|
||||
|
||||
@@ -66,7 +66,7 @@ from pretix.api.serializers.fields import (
|
||||
)
|
||||
from pretix.api.serializers.i18n import I18nURLField
|
||||
from pretix.base.forms import I18nMarkdownTextarea, I18nURLFormField
|
||||
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
|
||||
from pretix.base.models.tax import VAT_ID_COUNTRIES
|
||||
from pretix.base.reldate import (
|
||||
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
|
||||
SerializerRelativeDateField, SerializerRelativeDateTimeField,
|
||||
@@ -1027,9 +1027,47 @@ DEFAULTS = {
|
||||
widget=forms.CheckboxInput,
|
||||
)
|
||||
},
|
||||
'tax_rate_default': {
|
||||
'default': None,
|
||||
'type': TaxRule
|
||||
'tax_rule_payment': {
|
||||
'default': 'default',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'serializer_kwargs': dict(
|
||||
choices=(
|
||||
('default', _('Use default tax rate')),
|
||||
('none', _('Charge no taxes')),
|
||||
),
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_("Tax handling on payment fees"),
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('default', _('Use default tax rate')),
|
||||
('none', _('Charge no taxes')),
|
||||
),
|
||||
)
|
||||
},
|
||||
'tax_rule_cancellation': {
|
||||
'default': 'none',
|
||||
'type': str,
|
||||
'form_class': forms.ChoiceField,
|
||||
'serializer_class': serializers.ChoiceField,
|
||||
'serializer_kwargs': dict(
|
||||
choices=(
|
||||
('none', _('Charge no taxes')),
|
||||
('split', _('Use same taxes as order positions (split according to net prices)')),
|
||||
('default', _('Use default tax rate')),
|
||||
),
|
||||
),
|
||||
'form_kwargs': dict(
|
||||
label=_("Tax handling on cancellation fees"),
|
||||
widget=forms.RadioSelect,
|
||||
choices=(
|
||||
('none', _('Charge no taxes')),
|
||||
('split', _('Use same taxes as order positions (split according to net prices)')),
|
||||
('default', _('Use default tax rate')),
|
||||
),
|
||||
)
|
||||
},
|
||||
'invoice_generate': {
|
||||
'default': 'False',
|
||||
|
||||
Reference in New Issue
Block a user