From 3735b6b5a9f83acfe6443068101e143244c00a5f Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 20 Apr 2025 20:00:23 +0200 Subject: [PATCH] Allow to round taxes on order-level --- src/pretix/api/serializers/event.py | 1 + ...e_includes_rounding_correction_and_more.py | 76 +++++++++++ src/pretix/base/models/orders.py | 110 +++++++++++++--- src/pretix/base/services/orders.py | 84 +++++++++---- src/pretix/base/services/pricing.py | 115 ++++++++++++++++- src/pretix/base/settings.py | 22 ++++ src/pretix/control/forms/event.py | 1 + .../pretixcontrol/event/invoicing.html | 1 + .../templates/pretixcontrol/order/index.html | 20 +++ .../pretixcontrol/order/transactions.html | 22 +++- src/pretix/control/views/orders.py | 2 + src/pretix/presale/views/__init__.py | 54 ++++---- src/tests/base/test_orders.py | 6 +- src/tests/base/test_pricing_rounding.py | 118 ++++++++++++++++++ 14 files changed, 555 insertions(+), 77 deletions(-) create mode 100644 src/pretix/base/migrations/0285_cartposition_price_includes_rounding_correction_and_more.py create mode 100644 src/tests/base/test_pricing_rounding.py diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index f7351a805d..b9f90d716d 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -829,6 +829,7 @@ class EventSettingsSerializer(SettingsSerializer): 'invoice_eu_currencies', 'invoice_logo_image', 'invoice_renderer_highlight_order_code', + 'tax_rounding', 'cancel_allow_user', 'cancel_allow_user_until', 'cancel_allow_user_unpaid_keep', diff --git a/src/pretix/base/migrations/0285_cartposition_price_includes_rounding_correction_and_more.py b/src/pretix/base/migrations/0285_cartposition_price_includes_rounding_correction_and_more.py new file mode 100644 index 0000000000..fda9c90c84 --- /dev/null +++ b/src/pretix/base/migrations/0285_cartposition_price_includes_rounding_correction_and_more.py @@ -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 + ), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 96f1b53268..4ff5d0a14b 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1259,7 +1259,8 @@ 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, 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] if d: create.append(Transaction( @@ -1272,9 +1273,11 @@ class Order(LockModel, LoggedModel): variation_id=variationid, subevent_id=subeventid, price=price, + price_includes_rounding_correction=price_includes_rounding_correction, tax_rate=taxrate, tax_rule_id=taxruleid, tax_value=taxvalue, + tax_value_includes_rounding_correction=taxvalue_includes_rounding_correction, tax_code=taxcode, fee_type=feetype, internal_type=internaltype, @@ -1499,6 +1502,9 @@ class AbstractPosition(models.Model): decimal_places=2, max_digits=13, verbose_name=_("Price") ) + price_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) attendee_name_cached = models.CharField( max_length=255, verbose_name=_("Attendee name"), @@ -2301,6 +2307,9 @@ class OrderFee(models.Model): decimal_places=2, max_digits=13, verbose_name=_("Value") ) + value_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) order = models.ForeignKey( Order, verbose_name=_("Order"), @@ -2329,6 +2338,9 @@ class OrderFee(models.Model): max_digits=13, decimal_places=2, 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) all = ScopedManager(organizer='order__event__organizer') @@ -2377,17 +2389,23 @@ class OrderFee(models.Model): 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: self.tax_rule = tax_rule - try: - ia = invoice_address or self.order.invoice_address - except InvoiceAddress.DoesNotExist: + if invoice_address: + ia = invoice_address + elif hasattr(self, "order"): + try: + ia = self.order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None + else: ia = None - 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 + event = event or self.order.event + 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: 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() 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): """ @@ -2501,6 +2537,9 @@ class OrderPosition(AbstractPosition): max_digits=13, decimal_places=2, 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) 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]) else: 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: 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, verbose_name=_("Price") ) + price_includes_rounding_correction = models.DecimalField( + max_digits=13, decimal_places=2, default=Decimal("0.00") + ) tax_rate = models.DecimalField( max_digits=7, decimal_places=2, verbose_name=_('Tax rate') @@ -3023,6 +3072,9 @@ class Transaction(models.Model): max_digits=13, decimal_places=2, 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( max_length=100, choices=OrderFee.FEE_TYPES, null=True, blank=True ) @@ -3052,14 +3104,19 @@ class Transaction(models.Model): @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_code) + return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, + 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): - 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_code) + return (obj.positionid, obj.item_id, obj.variation_id, obj.subevent_id, obj.price, + 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): - 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_code) + return (None, None, None, None, obj.value, obj.value_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) raise ValueError('invalid state') # noqa @property @@ -3070,6 +3127,14 @@ class Transaction(models.Model): def full_tax_value(self): 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): """ @@ -3110,6 +3175,13 @@ class CartPosition(AbstractPosition): max_digits=7, decimal_places=2, default=Decimal('0.00'), 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( decimal_places=2, max_digits=13, null=True, ) @@ -3150,9 +3222,15 @@ class CartPosition(AbstractPosition): @property 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) - 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 def sort_key(self): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 200b8a9f24..e46a2b648b 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -93,7 +93,7 @@ from pretix.base.services.memberships import ( create_membership, validate_memberships_in_order, ) 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.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, meta_info: dict, event: Event, require_approval=False): 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 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): if 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: # This algorithm of treating min/max values and fees needs to stay in sync between the following # places in the code base: # - pretix.base.services.cart.get_fees # - pretix.base.services.orders._get_fees # - 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') 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']): to_pay = min(to_pay, Decimal(p['max_value'])) 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: pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, internal_type=p['pprov'].identifier) + pf._calculate_tax(invoice_address=address, event=event) fees.append(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.")) return fees @@ -1001,10 +1015,13 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no raise OrderError(e.message) require_approval = any(p.requires_approval(invoice_address=address) for p in positions) + + # Final calculation of fees, also performs final rounding try: fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) except TaxRule.SaleNotAllowed: raise OrderError(error_messages['country_blocked']) + total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) order = Order( @@ -1036,12 +1053,6 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no for fee in fees: 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() # 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') return payment_sum - refund_sum - def _recalculate_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()]) + def _recalculate_rounding_total_and_payment_fee(self): + 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') + fee_changed = False if self.open_payment: current_fee = Decimal('0.00') fee = None @@ -2726,13 +2740,31 @@ class OrderChangeManager: fee.value = payment_fee fee._calculate_tax() fee.save() + fee_changed = True if not self.open_payment.fee: self.open_payment.fee = fee self.open_payment.save(update_fields=['fee']) elif fee and not fee.canceled: 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() def _check_order_size(self): @@ -2913,7 +2945,7 @@ class OrderChangeManager: self._perform_operations() except TaxRule.SaleNotAllowed: 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_to_free() if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index ac6b1a13e8..f6f45a9474 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -23,15 +23,17 @@ import re from collections import defaultdict from datetime import datetime 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.conf import settings from django.db.models import Q from pretix.base.decimal import round_decimal from pretix.base.models import ( - AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, - SalesChannel, Voucher, + AbstractPosition, CartPosition, InvoiceAddress, Item, ItemAddOn, + ItemVariation, OrderFee, OrderPosition, SalesChannel, Voucher, ) from pretix.base.models.discount import Discount, PositionInfo 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, - 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: tax_rule = TaxRule( 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, 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, + 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, subtract_from_gross=bundled_sum) 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], 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 @@ -203,3 +207,102 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], new_prices.update(result) 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 diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 550d747ebd..1bb929a542 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -465,6 +465,28 @@ DEFAULTS = { 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': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 427447ab35..ab42d57df7 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -881,6 +881,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): 'invoice_logo_image', 'invoice_renderer_highlight_order_code', 'invoice_renderer_font', + 'tax_rounding', ] invoice_generate_sales_channels = forms.MultipleChoiceField( diff --git a/src/pretix/control/templates/pretixcontrol/event/invoicing.html b/src/pretix/control/templates/pretixcontrol/event/invoicing.html index a3f8b89965..74bd1a9650 100644 --- a/src/pretix/control/templates/pretixcontrol/event/invoicing.html +++ b/src/pretix/control/templates/pretixcontrol/event/invoicing.html @@ -21,6 +21,7 @@ {% bootstrap_field form.invoice_numbers_prefix layout="control" %} {% bootstrap_field form.invoice_numbers_prefix_cancellations layout="control" %} {% bootstrap_field form.invoice_numbers_counter_length layout="control" %} + {% bootstrap_field form.tax_rounding layout="control" %}
{% trans "Address form" %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 81ba1f8c43..d87522da60 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -641,6 +641,16 @@ {% endif %} {% endif %} + {% if django_settings.DEBUG %} +
+ + price = {{ line.price|floatformat:2 }}
+ rounding_correction = {{ line.price_includes_rounding_correction|floatformat:2 }}
+ tax_value = {{ line.tax_value|floatformat:2 }}
+ tax_value_rounding_correction = {{ line.tax_value_includes_rounding_correction|floatformat:2 }}
+ voucher_budget_use = {{ line.voucher_budget_use|floatformat:2 }} +
+ {% endif %}
@@ -681,6 +691,16 @@ {% endif %} {% endif %} + {% if django_settings.DEBUG %} +
+ + price = {{ fee.value|floatformat:2 }}
+ rounding_correction = {{ fee.value_includes_rounding_correction|floatformat:2 }}
+ tax_value = {{ fee.tax_value|floatformat:2 }}
+ tax_value_rounding_correction = {{ fee.tax_value_includes_rounding_correction|floatformat:2 }}
+ voucher_budget_use = {{ fee.voucher_budget_use|floatformat:2 }} +
+ {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/transactions.html b/src/pretix/control/templates/pretixcontrol/order/transactions.html index 7d327de15e..90431ec73f 100644 --- a/src/pretix/control/templates/pretixcontrol/order/transactions.html +++ b/src/pretix/control/templates/pretixcontrol/order/transactions.html @@ -56,8 +56,26 @@ {{ t.get_tax_code_display }} {{ t.count }} × {{ t.price|money:request.event.currency }} - {{ t.full_tax_value|money:request.event.currency }} - {{ t.full_price|money:request.event.currency }} + + {{ t.full_tax_value|money:request.event.currency }} + {% if t.full_price_includes_rounding_correction %} +
+ {% blocktrans trimmed with amount=t.full_tax_value_includes_rounding_correction|money:request.event.currency %} + incl. {{ amount }} rounding correction + {% endblocktrans %} + + {% endif %} + + + {{ t.full_price|money:request.event.currency }} + {% if t.full_price_includes_rounding_correction %} +
+ {% blocktrans trimmed with amount=t.full_price_includes_rounding_correction|money:request.event.currency %} + incl. {{ amount }} rounding correction + {% endblocktrans %} + + {% endif %} + {% endfor %} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 65c77ef227..0ee20506bb 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -649,7 +649,9 @@ class OrderTransactions(OrderView): ctx['sums'] = self.order.transactions.aggregate( sum_count=Sum('count'), 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_includes_rounding_correction=Sum(F('count') * F('tax_value_includes_rounding_correction')), ) return ctx diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index b8d6acb691..340e0362df 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -54,6 +54,7 @@ from pretix.base.models import ( QuestionAnswer, QuestionOption, TaxRule, ) 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.helpers.cookies import set_cookie_without_samesite from pretix.multidomain.urlreverse import eventreverse @@ -147,6 +148,34 @@ class CartMixin: '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 # We do this by list manipulations instead of a GROUP BY query, as # Django is unable to join related models in a .values() query @@ -177,7 +206,7 @@ class CartMixin: pos.subevent_id, pos.item_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.seat_id or 0), pos.valid_from, @@ -204,29 +233,6 @@ class CartMixin: group.additional_answers = pos_additional_fields.get(group.pk) 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: 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) diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index d30ed3336f..b2de86fca7 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -2270,7 +2270,7 @@ class OrderChangeManagerTests(TestCase): def test_recalculate_country_rate(self): prov = self.ocm._get_payment_provider() 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') 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): prov = self.ocm._get_payment_provider() 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') fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) @@ -2332,7 +2332,7 @@ class OrderChangeManagerTests(TestCase): def test_recalculate_reverse_charge(self): prov = self.ocm._get_payment_provider() 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') fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT) diff --git a/src/tests/base/test_pricing_rounding.py b/src/tests/base/test_pricing_rounding.py new file mode 100644 index 0000000000..d0c9ec62cb --- /dev/null +++ b/src/tests/base/test_pricing_rounding.py @@ -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 . +# +# 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 +# . +# +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")