Replace algorithm

This commit is contained in:
Raphael Michel
2025-08-13 15:23:32 +02:00
parent cb2815a7f0
commit 900540f032
8 changed files with 180 additions and 79 deletions
+37 -41
View File
@@ -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
+36 -11
View File
@@ -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")
+4 -3
View File
@@ -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
)
+17 -13
View File
@@ -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('{}<br><span class="text-muted">{}</span>', v, help_text.get(k, "")))
+1 -1
View File
@@ -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(
+70 -8
View File
@@ -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")
+13
View File
@@ -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):
+2 -2
View File
@@ -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()