Compare commits

...

1 Commits

Author SHA1 Message Date
Raphael Michel
5818d6d66f Price rounding: Make sum_by_net_keep_gross even more consumer-friendly (Z#23220106)
The rounding mode sum_by_net_keep_gross is supposed to be the
consumer-friendly algorithm. However, some consumer-friendly prices are still
impossible, such as 15.00 incl. 19%.  Previously, the system would round
to 15.01, because it's the gross price derived from the closest net
price. With this PR, it rounds to 14.99 instead, because consumers like
paying less more than they like paying more.
2026-01-08 10:12:34 +01:00
4 changed files with 45 additions and 13 deletions

View File

@@ -286,6 +286,11 @@ 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.**
Some gross prices **cannot** stay the same. For example, it is impossible to represent €15.00 incl. 19%, since a net
price of €12.60 would lead to €14.99 gross and a net price of €12.61 would lead to €15.01 gross. Since this algorithm
is supposed to be consumer-friendly, it has a bias for choosing €14.99 in this case, even if €15.01 would be a little
less of a difference.
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:

View File

@@ -284,12 +284,27 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep
# 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)
if target_net_total and target_gross_total > gross_total:
target_net_total -= min((target_gross_total - gross_total), len(lines) * minimum_unit)
try_target_gross_total = round_decimal((target_net_total * (1 + tax_rate / 100)), currency)
if try_target_gross_total <= gross_total:
target_gross_total = try_target_gross_total
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_gross:
if diff_gross and diff_net:
apply_diff = diff_gross_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
l.tax_value = l.tax_value_before_rounding
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
diff_gross -= apply_diff
diff_net -= apply_diff
elif diff_gross:
apply_diff = diff_gross_sgn * minimum_unit
l.price = l.gross_price_before_rounding + apply_diff
l.price_includes_rounding_correction = apply_diff
@@ -312,6 +327,11 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep
l.tax_value_includes_rounding_correction = Decimal("0.00")
changed.append(l)
# Double-check that result is consistent in computing gross from net
new_net_total = sum(l.price - l.tax_value for l in lines)
new_gross_total = sum(l.price for l in lines)
assert new_gross_total == round_decimal((new_net_total * (1 + tax_rate / 100)), currency)
elif rounding_mode == "line":
for l in lines:
if l.price_includes_rounding_correction or l.tax_value_includes_rounding_correction:

View File

@@ -871,7 +871,8 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
"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. Gross prices may still change if they are impossible to derive from a rounded net price."
"possible. Gross prices may still change if they are impossible to derive from a rounded net price, "
"but the system will prefer rounding them down instead of up."
),
}
self.fields["tax_rounding"].choices = (

View File

@@ -134,17 +134,12 @@ def test_revert_net_keep_gross_rounding_to_single_line(sample_lines):
assert l.tax_rate == Decimal("19.00")
@pytest.mark.django_db
@pytest.mark.parametrize("rounding_mode", [
"sum_by_net",
"sum_by_net_keep_gross",
])
def test_rounding_of_impossible_gross_price(rounding_mode):
def test_rounding_of_impossible_gross_price():
l = OrderPosition(
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("sum_by_net", "EUR", [l])
assert l.price == Decimal("23.01")
assert l.price_includes_rounding_correction == Decimal("0.01")
assert l.tax_value == Decimal("1.51")
@@ -152,6 +147,19 @@ def test_rounding_of_impossible_gross_price(rounding_mode):
assert l.tax_rate == Decimal("7.00")
def test_rounding_of_impossible_gross_price_keep_gross():
l = OrderPosition(
price=Decimal("23.00"),
)
l._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress())
apply_rounding("sum_by_net_keep_gross", "EUR", [l])
assert l.price == Decimal("22.99")
assert l.price_includes_rounding_correction == Decimal("-0.01")
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(
@@ -236,10 +244,8 @@ def test_do_not_touch_free(rounding_mode):
)
l2._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress())
apply_rounding(rounding_mode, "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")
assert l2.tax_value_includes_rounding_correction == Decimal("0.01")
assert l2.price == Decimal("23.01") or l2.price == Decimal("22.99")
assert l2.price_includes_rounding_correction != Decimal("0.00") or l2.tax_value_includes_rounding_correction != Decimal("0.00")
assert l2.tax_rate == Decimal("7.00")
assert l1.price == Decimal("0.00")
assert l1.price_includes_rounding_correction == Decimal("0.00")