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()