From 900540f032dd24cdd7bda640b2e7798b105a613c Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 13 Aug 2025 15:23:32 +0200 Subject: [PATCH] Replace algorithm --- doc/development/algorithms/pricing.rst | 78 ++++++++++++------------- src/pretix/base/services/pricing.py | 47 +++++++++++---- src/pretix/base/settings.py | 7 ++- src/pretix/control/forms/event.py | 30 +++++----- src/tests/api/test_order_create.py | 2 +- src/tests/base/test_pricing_rounding.py | 78 ++++++++++++++++++++++--- src/tests/base/test_taxrules.py | 13 +++++ src/tests/presale/test_checkout.py | 4 +- 8 files changed, 180 insertions(+), 79 deletions(-) diff --git a/doc/development/algorithms/pricing.rst b/doc/development/algorithms/pricing.rst index 197f5d9749..e5e69c0ca2 100644 --- a/doc/development/algorithms/pricing.rst +++ b/doc/development/algorithms/pricing.rst @@ -198,7 +198,9 @@ pretix internally always stores taxes on a per-line level, like this: Sum 420.15 79.85 500.00 ========== ========== =========== ======= ============= -This has a few significant advantages: +Whether the net price is computed from the gross price or vice versa is configured on the tax rule and may differ for every line. + +The line-based computation has a few significant advantages: - We can report both net and gross prices for every individual ticket. @@ -207,42 +209,33 @@ This has a few significant advantages: - When splitting the order into two, both net price and gross price are split without any changes in rounding. -The main problem with this approach is that some external systems, formats, or jurisdictions expect a rounding scheme -that works differently. For example, the EN 16931 standard for electronic invoicing expects us to build the sum of -net values and then compute the tax on the document level, like this: +The main disadvantage is that the tax looks "wrong" when computed from the sum. Taking the sum of net prices (420.15) +and multiplying it with the tax rate (19%) yields a tax amount of 79.83 (instead of 79.85) and a gross sum of 499.98 +(instead of 499.98). This becomes a problem when juristictions, data formats, or external systems expect this calculation +to work on the level of the entire order. A prominent example is the EN 16931 standard for e-invoicing that +does not allow the computation as created by pretix. - ================= ========== =========== - Product Tax rate Net price - ================= ========== =========== - Ticket A 19 % 84.03 - Ticket B 19 % 84.03 - Ticket C 19 % 84.03 - Ticket D 19 % 84.03 - Ticket E 19 % 84.03 - Net sum 420.15 - Taxes on 420.15 79.83 - Gross sum 499.98 - ================= ========== =========== - -As the example shows, this causes a difference in the end price that needs to be paid by the end user. -So depending on the rounding scheme, a different payment amount might be due. -This has significant disadvantages: +However, calculating the tax rate from the net total has significant disadvantages: - It is impossible to guarantee a stable gross price this way, i.e. if you advertise a price of €100 per ticket to - consumers, they will be confused when they only need to pay €499.98. + consumers, they will be confused when they only need to pay €499.98 for 5 tickets. -- When splitting the order, the sum of the new orders might require additional payment or refund of a few cents. +- Some prices are impossible, e.g. you cannot sell a ticket for a gross price of €99.99 at a 19% tax rate, since there + is no two-decimal net price that would be computed to a gross price of €99.99. -pretix therefore allows you to choose between the following options: +- When splitting an order into two, the combined of the new orders is not guaranteed to be the same as the total of the + original order. Therefore, additional payments or refunds of very small amounts might be necessary. -Rounding every line individually -"""""""""""""""""""""""""""""""" +To allow organizers to make your own choices on this matter, pretix provides following options: + +Compute taxes for every line individually +""""""""""""""""""""""""""""""""""""""""" Algorithm identifier: ``line`` This is our original algorithm where the tax value is rounded for every line individually. -**This is our current algorithm and we recommend it whenever you do not have different requirements.** +**This is our current default algorithm and we recommend it whenever you do not have different requirements** (see below). For the example above: ========== ========== =========== ======= ============= @@ -257,17 +250,19 @@ For the example above: ========== ========== =========== ======= ============= -Rounding by order total, keeping net prices stable -"""""""""""""""""""""""""""""""""""""""""""""""""" +Compute taxes based on net total +"""""""""""""""""""""""""""""""" Algorithm identifier: ``sum_by_net`` -In this algorithm, the gross prices of some the individual lines will be modified such that the end value will be -compliant with the computation scheme expected by e.g. EN16931. This will lead to different gross prices to be shown -for tickets of the same type, but the net price will stay always the same. +In this algorithm, the tax value and gross total are computed from the sum of the net prices. To accomplish this within +our data model, the gross price and tax of some of the tickets will be changed by the minimum currency unit (e.g. €0.01). +The net price of the tickets always stay the same. -**This is our current algorithm and we recommend it whenever you need to round taxes on document-level and primarily deal with business customers.** -For the example above: +**This is the algorithm intended by EN 16931 invoices and our recommendation to use for e-invoicing when (primarily) business customers are involved.** + +The main downside is that it might be confusing when selling to consumers, since the amounts to be paid change in unexpected ways. +For the example above, the customer expects to pay 5 times 500.00, but they are are in fact charged 499.98: ========== ========== =========== ============================== ============================== Product Tax rate Net price Tax Gross price @@ -280,18 +275,19 @@ For the example above: Sum 420.15 78.83 499.98 ========== ========== =========== ============================== ============================== +Compute taxes based on net total with stable gross prices +""""""""""""""""""""""""""""""""""""""""""""""""""""""""" -Rounding by order total, keeping gross prices stable -"""""""""""""""""""""""""""""""""""""""""""""""""""" +Algorithm identifier: ``sum_by_net_keep_gross`` -Algorithm identifier: ``sum_by_gross`` +In this algorithm, the tax value and gross total are computed from the sum of the net prices. However, the net prices +of some of the tickets will be changed automatically by the minimum currency unit (e.g. €0.01) such that the resulting +gross prices stay the same. -In this algorithm, the net prices of some the individual lines will be modified such that the end value will be -compliant with the computation scheme expected by e.g. EN16931. This will lead to different net prices to be shown -for tickets of the same type, but the gross price will stay always the same. +**This is less confusing to consumers and the end result is still compliant to EN 16931, so we recommend this for e-invoicing when (primarily) consumers are involved.** -**This is our current algorithm and we recommend it whenever you need to round taxes on document-level and primarily deal with consumers.** -For the example above: +The main downside is that it might be confusing when sellin got business customers, since the prices of the identical tickets appear to be different. +Full computation for the example above: ========== ========== ============================= ============================== ============= Product Tax rate Net price Tax Gross price diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index f6f45a9474..7b59ea2149 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -209,7 +209,7 @@ 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_gross", "sum_by_net"], currency: str, +def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], 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 @@ -218,13 +218,13 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_gross", "sum_by_net"], 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_keep_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 rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``. :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. @@ -240,20 +240,26 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_gross", "sum_by_net"], 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))) + + # Compute the net and gross total of the line-based computation method 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) + + # Compute the gross total we need to achieve based on the net total target_gross_total = round_decimal((net_total * (1 + tax_rate / 100)), currency) + # Add/subtract the smallest possible from both gross prices and tax values (so net values stay the same) + # until the values align 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.price_includes_rounding_correction = apply_diff 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 @@ -265,27 +271,46 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_gross", "sum_by_net"], l.tax_value_includes_rounding_correction = Decimal("0.00") changed.append(l) - elif rounding_mode == "sum_by_gross": + elif rounding_mode == "sum_by_net_keep_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))) + + # Compute the net and gross total of the line-based computation method 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) + + # Compute the net total that would yield the correct gross total (if possible) 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 + # Compute the gross total that would be computed from that net total – this will be different than + # gross_total when there is no possible net value for the gross total + # e.g. 99.99 at 19% is impossible since 84.03 + 19% = 100.00 and 84.02 + 19% = 99.98 + target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency) + + diff_gross = target_gross_total - gross_total + diff_net = target_net_total - net_total + diff_gross_sgn = -1 if diff_gross < 0 else 1 + diff_net_sgn = -1 if diff_net < 0 else 1 for l in lines: - if diff: - apply_diff = diff_sgn * minimum_unit + if diff_gross: + apply_diff = diff_gross_sgn * minimum_unit + l.price = l.price - l.price_includes_rounding_correction + apply_diff + l.price_includes_rounding_correction = apply_diff + l.tax_value = l.tax_value - l.tax_value_includes_rounding_correction + apply_diff + l.tax_value_includes_rounding_correction = apply_diff + changed.append(l) + diff_gross -= apply_diff + elif diff_net: + apply_diff = diff_net_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) + diff_net -= apply_diff 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") diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 173a586aa1..cff5784e13 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -78,9 +78,10 @@ from pretix.control.forms import ( from pretix.helpers.countries import CachedCountries ROUNDING_MODES = ( - ('line', _('Round taxes for every line individually')), - ('sum_by_net', _('Round taxes by order total, keeping net prices stable')), - ('sum_by_gross', _('Round taxes by order total, keeping gross prices stable')), + ('line', _('Compute taxes for every line individually')), + ('sum_by_net', _('Compute taxes based on net total')), + ('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 bafbd62f33..240ebcc700 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -848,19 +848,23 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm): self.fields["display_net_prices"].label = _("Prices shown to customer") self.fields["display_net_prices"].widget = DisplayNetPricesBooleanSelect() help_text = { - "line": _("Recommended when e-invoicing is not required. Each product will be sold with the advertised " - "net and gross price. However, in orders of more than one product, the total tax amount " - "can differ from when it would be computed from the order total."), - "sum_by_net": _("Recommended for e-invoicing in Europe when you primarily sell to business customers and " - "show prices to customers excluding tax. " - "For orders of more than one product, the gross price of some products may be changed " - "automatically to ensure correct rounding of the order total, while the net prices " - "stay as configured. This may cause the actual payment amount to differ from buying the " - "products individually."), - "sum_by_gross": _("Recommended for e-invoicing in Europe when you primarily sell to consumers. " - "For an order of more than one product, the net price of some products may be changed " - "automatically to ensure correct rounding of the order total, while the gross prices " - "stay as configured."), + "line": _( + "Recommended when e-invoicing is not required. Each product will be sold with the advertised " + "net and gross price. However, in orders of more than one product, the total tax amount " + "can differ from when it would be computed from the order total." + ), + "sum_by_net": _( + "Recommended for e-invoicing when you primarily sell to business customers and " + "show prices to customers excluding tax. " + "The gross price of some products may be changed to ensure correct rounding, while the net " + "prices will be presented as configured. This may cause the actual payment amount to differ." + ), + "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 " + "rounding of the order total. The system attempts to keep gross prices as configured whenever " + "possible." + ), } self.fields["tax_rounding"].choices = ( (k, format_html('{}
{}', v, help_text.get(k, ""))) diff --git a/src/tests/api/test_order_create.py b/src/tests/api/test_order_create.py index 0542b250cd..e84618ab8c 100644 --- a/src/tests/api/test_order_create.py +++ b/src/tests/api/test_order_create.py @@ -3127,7 +3127,7 @@ def test_order_create_rounding_mode(token_client, organizer, event, item, quota, assert resp.data["positions"][0]["price"] == "99.99" assert resp.data["positions"][-1]["price"] == "100.00" - res["tax_rounding_mode"] = "sum_by_gross" + res["tax_rounding_mode"] = "sum_by_net_keep_gross" for simulate in (True, False): res["simulate"] = simulate resp = token_client.post( diff --git a/src/tests/base/test_pricing_rounding.py b/src/tests/base/test_pricing_rounding.py index 40902ff5cf..3d908868ab 100644 --- a/src/tests/base/test_pricing_rounding.py +++ b/src/tests/base/test_pricing_rounding.py @@ -39,7 +39,17 @@ def sample_lines(): def _validate_sample_lines(sample_lines, rounding_mode): - apply_rounding(rounding_mode, "EUR", sample_lines) + corrections = [ + (line.tax_value_includes_rounding_correction, line.price_includes_rounding_correction) + for line in sample_lines + ] + changed = apply_rounding(rounding_mode, "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 + else: + assert line not in changed + if rounding_mode == "line": for line in sample_lines: assert line.price == Decimal("100.00") @@ -54,13 +64,15 @@ def _validate_sample_lines(sample_lines, rounding_mode): assert line.tax_rate == Decimal("19.00") assert sum(line.price for line in sample_lines) == Decimal("499.98") assert sum(line.tax_value for line in sample_lines) == Decimal("79.83") - elif rounding_mode == "sum_by_gross": + assert sum(line.price - line.tax_value for line in sample_lines) == Decimal("420.15") + elif rounding_mode == "sum_by_net_keep_gross": for line in sample_lines: assert line.price == Decimal("100.00") # net price may vary assert line.tax_rate == Decimal("19.00") assert sum(line.price for line in sample_lines) == Decimal("500.00") assert sum(line.tax_value for line in sample_lines) == Decimal("79.83") + assert sum(line.price - line.tax_value for line in sample_lines) == Decimal("420.17") @pytest.mark.django_db @@ -75,13 +87,13 @@ def test_simple_case_by_net(sample_lines): @pytest.mark.django_db def test_simple_case_by_gross(sample_lines): - _validate_sample_lines(sample_lines, "sum_by_gross") + _validate_sample_lines(sample_lines, "sum_by_net_keep_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, "sum_by_net_keep_gross") _validate_sample_lines(sample_lines, "line") _validate_sample_lines(sample_lines, "sum_by_net") @@ -105,7 +117,7 @@ def test_revert_net_rounding_to_single_line(sample_lines): @pytest.mark.django_db -def test_revert_gross_rounding_to_single_line(sample_lines): +def test_revert_net_keep_gross_rounding_to_single_line(sample_lines): l = OrderPosition( price=Decimal("100.00"), price_includes_rounding_correction=Decimal("0.00"), @@ -114,7 +126,7 @@ def test_revert_gross_rounding_to_single_line(sample_lines): tax_rate=Decimal("19.00"), tax_code="S", ) - apply_rounding("sum_by_gross", "EUR", [l]) + apply_rounding("sum_by_net_keep_gross", "EUR", [l]) assert l.price == Decimal("100.00") assert l.price_includes_rounding_correction == Decimal("0.00") assert l.tax_value == Decimal("15.97") @@ -123,14 +135,64 @@ def test_revert_gross_rounding_to_single_line(sample_lines): @pytest.mark.django_db -def test_rounding_of_impossible_price(sample_lines): +@pytest.mark.parametrize("rounding_mode", [ + "sum_by_net", + "sum_by_net_keep_gross", +]) +def test_rounding_of_impossible_gross_price(rounding_mode): l = OrderPosition( price=Decimal("23.00"), ) l._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress()) - apply_rounding("sum_by_net", "EUR", [l]) + apply_rounding(rounding_mode, "EUR", [l]) assert l.price == Decimal("23.01") assert l.price_includes_rounding_correction == Decimal("0.01") assert l.tax_value == Decimal("1.51") assert l.tax_value_includes_rounding_correction == Decimal("0.01") assert l.tax_rate == Decimal("7.00") + + +@pytest.mark.django_db +def test_round_down(): + 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") + 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) + 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) + 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") + + +@pytest.mark.django_db +def test_round_up(): + lines = [OrderPosition( + price=Decimal("99.98"), + tax_value=Decimal("15.96"), + tax_rate=Decimal("19.00"), + tax_code="S", + ) for _ in range(5)] + assert sum(l.price for l in lines) == Decimal("499.90") + 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) + 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) + 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") diff --git a/src/tests/base/test_taxrules.py b/src/tests/base/test_taxrules.py index e4819e2115..e5ed280284 100644 --- a/src/tests/base/test_taxrules.py +++ b/src/tests/base/test_taxrules.py @@ -60,6 +60,19 @@ def test_from_gross_price(event): assert tp.rate == Decimal('10.00') assert tp.code == 'S/standard' + tr = TaxRule( + event=event, + rate=Decimal('19.00'), + code=None, + price_includes_tax=True, + ) + tp = tr.tax(Decimal('99.99')) + assert tp.gross == Decimal('99.99') + assert tp.net == Decimal('84.03') + assert tp.tax == Decimal('15.96') + assert tp.rate == Decimal('19.00') + assert tp.code is None + @pytest.mark.django_db def test_from_net_price(event): diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 522ee2254a..3b0dfb686b 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -2138,8 +2138,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): assert op2.tax_value == Decimal("15.97") assert op2.tax_value_includes_rounding_correction == Decimal("0.00") - def test_rounding_sum_by_gross(self): - self.event.settings.tax_rounding = "sum_by_gross" + def test_rounding_sum_by_net_keep_gross(self): + self.event.settings.tax_rounding = "sum_by_net_keep_gross" self.event.settings.set('payment_banktransfer__enabled', True) self.ticket.default_price = Decimal("100.00") self.ticket.save()