Compare commits

...
1 Commits
Author SHA1 Message Date
Raphael Michel 4ef26595f5 Tax rounding: Allow to round by gross total 2026-10-27 13:38:04 +01:00
5 changed files with 120 additions and 6 deletions

No files matched your search

+27 -1
View File
@@ -284,7 +284,10 @@ In this algorithm, the tax value and gross total are computed from the sum of th
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.
**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 a bit 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.**
However, confusion for consumers can still arise when a price cannot be represented by a net price.
For example, you cannot sell a single ticket for 15.00 with a 19 % tax rate, because 12.60 * 1.19 = 14.99 and 12.61 * 1.19 = 15.01, so a total gross price of 15.00 is impossible to reach and the system will round to a different gross price.
The main downside is that it might be confusing when selling to business customers, since the prices of the identical tickets appear to be different.
Full computation for the example above:
@@ -299,3 +302,26 @@ Full computation for the example above:
Ticket E 19 % 84.03 15.97 100.00
Sum 420.17 79.83 500.00
========== ========== ============================= ============================== =============
Compute taxes based on gross total
""""""""""""""""""""""""""""""""""
Algorithm identifier: ``sum_by_gross``
In this algorithm, the net prices of some the individual lines will be modified such that the end values will be consistent with a computation from the gross sum.
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 the most sensible algorithm when dealing mostly with consumer or when advertising gross prices, and you still need to issue e-invoices. Technically, it is incompliant with EN 16931, but at least for Germany, the Federal Ministry of Finance (BMF) allows to use it until end of 2027 in lieu of a better solution.**
For the example above:
========== ========== ============================= ============================== =============
Product Tax rate Net price Tax Gross price
========== ========== ============================= ============================== =============
Ticket A 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket B 19 % 84.04 (incl. 0.01 rounding) 15.96 (incl. -0.01 rounding) 100.00
Ticket C 19 % 84.03 15.97 100.00
Ticket D 19 % 84.03 15.97 100.00
Ticket E 19 % 84.03 15.97 100.00
Sum 420.17 79.83 500.00
========== ========== ============================= ============================== =============
+33 -2
View File
@@ -211,7 +211,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_net", "sum_by_net_only_business", "sum_by_net_keep_gross"],
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only_business", "sum_by_net_keep_gross", "sum_by_gross"],
invoice_address: Optional[InvoiceAddress], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
"""
@@ -227,7 +227,10 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only
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"``, ``"sum_by_net_only_business"``, or ``"sum_by_net_keep_gross"``.
When rounding mode is set to ``"sum_by_gross"``, the net prices and 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.
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, ``"sum_by_net_only_business"``, ``"sum_by_net_keep_gross"``, or ``"sum_by_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
@@ -321,6 +324,34 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only
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:
+1 -1
View File
@@ -83,7 +83,7 @@ ROUNDING_MODES = (
('sum_by_net', _('Compute taxes based on net total')),
('sum_by_net_only_business', _('For business customers, compute taxes based on net total. For individuals, use line-based rounding')),
('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
('sum_by_gross', _('Compute taxes based on gross total')),
)
+8 -1
View File
@@ -990,11 +990,18 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
"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. "
"Recommended for e-invoicing when you primarily sell to consumers and need invoices compliant with EN 16931. "
"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. Gross prices may still change if they are impossible to derive from a rounded net price."
),
"sum_by_gross": _(
"Recommended for e-invoicing when you primarily sell to consumers or advertise gross prices. "
"The net price of some products may be changed to ensure correct rounding, while the gross "
"prices will be kept as configured. This actual payment amount always stays as expected. This is "
"technically not compliant with European e-invoicing standards, but e.g. allowed within Germany at "
"least until end of 2027."
),
}
self.fields["tax_rounding"].choices = (
(k, format_html('{}<br><span class="text-muted">{}</span>', v, help_text.get(k, "")))
+51 -1
View File
@@ -73,6 +73,14 @@ def _validate_sample_lines(sample_lines, rounding_mode):
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")
elif rounding_mode == "sum_by_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
@@ -86,16 +94,22 @@ def test_simple_case_by_net(sample_lines):
@pytest.mark.django_db
def test_simple_case_by_gross(sample_lines):
def test_simple_case_net_by_gross(sample_lines):
_validate_sample_lines(sample_lines, "sum_by_net_keep_gross")
@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_net_keep_gross")
_validate_sample_lines(sample_lines, "line")
_validate_sample_lines(sample_lines, "sum_by_net")
_validate_sample_lines(sample_lines, "sum_by_gross")
@pytest.mark.django_db
@@ -134,6 +148,24 @@ def test_revert_net_keep_gross_rounding_to_single_line(sample_lines):
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", 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")
assert l.tax_value_includes_rounding_correction == Decimal("0.00")
assert l.tax_rate == Decimal("19.00")
@pytest.mark.django_db
@pytest.mark.parametrize("rounding_mode", [
"sum_by_net",
@@ -152,6 +184,24 @@ def test_rounding_of_impossible_gross_price(rounding_mode):
assert l.tax_rate == Decimal("7.00")
@pytest.mark.django_db
@pytest.mark.parametrize("rounding_mode", [
"sum_by_gross",
"line",
])
def test_rounding_of_impossible_gross_price_possible_when_not_rounding_by_net(rounding_mode):
l = OrderPosition(
price=Decimal("23.00"),
)
l._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress())
apply_rounding(rounding_mode, None, "EUR", [l])
assert l.price == Decimal("23.00")
assert l.price_includes_rounding_correction == Decimal("0.00")
assert l.tax_value == Decimal("1.50")
assert l.tax_value_includes_rounding_correction == Decimal("0.00")
assert l.tax_rate == Decimal("7.00")
@pytest.mark.django_db
def test_round_down():
lines = [OrderPosition(