From 15cdf606d025c3e55a32089123723bef74ed83f6 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Tue, 13 Jan 2026 18:11:23 +0100 Subject: [PATCH] Tax rounding: Allow to apply only for B2B (Z#23220106) Most effective in combination with #5807 --- src/pretix/api/serializers/order.py | 1 + src/pretix/base/services/cart.py | 4 +- src/pretix/base/services/orders.py | 35 +++++++++++++---- src/pretix/base/services/pricing.py | 11 +++++- src/pretix/base/settings.py | 1 + src/pretix/control/forms/event.py | 5 +++ src/pretix/control/logdisplay.py | 6 +++ .../pretixpresale/event/fragment_cart.html | 13 ++++++- src/pretix/presale/views/__init__.py | 11 ++++-- src/tests/base/test_pricing_rounding.py | 39 +++++++++++++------ 10 files changed, 100 insertions(+), 26 deletions(-) diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 1459e1c26d..7b8f06950d 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1733,6 +1733,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): rounding_mode = self.context["event"].settings.tax_rounding changed = apply_rounding( rounding_mode, + ia, self.context["event"].currency, [*pos_map.values(), *fees] ) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 97e7471535..36e6febfb3 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1639,7 +1639,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment if fee.tax_rule and not fee.tax_rule.pk: fee.tax_rule = None # TODO: deprecate - apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) + apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees]) if total != 0 and payments: @@ -1679,7 +1679,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment fees.append(pf) # Re-apply rounding as grand total has changed - apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) + apply_rounding(event.settings.tax_rounding, invoice_address, 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 diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5c9556f141..22d4451d90 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -968,7 +968,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li 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]) + apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees]) payments_assigned = Decimal("0.00") @@ -995,7 +995,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li p['fee'] = pf # Re-apply rounding as grand total has changed - apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) + apply_rounding(event.settings.tax_rounding, address, 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 @@ -1641,6 +1641,7 @@ class OrderChangeManager: ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until')) AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) + ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple()) class AddPositionResult: _position: Optional[OrderPosition] @@ -1804,6 +1805,7 @@ class OrderChangeManager: positions = self.order.positions.select_related('item', 'item__tax_rule') ia = self._invoice_address tax_rules = self._current_tax_rules() + self._operations.append(self.ForceRecomputeOperation()) for pos in positions: tax_rule = tax_rules.get(pos.pk, pos.tax_rule) @@ -2640,6 +2642,10 @@ class OrderChangeManager: except BlockedTicketSecret.DoesNotExist: pass # todo: revoke list handling + elif isinstance(op, self.ForceRecomputeOperation): + self.order.log_action('pretix.event.order.changed.recomputed', user=self.user, auth=self.auth, data={}) + else: + raise TypeError(f"Unknown operation {type(op)}") for p in secret_dirty: assign_ticket_secret( @@ -2694,7 +2700,10 @@ class OrderChangeManager: fees.append(new_fee) changed_by_rounding = set(apply_rounding( - self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees + self.order.tax_rounding_mode, + self._invoice_address, + self.event.currency, + [p for p in split_positions if not p.canceled] + fees )) split_order.total = sum([p.price for p in split_positions if not p.canceled]) @@ -2716,7 +2725,10 @@ class OrderChangeManager: fee.delete() changed_by_rounding |= set(apply_rounding( - self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees + self.order.tax_rounding_mode, + self._invoice_address, + self.event.currency, + [p for p in split_positions if not p.canceled] + fees )) split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees]) @@ -2833,7 +2845,12 @@ class OrderChangeManager: if fee_changed: fees = list(self.order.fees.all()) - changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees]) + changed = apply_rounding( + self.order.tax_rounding_mode, + self._invoice_address, + self.order.event.currency, + [*positions, *fees] + ) for l in changed: if isinstance(l, OrderPosition): l.save(update_fields=[ @@ -3269,8 +3286,12 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay positions = list(order.positions.all()) fees = list(order.fees.all()) + try: + ia = order.invoice_address + except InvoiceAddress.DoesNotExist: + ia = None rounding_changed = set(apply_rounding( - order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]] + order.tax_rounding_mode, ia, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]] )) total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk) pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid) @@ -3295,7 +3316,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay fee = None rounding_changed |= set(apply_rounding( - order.tax_rounding_mode, order.event.currency, [*positions, *fees] + order.tax_rounding_mode, ia, order.event.currency, [*positions, *fees] )) for l in rounding_changed: if isinstance(l, OrderPosition): diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 3921a1b132..78c046f66a 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -209,7 +209,8 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel], return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)] -def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str, +def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only_business", "sum_by_net_keep_gross"], + invoice_address: Optional[InvoiceAddress], currency: str, lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list: """ Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode @@ -224,11 +225,17 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep 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_net"``, or ``"sum_by_net_keep_gross"``. + :param rounding_mode: One of ``"line"``, ``"sum_by_net"``, ``"sum_by_net_only_business"``, or ``"sum_by_net_keep_gross"``. + :param invoice_address: The invoice address, or ``None`` :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. """ + if rounding_mode == "sum_by_net_only_business": + if invoice_address and invoice_address.is_business: + rounding_mode = "sum_by_net" + else: + rounding_mode = "line" def _key(line): return (line.tax_rate, line.tax_code or "") diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index e6d123d060..6ed3048755 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -81,6 +81,7 @@ from pretix.helpers.countries import CachedCountries ROUNDING_MODES = ( ('line', _('Compute taxes for every line individually')), ('sum_by_net', _('Compute taxes based on net total')), + ('sum_by_net_only_business', _('Compute taxes based on net total for business customers')), ('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')), # We could also have sum_by_gross, but we're not aware of any use-cases for it ) diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index b04c378ce7..942eca6831 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -867,6 +867,11 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm): "The gross price of some products may be changed to ensure correct rounding, while the net " "prices will be kept as configured. This may cause the actual payment amount to differ." ), + "sum_by_net_only_business": _( + "Same as above, but only applied to business customers. Line-based rounding will be used for consumers. " + "Recommended when e-invoicing is only used for business customers and consumers do not receive " + "invoices. This can cause the payment amount to change when the invoice address is changed." + ), "sum_by_net_keep_gross": _( "Recommended for e-invoicing when you primarily sell to consumers. " "The gross or net price of some products may be changed automatically to ensure correct " diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 02ccce2e4c..f3d71f08b8 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -170,6 +170,12 @@ class OrderFeeAdded(OrderChangeLogEntryType): plain = _('A fee has been added') +@log_entry_types.new() +class OrderRecomputed(OrderChangeLogEntryType): + action_type = 'pretix.event.order.changed.recomputed' + plain = _('Taxes and rounding have been recomputed') + + @log_entry_types.new() class OrderFeeChanged(OrderChangeLogEntryType): action_type = 'pretix.event.order.changed.feevalue' diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 361dcca763..92317c4a1a 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -493,7 +493,18 @@ {% endif %} - +{% if cart.show_rounding_info %} +
+ + {% icon "info-circle" %} + {% blocktrans trimmed %} + Since you entered a business address, your price was computed from the + VAT-exclusive price. Due to rounding, this caused a small change to the + total price. + {% endblocktrans %} + +
+{% endif %}
{% if not cart.is_ordered %} diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 77a7d2dfb3..95b47ca796 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -167,7 +167,7 @@ class CartMixin: fees = [] if not order: - apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*lcp, *fees]) + apply_rounding(self.request.event.settings.tax_rounding, self.invoice_address, self.request.event.currency, [*lcp, *fees]) total = sum([c.price for c in lcp]) + sum([f.value for f in fees]) net_total = sum(p.price - p.tax_value for p in lcp) + sum([f.net_value for f in fees]) @@ -262,6 +262,11 @@ class CartMixin: 'max_expiry_extend': max_expiry_extend, 'is_ordered': bool(order), 'itemcount': sum(c.count for c in positions if not c.addon_to), + 'show_rounding_info': ( + self.request.event.settings.tax_rounding == "sum_by_net_only_business" and + not self.request.event.settings.display_net_prices and + sum(c.price_includes_rounding_correction for c in positions) + sum(f.price_includes_rounding_correction for f in fees) + ), 'itemvarsums': itemvarsums, 'current_selected_payments': [ p for p in self.current_selected_payments(positions, fees, self.invoice_address) @@ -273,7 +278,7 @@ class CartMixin: raw_payments = copy.deepcopy(self.cart_session.get('payments', [])) fees = [f for f in fees if f.fee_type != OrderFee.FEE_TYPE_PAYMENT] # we re-compute these here - apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*positions, *fees]) + apply_rounding(self.request.event.settings.tax_rounding, invoice_address, self.request.event.currency, [*positions, *fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees]) payments = [] @@ -327,7 +332,7 @@ class CartMixin: fees.append(pf) # Re-apply rounding as grand total has changed - apply_rounding(self.request.event.settings.tax_rounding, self.request.event.currency, [*positions, *fees]) + apply_rounding(self.request.event.settings.tax_rounding, invoice_address, self.request.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 diff --git a/src/tests/base/test_pricing_rounding.py b/src/tests/base/test_pricing_rounding.py index ffe9eaea1b..d8e795c676 100644 --- a/src/tests/base/test_pricing_rounding.py +++ b/src/tests/base/test_pricing_rounding.py @@ -43,7 +43,7 @@ def _validate_sample_lines(sample_lines, rounding_mode): (line.tax_value_includes_rounding_correction, line.price_includes_rounding_correction) for line in sample_lines ] - changed = apply_rounding(rounding_mode, "EUR", sample_lines) + changed = apply_rounding(rounding_mode, None, "EUR", sample_lines) for line, original in zip(sample_lines, corrections): if (line.tax_value_includes_rounding_correction, line.price_includes_rounding_correction) != original: assert line in changed @@ -108,7 +108,7 @@ def test_revert_net_rounding_to_single_line(sample_lines): tax_rate=Decimal("19.00"), tax_code="S", ) - apply_rounding("sum_by_net", "EUR", [l]) + apply_rounding("sum_by_net", None, "EUR", [l]) assert l.price == Decimal("100.00") assert l.price_includes_rounding_correction == Decimal("0.00") assert l.tax_value == Decimal("15.97") @@ -126,7 +126,7 @@ def test_revert_net_keep_gross_rounding_to_single_line(sample_lines): tax_rate=Decimal("19.00"), tax_code="S", ) - apply_rounding("sum_by_net_keep_gross", "EUR", [l]) + apply_rounding("sum_by_net_keep_gross", None, "EUR", [l]) assert l.price == Decimal("100.00") assert l.price_includes_rounding_correction == Decimal("0.00") assert l.tax_value == Decimal("15.97") @@ -144,7 +144,7 @@ def test_rounding_of_impossible_gross_price(rounding_mode): price=Decimal("23.00"), ) l._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress()) - apply_rounding(rounding_mode, "EUR", [l]) + apply_rounding(rounding_mode, None, "EUR", [l]) assert l.price == Decimal("23.01") assert l.price_includes_rounding_correction == Decimal("0.01") assert l.tax_value == Decimal("1.51") @@ -164,12 +164,12 @@ def test_round_down(): assert sum(l.tax_value for l in lines) == Decimal("79.85") assert sum(l.price - l.tax_value for l in lines) == Decimal("420.15") - apply_rounding("sum_by_net", "EUR", lines) + apply_rounding("sum_by_net", None, "EUR", lines) assert sum(l.price for l in lines) == Decimal("499.98") assert sum(l.tax_value for l in lines) == Decimal("79.83") assert sum(l.price - l.tax_value for l in lines) == Decimal("420.15") - apply_rounding("sum_by_net_keep_gross", "EUR", lines) + apply_rounding("sum_by_net_keep_gross", None, "EUR", lines) assert sum(l.price for l in lines) == Decimal("500.00") assert sum(l.tax_value for l in lines) == Decimal("79.83") assert sum(l.price - l.tax_value for l in lines) == Decimal("420.17") @@ -187,12 +187,12 @@ def test_round_up(): assert sum(l.tax_value for l in lines) == Decimal("79.80") assert sum(l.price - l.tax_value for l in lines) == Decimal("420.10") - apply_rounding("sum_by_net", "EUR", lines) + apply_rounding("sum_by_net", None, "EUR", lines) assert sum(l.price for l in lines) == Decimal("499.92") assert sum(l.tax_value for l in lines) == Decimal("79.82") assert sum(l.price - l.tax_value for l in lines) == Decimal("420.10") - apply_rounding("sum_by_net_keep_gross", "EUR", lines) + apply_rounding("sum_by_net_keep_gross", None, "EUR", lines) assert sum(l.price for l in lines) == Decimal("499.90") assert sum(l.tax_value for l in lines) == Decimal("79.82") assert sum(l.price - l.tax_value for l in lines) == Decimal("420.08") @@ -210,12 +210,12 @@ def test_round_currency_without_decimals(): assert sum(l.tax_value for l in lines) == Decimal("7980.00") assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00") - apply_rounding("sum_by_net", "JPY", lines) + apply_rounding("sum_by_net", None, "JPY", lines) assert sum(l.price for l in lines) == Decimal("49992.00") assert sum(l.tax_value for l in lines) == Decimal("7982.00") assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00") - apply_rounding("sum_by_net_keep_gross", "JPY", lines) + apply_rounding("sum_by_net_keep_gross", None, "JPY", lines) assert sum(l.price for l in lines) == Decimal("49990.00") assert sum(l.tax_value for l in lines) == Decimal("7982.00") assert sum(l.price - l.tax_value for l in lines) == Decimal("42008.00") @@ -235,7 +235,7 @@ def test_do_not_touch_free(rounding_mode): price=Decimal("23.00"), ) l2._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress()) - apply_rounding(rounding_mode, "EUR", [l1, l2]) + apply_rounding(rounding_mode, None, "EUR", [l1, l2]) assert l2.price == Decimal("23.01") assert l2.price_includes_rounding_correction == Decimal("0.01") assert l2.tax_value == Decimal("1.51") @@ -245,3 +245,20 @@ def test_do_not_touch_free(rounding_mode): assert l1.price_includes_rounding_correction == Decimal("0.00") assert l1.tax_value == Decimal("0.00") assert l1.tax_value_includes_rounding_correction == Decimal("0.00") + + +@pytest.mark.django_db +def test_only_business(): + lines = [OrderPosition( + price=Decimal("100.00"), + tax_value=Decimal("15.97"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) for _ in range(5)] + assert sum(l.price for l in lines) == Decimal("500.00") + + apply_rounding("sum_by_net_only_business", None, "EUR", lines) + assert sum(l.price for l in lines) == Decimal("500.00") + + apply_rounding("sum_by_net_only_business", InvoiceAddress(is_business=True), "EUR", lines) + assert sum(l.price for l in lines) == Decimal("499.98")