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:
Raphael Michel
2025-06-30 16:47:09 +02:00
committed by GitHub
parent 090358833d
commit 14ed6982a5
34 changed files with 615 additions and 104 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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.'))

View File

@@ -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