Allow to round taxes on order-level

This commit is contained in:
Raphael Michel
2025-04-20 20:00:23 +02:00
parent b4264c0ae7
commit 3735b6b5a9
14 changed files with 555 additions and 77 deletions

View File

@@ -829,6 +829,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_eu_currencies', 'invoice_eu_currencies',
'invoice_logo_image', 'invoice_logo_image',
'invoice_renderer_highlight_order_code', 'invoice_renderer_highlight_order_code',
'tax_rounding',
'cancel_allow_user', 'cancel_allow_user',
'cancel_allow_user_until', 'cancel_allow_user_until',
'cancel_allow_user_unpaid_keep', 'cancel_allow_user_unpaid_keep',

View File

@@ -0,0 +1,76 @@
# Generated by Django 4.2.17 on 2025-04-20 13:58
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0284_ordersyncresult_ordersyncqueue"),
]
operations = [
migrations.AddField(
model_name="cartposition",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="cartposition",
name="tax_code",
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name="cartposition",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderfee",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderfee",
name="value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderposition",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="orderposition",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="transaction",
name="price_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
migrations.AddField(
model_name="transaction",
name="tax_value_includes_rounding_correction",
field=models.DecimalField(
decimal_places=2, default=Decimal("0.00"), max_digits=13
),
),
]

View File

@@ -1259,7 +1259,8 @@ class Order(LockModel, LoggedModel):
keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys()) keys = set(target_transaction_count.keys()) | set(current_transaction_count.keys())
create = [] create = []
for k in keys: for k in keys:
positionid, itemid, variationid, subeventid, price, taxrate, taxruleid, taxvalue, feetype, internaltype, taxcode = k (positionid, itemid, variationid, subeventid, price, price_includes_rounding_correction, taxrate,
taxruleid, taxvalue, taxvalue_includes_rounding_correction, feetype, internaltype, taxcode) = k
d = target_transaction_count[k] - current_transaction_count[k] d = target_transaction_count[k] - current_transaction_count[k]
if d: if d:
create.append(Transaction( create.append(Transaction(
@@ -1272,9 +1273,11 @@ class Order(LockModel, LoggedModel):
variation_id=variationid, variation_id=variationid,
subevent_id=subeventid, subevent_id=subeventid,
price=price, price=price,
price_includes_rounding_correction=price_includes_rounding_correction,
tax_rate=taxrate, tax_rate=taxrate,
tax_rule_id=taxruleid, tax_rule_id=taxruleid,
tax_value=taxvalue, tax_value=taxvalue,
tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction,
tax_code=taxcode, tax_code=taxcode,
fee_type=feetype, fee_type=feetype,
internal_type=internaltype, internal_type=internaltype,
@@ -1499,6 +1502,9 @@ class AbstractPosition(models.Model):
decimal_places=2, max_digits=13, decimal_places=2, max_digits=13,
verbose_name=_("Price") verbose_name=_("Price")
) )
price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
attendee_name_cached = models.CharField( attendee_name_cached = models.CharField(
max_length=255, max_length=255,
verbose_name=_("Attendee name"), verbose_name=_("Attendee name"),
@@ -2301,6 +2307,9 @@ class OrderFee(models.Model):
decimal_places=2, max_digits=13, decimal_places=2, max_digits=13,
verbose_name=_("Value") verbose_name=_("Value")
) )
value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
verbose_name=_("Order"), verbose_name=_("Order"),
@@ -2329,6 +2338,9 @@ class OrderFee(models.Model):
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = ScopedManager(organizer='order__event__organizer') all = ScopedManager(organizer='order__event__organizer')
@@ -2377,17 +2389,23 @@ class OrderFee(models.Model):
self.fee_type, self.value self.fee_type, self.value
) )
def _calculate_tax(self, tax_rule=None, invoice_address=None): def _calculate_tax(self, tax_rule=None, invoice_address=None, event=None):
if tax_rule: if tax_rule:
self.tax_rule = tax_rule self.tax_rule = tax_rule
try: if invoice_address:
ia = invoice_address or self.order.invoice_address ia = invoice_address
except InvoiceAddress.DoesNotExist: elif hasattr(self, "order"):
try:
ia = self.order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
else:
ia = None ia = None
if not self.tax_rule and self.fee_type == "payment" and self.order.event.settings.tax_rule_payment == "default": event = event or self.order.event
self.tax_rule = self.order.event.cached_default_tax_rule if not self.tax_rule and self.fee_type == "payment" and event.settings.tax_rule_payment == "default":
self.tax_rule = event.cached_default_tax_rule
if self.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) tax = self.tax_rule.tax(self.value, base_price_is='gross', invoice_address=ia, force_fixed_gross_price=True)
@@ -2422,6 +2440,24 @@ class OrderFee(models.Model):
self.order.touch() self.order.touch()
super().delete(**kwargs) super().delete(**kwargs)
# For historical reasons, OrderFee has "value", but OrderPosition has "price". These properties
# help using them the same way.
@property
def price(self):
return self.value
@price.setter
def price(self, value):
self.value = value
@property
def price_includes_rounding_correction(self):
return self.value_includes_rounding_correction
@price_includes_rounding_correction.setter
def price_includes_rounding_correction(self, value):
self.value_includes_rounding_correction = value
class OrderPosition(AbstractPosition): class OrderPosition(AbstractPosition):
""" """
@@ -2501,6 +2537,9 @@ class OrderPosition(AbstractPosition):
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00"),
)
secret = models.CharField(max_length=255, null=False, blank=False, db_index=True) secret = models.CharField(max_length=255, null=False, blank=False, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True) web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
@@ -2673,7 +2712,14 @@ class OrderPosition(AbstractPosition):
setattr(op, f.name, cp_mapping[cartpos.addon_to_id]) setattr(op, f.name, cp_mapping[cartpos.addon_to_id])
else: else:
setattr(op, f.name, getattr(cartpos, f.name)) setattr(op, f.name, getattr(cartpos, f.name))
op._calculate_tax()
op.tax_value = cartpos.tax_value
op.tax_value_includes_rounding_correction = cartpos.tax_value_includes_rounding_correction
op.tax_rate = cartpos.tax_rate
op.tax_code = cartpos.tax_code
op.tax_rule = cartpos.item.tax_rule
# todo: is removing this safe? op._calculate_tax()
if cartpos.voucher: if cartpos.voucher:
op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher op.voucher_budget_use = cartpos.listed_price - cartpos.price_after_voucher
@@ -3006,6 +3052,9 @@ class Transaction(models.Model):
decimal_places=2, max_digits=13, decimal_places=2, max_digits=13,
verbose_name=_("Price") verbose_name=_("Price")
) )
price_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
tax_rate = models.DecimalField( tax_rate = models.DecimalField(
max_digits=7, decimal_places=2, max_digits=7, decimal_places=2,
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
@@ -3023,6 +3072,9 @@ class Transaction(models.Model):
max_digits=13, decimal_places=2, max_digits=13, decimal_places=2,
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
fee_type = models.CharField( fee_type = models.CharField(
max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True
) )
@@ -3052,14 +3104,19 @@ class Transaction(models.Model):
@staticmethod @staticmethod
def key(obj): def key(obj):
if isinstance(obj, Transaction): if isinstance(obj, Transaction):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
obj.tax_value, obj.tax_value_includes_rounding_correction, obj.fee_type,
obj.internal_type, obj.tax_code)
elif isinstance(obj, OrderPosition): elif isinstance(obj, OrderPosition):
return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, obj.tax_rate, return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price,
obj.tax_rule_id, obj.tax_value, None, None, obj.tax_code) obj.price_includes_rounding_correction, obj.tax_rate, obj.tax_rule_id,
obj.tax_value, obj.tax_value_includes_rounding_correction, None,
None, obj.tax_code)
elif isinstance(obj, OrderFee): elif isinstance(obj, OrderFee):
return (None, None, None, None, obj.value, obj.tax_rate, return (None, None, None, None, obj.value, obj.value_includes_rounding_correction,
obj.tax_rule_id, obj.tax_value, obj.fee_type, obj.internal_type, obj.tax_code) obj.tax_rate, obj.tax_rule_id, obj.tax_value, obj.tax_value_includes_rounding_correction,
obj.fee_type, obj.internal_type, obj.tax_code)
raise ValueError('invalid state') # noqa raise ValueError('invalid state') # noqa
@property @property
@@ -3070,6 +3127,14 @@ class Transaction(models.Model):
def full_tax_value(self): def full_tax_value(self):
return self.tax_value * self.count return self.tax_value * self.count
@property
def full_price_includes_rounding_correction(self):
return self.price_includes_rounding_correction * self.count
@property
def full_tax_value_includes_rounding_correction(self):
return self.tax_value_includes_rounding_correction * self.count
class CartPosition(AbstractPosition): class CartPosition(AbstractPosition):
""" """
@@ -3110,6 +3175,13 @@ class CartPosition(AbstractPosition):
max_digits=7, decimal_places=2, default=Decimal('0.00'), max_digits=7, decimal_places=2, default=Decimal('0.00'),
verbose_name=_('Tax rate') verbose_name=_('Tax rate')
) )
tax_code = models.CharField(
max_length=190,
null=True, blank=True,
)
tax_value_includes_rounding_correction = models.DecimalField(
max_digits=13, decimal_places=2, default=Decimal("0.00")
)
listed_price = models.DecimalField( listed_price = models.DecimalField(
decimal_places=2, max_digits=13, null=True, decimal_places=2, max_digits=13, null=True,
) )
@@ -3150,9 +3222,15 @@ class CartPosition(AbstractPosition):
@property @property
def tax_value(self): def tax_value(self):
net = round_decimal(self.price - (self.price * (1 - 100 / (100 + self.tax_rate))), price = self.price - self.price_includes_rounding_correction
net = round_decimal(price - (price * (1 - 100 / (100 + self.tax_rate))),
self.event.currency) self.event.currency)
return self.price - net return self.price - self.price_includes_rounding_correction - net + self.tax_value_includes_rounding_correction
@tax_value.setter
def tax_value(self, value):
# ignore, tax value is always computed on the fly
pass
@cached_property @cached_property
def sort_key(self): def sort_key(self):

View File

@@ -93,7 +93,7 @@ from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order, create_membership, validate_memberships_in_order,
) )
from pretix.base.services.pricing import ( from pretix.base.services.pricing import (
apply_discounts, get_listed_price, get_price, apply_discounts, apply_rounding, get_listed_price, get_price,
) )
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
@@ -939,7 +939,8 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress, def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress,
meta_info: dict, event: Event, require_approval=False): meta_info: dict, event: Event, require_approval=False):
fees = [] fees = []
total = sum([c.price for c in positions]) # Pre-rounding, pre-fee total is used for fee calculation
total = sum([c.price - c.price_includes_rounding_correction for c in positions])
gift_cards = [] # for backwards compatibility gift_cards = [] # for backwards compatibility
for p in payment_requests: for p in payment_requests:
@@ -950,40 +951,53 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
meta_info=meta_info, positions=positions, gift_cards=gift_cards): meta_info=meta_info, positions=positions, gift_cards=gift_cards):
if resp: if resp:
fees += resp fees += resp
total += sum(f.value for f in fees)
total_remaining = total for fee in fees:
fee._calculate_tax(invoice_address=address, event=event)
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
# Apply rounding to get final total in case no payment fees will be added
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments_assigned = Decimal("0.00")
for p in payment_requests: for p in payment_requests:
# This algorithm of treating min/max values and fees needs to stay in sync between the following # This algorithm of treating min/max values and fees needs to stay in sync between the following
# places in the code base: # places in the code base:
# - pretix.base.services.cart.get_fees # - pretix.base.services.cart.get_fees
# - pretix.base.services.orders._get_fees # - pretix.base.services.orders._get_fees
# - pretix.presale.views.CartMixin.current_selected_payments # - pretix.presale.views.CartMixin.current_selected_payments
if p.get('min_value') and total_remaining < Decimal(p['min_value']): if p.get('min_value') and total - payments_assigned < Decimal(p['min_value']):
p['payment_amount'] = Decimal('0.00') p['payment_amount'] = Decimal('0.00')
continue continue
to_pay = total_remaining to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']): if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value'])) to_pay = min(to_pay, Decimal(p['max_value']))
payment_fee = p['pprov'].calculate_fee(to_pay) payment_fee = p['pprov'].calculate_fee(to_pay)
total_remaining += payment_fee
to_pay += payment_fee
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
total_remaining -= to_pay
p['payment_amount'] = to_pay
if payment_fee: if payment_fee:
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=p['pprov'].identifier) internal_type=p['pprov'].identifier)
pf._calculate_tax(invoice_address=address, event=event)
fees.append(pf) fees.append(pf)
p['fee'] = pf p['fee'] = pf
if total_remaining != Decimal('0.00') and not require_approval: # Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed
to_pay = max(total - payments_assigned, Decimal("0.00"))
if p.get('max_value') and to_pay > Decimal(p['max_value']):
to_pay = min(to_pay, Decimal(p['max_value']))
payments_assigned += to_pay
p['payment_amount'] = to_pay
if total != payments_assigned and not require_approval:
raise OrderError(_("The selected payment methods do not cover the total balance.")) raise OrderError(_("The selected payment methods do not cover the total balance."))
return fees return fees
@@ -1001,10 +1015,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
raise OrderError(e.message) raise OrderError(e.message)
require_approval = any(p.requires_approval(invoice_address=address) for p in positions) require_approval = any(p.requires_approval(invoice_address=address) for p in positions)
# Final calculation of fees, also performs final rounding
try: try:
fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval)
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked']) raise OrderError(error_messages['country_blocked'])
total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees])
order = Order( order = Order(
@@ -1036,12 +1053,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no
for fee in fees: for fee in fees:
fee.order = order fee.order = order
try:
fee._calculate_tax()
except TaxRule.SaleNotAllowed:
raise OrderError(error_messages['country_blocked'])
if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate
fee.save() fee.save()
# Safety check: Is the amount we're now going to charge the same amount the user has been shown when they # Safety check: Is the amount we're now going to charge the same amount the user has been shown when they
@@ -2696,9 +2707,12 @@ class OrderChangeManager:
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00') ).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum return payment_sum - refund_sum
def _recalculate_total_and_payment_fee(self): def _recalculate_rounding_total_and_payment_fee(self):
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()]) positions = list(self.order.positions.all())
fees = list(self.order.fees.all())
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
payment_fee = Decimal('0.00') payment_fee = Decimal('0.00')
fee_changed = False
if self.open_payment: if self.open_payment:
current_fee = Decimal('0.00') current_fee = Decimal('0.00')
fee = None fee = None
@@ -2726,13 +2740,31 @@ class OrderChangeManager:
fee.value = payment_fee fee.value = payment_fee
fee._calculate_tax() fee._calculate_tax()
fee.save() fee.save()
fee_changed = True
if not self.open_payment.fee: if not self.open_payment.fee:
self.open_payment.fee = fee self.open_payment.fee = fee
self.open_payment.save(update_fields=['fee']) self.open_payment.save(update_fields=['fee'])
elif fee and not fee.canceled: elif fee and not fee.canceled:
fee.delete() fee.delete()
fee_changed = True
self.order.total = total + payment_fee if fee_changed:
fees = list(self.order.fees.all())
print("round", positions, fees)
changed = apply_rounding(self.order.event.settings.tax_rounding, self.order.event.currency, [*positions, *fees])
for l in changed:
if isinstance(l, OrderPosition):
l.save(update_fields=[
"price", "price_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
elif isinstance(l, OrderFee):
l.save(update_fields=[
"value", "value_includes_rounding_correction", "tax_value", "tax_value_includes_rounding_correction"
])
total = sum([p.price for p in positions]) + sum([f.value for f in fees])
self.order.total = total
self.order.save() self.order.save()
def _check_order_size(self): def _check_order_size(self):
@@ -2913,7 +2945,7 @@ class OrderChangeManager:
self._perform_operations() self._perform_operations()
except TaxRule.SaleNotAllowed: except TaxRule.SaleNotAllowed:
raise OrderError(self.error_messages['tax_rule_country_blocked']) raise OrderError(self.error_messages['tax_rule_country_blocked'])
self._recalculate_total_and_payment_fee() self._recalculate_rounding_total_and_payment_fee()
self._check_paid_price_change() self._check_paid_price_change()
self._check_paid_to_free() self._check_paid_to_free()
if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID):

View File

@@ -23,15 +23,17 @@ import re
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from decimal import Decimal from decimal import Decimal
from typing import List, Optional, Tuple, Union from itertools import groupby
from typing import List, Literal, Optional, Tuple, Union
from django import forms from django import forms
from django.conf import settings
from django.db.models import Q from django.db.models import Q
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import ( from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn,
SalesChannel, Voucher, ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher,
) )
from pretix.base.models.discount import Discount, PositionInfo from pretix.base.models.discount import Discount, PositionInfo
from pretix.base.models.event import Event, SubEvent from pretix.base.models.event import Event, SubEvent
@@ -136,7 +138,8 @@ def get_listed_price(item: Item, variation: ItemVariation = None, subevent: SubE
def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool, def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, custom_price_input_is_net: bool,
tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal, is_bundled=False) -> TaxedPrice: tax_rule: TaxRule, invoice_address: InvoiceAddress, bundled_sum: Decimal,
is_bundled=False) -> TaxedPrice:
if not tax_rule: if not tax_rule:
tax_rule = TaxRule( tax_rule = TaxRule(
name='', name='',
@@ -152,7 +155,8 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
override_tax_code=price.code, invoice_address=invoice_address, override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum) subtract_from_gross=bundled_sum)
else: else:
price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross', override_tax_rate=price.rate, price = tax_rule.tax(max(custom_price_input, price.gross), base_price_is='gross',
override_tax_rate=price.rate,
override_tax_code=price.code, invoice_address=invoice_address, override_tax_code=price.code, invoice_address=invoice_address,
subtract_from_gross=bundled_sum) subtract_from_gross=bundled_sum)
else: else:
@@ -164,7 +168,7 @@ def get_line_price(price_after_voucher: Decimal, custom_price_input: Decimal, cu
def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]], positions: List[Tuple[int, Optional[int], Optional[datetime], Decimal, bool, bool, Decimal]],
collect_potential_discounts: Optional[defaultdict]=None) -> List[Tuple[Decimal, Optional[Discount]]]: collect_potential_discounts: Optional[defaultdict] = None) -> List[Tuple[Decimal, Optional[Discount]]]:
""" """
Applies any dynamic discounts to a cart Applies any dynamic discounts to a cart
@@ -203,3 +207,102 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
new_prices.update(result) new_prices.update(result)
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)] return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
def apply_rounding(rounding_mode: Literal["line", "sum_by_gross", "sum_by_net"], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
"""
Given a a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
and mutates the ``price``, ``price_includes_rounding_correction``, ``tax_value``, and
``tax_value_includes_rounding_correction`` attributes.
When rounding mode is set to ``"line"``, the tax will be computed and rounded individually for every line.
When rounding mode is set to ``"sum_by_gross"``, the tax values of the individual lines will be adjusted such that
the per-taxrate/taxcode subtotal is rounded correctly. The gross prices will stay constant.
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
:param rounding_mode: One of ``"line"``, ``"sum_by_gross"``, or ``"sum_by_net"``.
:param currency: Currency that will be used to determine rounding precision
:param lines: List of order/cart contents
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
"""
def _key(line):
return (line.tax_rate, line.tax_code)
places = settings.CURRENCY_PLACES.get(currency, 2)
minimum_unit = Decimal('1') / 10 ** places
changed = []
if rounding_mode == "sum_by_net":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -(l.price - l.price_includes_rounding_correction)))
net_total = sum(
l.price - l.price_includes_rounding_correction - l.tax_value + l.tax_value_includes_rounding_correction
for l in lines
)
gross_total = sum(l.price - l.price_includes_rounding_correction for l in lines)
target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency)
diff = target_gross_total - gross_total
diff_sgn = -1 if diff < 0 else 1
for l in lines:
if diff:
apply_diff = diff_sgn * minimum_unit
l.price = l.price - l.price_includes_rounding_correction + apply_diff
l.price_includes_rounding_correction = diff_sgn * minimum_unit
l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction + apply_diff
l.tax_value_includes_rounding_correction = apply_diff
diff -= apply_diff
changed.append(l)
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.price - l.price_includes_rounding_correction
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "sum_by_gross":
for (tax_rate, tax_code), lines in groupby(sorted(lines, key=_key), key=_key):
lines = list(sorted(lines, key=lambda l: -(l.price - l.price_includes_rounding_correction)))
net_total = sum(
l.price - l.price_includes_rounding_correction - l.tax_value + l.tax_value_includes_rounding_correction
for l in lines
)
gross_total = sum(l.price - l.price_includes_rounding_correction for l in lines)
target_net_total = round_decimal(gross_total - (gross_total * (1 - 100 / (100 + tax_rate))), currency)
diff = target_net_total - net_total
diff_sgn = -1 if diff < 0 else 1
for l in lines:
if diff:
apply_diff = diff_sgn * minimum_unit
l.price = l.price - l.price_includes_rounding_correction
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction - apply_diff
l.tax_value_includes_rounding_correction = -apply_diff
diff -= apply_diff
changed.append(l)
elif l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.price - l.price_includes_rounding_correction
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
elif rounding_mode == "line":
for l in lines:
if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:
l.price = l.price - l.price_includes_rounding_correction
l.price_includes_rounding_correction = Decimal("0.00")
l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
else:
raise ValueError("Unknown rounding_mode")
return changed

View File

@@ -465,6 +465,28 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-order_phone_asked'}),
) )
}, },
'tax_rounding': {
'default': 'line',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'form_kwargs': dict(
label=_("Rounding of taxes"),
widget=forms.RadioSelect,
choices=(
('line', _('Rounding every line individually')),
('sum_by_net', _('Rounding by order total, keeping net prices stable')),
('sum_by_gross', _('Rounding by order total, keeping gross prices stable')),
),
),
'serializer_kwargs': dict(
choices=(
('line', _('Rounding every line individually')),
('sum_by_net', _('Rounding by order total, keeping net prices stable')),
('sum_by_gross', _('Rounding by order total, keeping gross prices stable')),
),
),
},
'invoice_address_asked': { 'invoice_address_asked': {
'default': 'True', 'default': 'True',
'type': bool, 'type': bool,

View File

@@ -881,6 +881,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_logo_image', 'invoice_logo_image',
'invoice_renderer_highlight_order_code', 'invoice_renderer_highlight_order_code',
'invoice_renderer_font', 'invoice_renderer_font',
'tax_rounding',
] ]
invoice_generate_sales_channels = forms.MultipleChoiceField( invoice_generate_sales_channels = forms.MultipleChoiceField(

View File

@@ -21,6 +21,7 @@
{% bootstrap_field form.invoice_numbers_prefix layout="control" %} {% bootstrap_field form.invoice_numbers_prefix layout="control" %}
{% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %} {% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %}
{% bootstrap_field form.invoice_numbers_counter_length layout="control" %} {% bootstrap_field form.invoice_numbers_counter_length layout="control" %}
{% bootstrap_field form.tax_rounding layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Address form" %}</legend> <legend>{% trans "Address form" %}</legend>

View File

@@ -641,6 +641,16 @@
</small> </small>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if django_settings.DEBUG %}
<br/>
<small>
price = {{ line.price|floatformat:2 }}<br>
rounding_correction = {{ line.price_includes_rounding_correction|floatformat:2 }}<br>
tax_value = {{ line.tax_value|floatformat:2 }}<br>
tax_value_rounding_correction = {{ line.tax_value_includes_rounding_correction|floatformat:2 }}<br>
voucher_budget_use = {{ line.voucher_budget_use|floatformat:2 }}
</small>
{% endif %}
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>
@@ -681,6 +691,16 @@
</small> </small>
{% endif %} {% endif %}
{% endif %} {% endif %}
{% if django_settings.DEBUG %}
<br/>
<small>
price = {{ fee.value|floatformat:2 }}<br>
rounding_correction = {{ fee.value_includes_rounding_correction|floatformat:2 }}<br>
tax_value = {{ fee.tax_value|floatformat:2 }}<br>
tax_value_rounding_correction = {{ fee.tax_value_includes_rounding_correction|floatformat:2 }}<br>
voucher_budget_use = {{ fee.voucher_budget_use|floatformat:2 }}
</small>
{% endif %}
</div> </div>
<div class="clearfix"></div> <div class="clearfix"></div>
</div> </div>

View File

@@ -56,8 +56,26 @@
<td>{{ t.get_tax_code_display }}</td> <td>{{ t.get_tax_code_display }}</td>
<td class="text-right flip">{{ t.count }} &times;</td> <td class="text-right flip">{{ t.count }} &times;</td>
<td class="text-right flip">{{ t.price|money:request.event.currency }}</td> <td class="text-right flip">{{ t.price|money:request.event.currency }}</td>
<td class="text-right flip">{{ t.full_tax_value|money:request.event.currency }}</td> <td class="text-right flip">
<td class="text-right flip">{{ t.full_price|money:request.event.currency }}</td> {{ t.full_tax_value|money:request.event.currency }}
{% if t.full_price_includes_rounding_correction %}
<br><small>
{% blocktrans trimmed with amount=t.full_tax_value_includes_rounding_correction|money:request.event.currency %}
incl. {{ amount }} rounding correction
{% endblocktrans %}
</small>
{% endif %}
</td>
<td class="text-right flip">
{{ t.full_price|money:request.event.currency }}
{% if t.full_price_includes_rounding_correction %}
<br><small>
{% blocktrans trimmed with amount=t.full_price_includes_rounding_correction|money:request.event.currency %}
incl. {{ amount }} rounding correction
{% endblocktrans %}
</small>
{% endif %}
</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>

View File

@@ -649,7 +649,9 @@ class OrderTransactions(OrderView):
ctx['sums'] = self.order.transactions.aggregate( ctx['sums'] = self.order.transactions.aggregate(
sum_count=Sum('count'), sum_count=Sum('count'),
full_price=Sum(F('count') * F('price')), full_price=Sum(F('count') * F('price')),
full_price_includes_rounding_correction=Sum(F('count') * F('price_includes_rounding_correction')),
full_tax_value=Sum(F('count') * F('tax_value')), full_tax_value=Sum(F('count') * F('tax_value')),
full_tax_value_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')),
) )
return ctx return ctx

View File

@@ -54,6 +54,7 @@ from pretix.base.models import (
QuestionAnswer, QuestionOption, TaxRule, QuestionAnswer, QuestionOption, TaxRule,
) )
from pretix.base.services.cart import get_fees from pretix.base.services.cart import get_fees
from pretix.base.services.pricing import apply_rounding
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.helpers.cookies import set_cookie_without_samesite from pretix.helpers.cookies import set_cookie_without_samesite
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
@@ -147,6 +148,34 @@ class CartMixin:
'question': value.label 'question': value.label
}) })
total = sum(p.price for p in lcp)
if order:
fees = order.fees.all()
elif lcp:
try:
fees = get_fees(
self.request.event, self.request, total, self.invoice_address,
payments if payments is not None else self.cart_session.get('payments', []),
cartpos
)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
fees = []
else:
fees = []
if not order:
apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*lcp, *fees])
print([(p.price, p.tax_value, ) for p in lcp])
total = sum(p.price for p in lcp)
net_total = sum(p.price - p.tax_value for p in lcp)
tax_total = sum(p.tax_value for p in lcp)
total += sum([f.value for f in fees])
net_total += sum([f.net_value for f in fees])
tax_total += sum([f.tax_value for f in fees])
# Group items of the same variation # Group items of the same variation
# We do this by list manipulations instead of a GROUP BY query, as # We do this by list manipulations instead of a GROUP BY query, as
# Django is unable to join related models in a .values() query # Django is unable to join related models in a .values() query
@@ -177,7 +206,7 @@ class CartMixin:
pos.subevent_id, pos.subevent_id,
pos.item_id, pos.item_id,
pos.variation_id, pos.variation_id,
pos.price, pos.net_price if self.request.event.settings.display_net_prices else pos.price,
(pos.voucher_id or 0), (pos.voucher_id or 0),
(pos.seat_id or 0), (pos.seat_id or 0),
pos.valid_from, pos.valid_from,
@@ -204,29 +233,6 @@ class CartMixin:
group.additional_answers = pos_additional_fields.get(group.pk) group.additional_answers = pos_additional_fields.get(group.pk)
positions.append(group) positions.append(group)
total = sum(p.total for p in positions)
net_total = sum(p.net_total for p in positions)
tax_total = sum(p.total - p.net_total for p in positions)
if order:
fees = order.fees.all()
elif positions:
try:
fees = get_fees(
self.request.event, self.request, total, self.invoice_address,
payments if payments is not None else self.cart_session.get('payments', []),
cartpos
)
except TaxRule.SaleNotAllowed:
# ignore for now, will fail on order creation
fees = []
else:
fees = []
total += sum([f.value for f in fees])
net_total += sum([f.net_value for f in fees])
tax_total += sum([f.tax_value for f in fees])
try: try:
first_expiry = min(p.expires for p in positions) if positions else now() first_expiry = min(p.expires for p in positions) if positions else now()
max_expiry_extend = min((p.max_extend for p in positions if p.max_extend), default=None) max_expiry_extend = min((p.max_extend for p in positions if p.max_extend), default=None)

View File

@@ -2270,7 +2270,7 @@ class OrderChangeManagerTests(TestCase):
def test_recalculate_country_rate(self): def test_recalculate_country_rate(self):
prov = self.ocm._get_payment_provider() prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_abs', Decimal('0.30')) prov.settings.set('_fee_abs', Decimal('0.30'))
self.ocm._recalculate_total_and_payment_fee() self.ocm._recalculate_rounding_total_and_payment_fee()
assert self.order.total == Decimal('46.30') assert self.order.total == Decimal('46.30')
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
@@ -2302,7 +2302,7 @@ class OrderChangeManagerTests(TestCase):
def test_recalculate_country_rate_keep_gross(self): def test_recalculate_country_rate_keep_gross(self):
prov = self.ocm._get_payment_provider() prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_abs', Decimal('0.30')) prov.settings.set('_fee_abs', Decimal('0.30'))
self.ocm._recalculate_total_and_payment_fee() self.ocm._recalculate_rounding_total_and_payment_fee()
assert self.order.total == Decimal('46.30') assert self.order.total == Decimal('46.30')
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
@@ -2332,7 +2332,7 @@ class OrderChangeManagerTests(TestCase):
def test_recalculate_reverse_charge(self): def test_recalculate_reverse_charge(self):
prov = self.ocm._get_payment_provider() prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_abs', Decimal('0.30')) prov.settings.set('_fee_abs', Decimal('0.30'))
self.ocm._recalculate_total_and_payment_fee() self.ocm._recalculate_rounding_total_and_payment_fee()
assert self.order.total == Decimal('46.30') assert self.order.total == Decimal('46.30')
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)

View File

@@ -0,0 +1,118 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
import pytest
from pretix.base.models import OrderPosition
from pretix.base.services.pricing import apply_rounding
@pytest.fixture
def sample_lines():
lines = [OrderPosition(
price=Decimal("100.00"),
tax_value=Decimal("15.97"),
tax_rate=Decimal("19.00"),
tax_code="S",
) for _ in range(5)]
return lines
def _validate_sample_lines(sample_lines, rounding_mode):
apply_rounding(rounding_mode, "EUR", sample_lines)
if rounding_mode == "line":
for l in sample_lines:
assert l.price == Decimal("100.00")
assert l.tax_value == Decimal("15.97")
assert l.tax_rate == Decimal("19.00")
assert sum(l.price for l in sample_lines) == Decimal("500.00")
assert sum(l.tax_value for l in sample_lines) == Decimal("79.85")
elif rounding_mode == "sum_by_net":
for l in sample_lines:
# gross price may vary
assert l.price - l.tax_value == Decimal("84.03")
assert l.tax_rate == Decimal("19.00")
assert sum(l.price for l in sample_lines) == Decimal("499.98")
assert sum(l.tax_value for l in sample_lines) == Decimal("79.83")
elif rounding_mode == "sum_by_gross":
for l in sample_lines:
assert l.price == Decimal("100.00")
# net price may vary
assert l.tax_rate == Decimal("19.00")
assert sum(l.price for l in sample_lines) == Decimal("500.00")
assert sum(l.tax_value for l in sample_lines) == Decimal("79.83")
@pytest.mark.django_db
def test_simple_case_by_line(sample_lines):
_validate_sample_lines(sample_lines, "line")
@pytest.mark.django_db
def test_simple_case_by_net(sample_lines):
_validate_sample_lines(sample_lines, "sum_by_net")
@pytest.mark.django_db
def test_simple_case_by_gross(sample_lines):
_validate_sample_lines(sample_lines, "sum_by_gross")
@pytest.mark.django_db
def test_simple_case_switch_rounding(sample_lines):
_validate_sample_lines(sample_lines, "sum_by_net")
_validate_sample_lines(sample_lines, "sum_by_gross")
_validate_sample_lines(sample_lines, "line")
_validate_sample_lines(sample_lines, "sum_by_net")
@pytest.mark.django_db
def test_revert_net_rounding_to_single_line(sample_lines):
l = OrderPosition(
price=Decimal("100.01"),
price_includes_rounding_correction=Decimal("0.01"),
tax_value=Decimal("15.98"),
tax_value_includes_rounding_correction=Decimal("0.01"),
tax_rate=Decimal("19.00"),
tax_code="S",
)
apply_rounding("sum_by_net", "EUR", [l])
assert l.price == Decimal("100.00")
assert l.tax_value == Decimal("15.97")
assert l.tax_rate == Decimal("19.00")
@pytest.mark.django_db
def test_revert_gross_rounding_to_single_line(sample_lines):
l = OrderPosition(
price=Decimal("100.00"),
price_includes_rounding_correction=Decimal("0.00"),
tax_value=Decimal("15.96"),
tax_value_includes_rounding_correction=Decimal("-0.01"),
tax_rate=Decimal("19.00"),
tax_code="S",
)
apply_rounding("sum_by_gross", "EUR", [l])
assert l.price == Decimal("100.00")
assert l.tax_value == Decimal("15.97")
assert l.tax_rate == Decimal("19.00")