Allow to round taxes on order-level (#5019)

* Allow to round taxes on order-level

* Rename get_cart_total

* Persist rounding mode with order

* Add general docs

* Order creation API

* Update fee algorithm

* Rounding on payment method change

* Round when splitting order

* Fix failing tests

* Add settings page

* Add tests

* Replace algorithm

* Add test case for currency rounding

* Improve order change

* Update flowchart

* Update discount logic (more hypothetical, we don't store rounding on cart positions atm)

* Rename internal method

* Fix typo

* Update help text

* Apply suggestions from code review

Co-authored-by: luelista <weller@rami.io>

* Order rounding refactor (#5571)

* Add RoundingCorrectionMixin providing before-rounding-values as properties

* Use gross_price_before_rounding in more places

* Update doc/development/algorithms/pricing.rst

Co-authored-by: Martin Gross <gross@rami.io>

* Allow to override on perform_order

* Rebase migration

* Fix event cancellation

---------

Co-authored-by: luelista <weller@rami.io>
Co-authored-by: Martin Gross <gross@rami.io>
This commit is contained in:
Raphael Michel
2025-10-30 11:49:31 +01:00
committed by GitHub
parent cdeb1e86bd
commit 3e972eddbf
37 changed files with 1923 additions and 319 deletions

View File

@@ -1820,6 +1820,63 @@ class OrderChangeManagerTests(TestCase):
assert round_decimal(self.op1.price * (1 - 100 / (100 + self.op1.tax_rate))) == self.op1.tax_value
assert self.order.total == self.op1.price + self.op2.price
@classscope(attr='o')
def test_change_price_with_rounding_change_impossible(self):
# Order starts with 2*100€ tickets, but rounding corrects it to 199€. Then, the user tries to force both prices
# to 100€. No luck.
self.order.status = Order.STATUS_PAID
self.order.tax_rounding_mode = "sum_by_net"
self.order.save()
self.op1.price = Decimal("100.00")
self.op1._calculate_tax(tax_rule=self.tr19)
self.op1.save()
self.op2.price = Decimal("100.00")
self.op2._calculate_tax(tax_rule=self.tr19)
self.op2.save()
self.order.refresh_from_db()
self.ocm.regenerate_secret(self.op1)
self.ocm.commit() # Force re-rounding
self.order.refresh_from_db()
self.ocm = OrderChangeManager(self.order, None)
assert self.order.total == Decimal("199.99")
self.ocm.change_price(self.op1, Decimal('100.00'))
self.ocm.change_price(self.op2, Decimal('100.00'))
self.ocm.commit()
self.op1.refresh_from_db()
self.op2.refresh_from_db()
self.order.refresh_from_db()
assert self.order.total == Decimal("199.99")
assert self.op1.price == Decimal('99.99')
assert self.op2.price == Decimal('100.00')
@classscope(attr='o')
def test_change_price_with_rounding_change_autocorrected(self):
self.order.status = Order.STATUS_PAID
self.order.tax_rounding_mode = "sum_by_net"
self.order.save()
self.op1.price = Decimal("0.00")
self.op1._calculate_tax(tax_rule=self.tr19)
self.op1.save()
self.op2.price = Decimal("100.00")
self.op2._calculate_tax(tax_rule=self.tr19)
self.op2.save()
self.order.refresh_from_db()
self.ocm.regenerate_secret(self.op1)
self.ocm.commit() # Force re-rounding
self.order.refresh_from_db()
self.ocm = OrderChangeManager(self.order, None)
assert self.order.total == Decimal("100.00")
self.ocm.change_price(self.op1, Decimal('100.00'))
self.ocm.commit()
self.op1.refresh_from_db()
self.op2.refresh_from_db()
self.order.refresh_from_db()
assert self.order.total == Decimal("199.99")
assert self.op1.price == Decimal('99.99')
assert self.op2.price == Decimal('100.00')
@classscope(attr='o')
def test_change_price_net_success(self):
self.tr7.price_includes_tax = False
@@ -2408,6 +2465,24 @@ class OrderChangeManagerTests(TestCase):
assert nop.price == Decimal('12.00')
assert nop.subevent == se1
@classscope(attr='o')
def test_add_item_with_rounding(self):
self.order.tax_rounding_mode = "sum_by_net"
self.order.save()
self.ocm.add_position(self.ticket, None, None, None)
self.ocm.commit()
self.order.refresh_from_db()
assert self.order.positions.count() == 3
op1, op2, op3 = self.order.positions.all()
assert op1.price == Decimal("23.01")
assert op1.price_includes_rounding_correction == Decimal("0.01")
assert op2.price == Decimal("23.01")
assert op2.price_includes_rounding_correction == Decimal("0.01")
assert op3.price == Decimal("23.00")
assert op3.price_includes_rounding_correction == Decimal("0.00")
assert self.order.total == Decimal("69.02")
assert self.order.transactions.count() == 7
@classscope(attr='o')
def test_reissue_invoice(self):
generate_invoice(self.order)
@@ -2522,7 +2597,7 @@ class OrderChangeManagerTests(TestCase):
def test_recalculate_country_rate(self):
prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_abs', Decimal('0.30'))
self.ocm._recalculate_total_and_payment_fee()
self.ocm._recalculate_rounding_total_and_payment_fee()
assert self.order.total == Decimal('46.30')
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
@@ -2554,7 +2629,7 @@ class OrderChangeManagerTests(TestCase):
def test_recalculate_country_rate_keep_gross(self):
prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_abs', Decimal('0.30'))
self.ocm._recalculate_total_and_payment_fee()
self.ocm._recalculate_rounding_total_and_payment_fee()
assert self.order.total == Decimal('46.30')
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
@@ -2584,7 +2659,7 @@ class OrderChangeManagerTests(TestCase):
def test_recalculate_reverse_charge(self):
prov = self.ocm._get_payment_provider()
prov.settings.set('_fee_abs', Decimal('0.30'))
self.ocm._recalculate_total_and_payment_fee()
self.ocm._recalculate_rounding_total_and_payment_fee()
assert self.order.total == Decimal('46.30')
fee = self.order.fees.get(fee_type=OrderFee.FEE_TYPE_PAYMENT)
@@ -2815,6 +2890,61 @@ class OrderChangeManagerTests(TestCase):
assert p.amount == Decimal('23.00')
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
@classscope(attr='o')
def test_split_with_rounding_change(self):
# Order starts with 2*100€ tickets, but rounding corrects it to 199€. Then, it gets split, so its now 100 + 100
# and 1€ is pending. Nasty, but we didn't choose the EN16931 rounding method…
self.order.status = Order.STATUS_PAID
self.order.tax_rounding_mode = "sum_by_net"
self.order.save()
self.op1.price = Decimal("100.00")
self.op1._calculate_tax(tax_rule=self.tr19)
self.op1.save()
self.op2.price = Decimal("100.00")
self.op2._calculate_tax(tax_rule=self.tr19)
self.op2.save()
self.order.refresh_from_db()
self.ocm.regenerate_secret(self.op1)
self.ocm.commit() # Force re-rounding
self.order.refresh_from_db()
self.ocm = OrderChangeManager(self.order, None)
assert self.order.total == Decimal("199.99")
self.order.payments.create(
provider='manual',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
amount=self.order.total,
)
# Split
self.ocm.split(self.op2)
self.ocm.commit()
self.order.refresh_from_db()
self.op2.refresh_from_db()
# First order
assert self.order.total == Decimal('100.00')
assert not self.order.fees.exists()
assert self.order.pending_sum == Decimal('0.01')
assert self.order.status == Order.STATUS_PENDING
r = self.order.refunds.last()
assert r.provider == 'offsetting'
assert r.amount == Decimal('100.00')
assert r.state == OrderRefund.REFUND_STATE_DONE
# New order
assert self.op2.order != self.order
o2 = self.op2.order
assert o2.total == Decimal('100.00')
assert o2.status == Order.STATUS_PAID
assert o2.positions.count() == 1
assert o2.fees.count() == 0
assert o2.pending_sum == Decimal('0.00')
p = o2.payments.last()
assert p.provider == 'offsetting'
assert p.amount == Decimal('100.00')
assert p.state == OrderPayment.PAYMENT_STATE_CONFIRMED
@classscope(attr='o')
def test_split_and_change_higher(self):
self.order.status = Order.STATUS_PAID

View File

@@ -0,0 +1,247 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
import pytest
from pretix.base.models import InvoiceAddress, OrderPosition, TaxRule
from pretix.base.services.pricing import apply_rounding
@pytest.fixture
def sample_lines():
lines = [OrderPosition(
price=Decimal("100.00"),
tax_value=Decimal("15.97"),
tax_rate=Decimal("19.00"),
tax_code="S",
) for _ in range(5)]
return lines
def _validate_sample_lines(sample_lines, rounding_mode):
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")
assert line.tax_value == Decimal("15.97")
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.85")
elif rounding_mode == "sum_by_net":
for line in sample_lines:
# gross price may vary
assert line.price - line.tax_value == Decimal("84.03")
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")
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
def test_simple_case_by_line(sample_lines):
_validate_sample_lines(sample_lines, "line")
@pytest.mark.django_db
def test_simple_case_by_net(sample_lines):
_validate_sample_lines(sample_lines, "sum_by_net")
@pytest.mark.django_db
def test_simple_case_by_gross(sample_lines):
_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_net_keep_gross")
_validate_sample_lines(sample_lines, "line")
_validate_sample_lines(sample_lines, "sum_by_net")
@pytest.mark.django_db
def test_revert_net_rounding_to_single_line(sample_lines):
l = OrderPosition(
price=Decimal("100.01"),
price_includes_rounding_correction=Decimal("0.01"),
tax_value=Decimal("15.98"),
tax_value_includes_rounding_correction=Decimal("0.01"),
tax_rate=Decimal("19.00"),
tax_code="S",
)
apply_rounding("sum_by_net", "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
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"),
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_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")
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",
"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(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")
@pytest.mark.django_db
def test_round_currency_without_decimals():
lines = [OrderPosition(
price=Decimal("9998.00"),
tax_value=Decimal("1596.00"),
tax_rate=Decimal("19.00"),
tax_code="S",
) for _ in range(5)]
assert sum(l.price for l in lines) == Decimal("49990.00")
assert sum(l.tax_value for l in lines) == Decimal("7980.00")
assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00")
apply_rounding("sum_by_net", "JPY", lines)
assert sum(l.price for l in lines) == Decimal("49992.00")
assert sum(l.tax_value for l in lines) == Decimal("7982.00")
assert sum(l.price - l.tax_value for l in lines) == Decimal("42010.00")
apply_rounding("sum_by_net_keep_gross", "JPY", lines)
assert sum(l.price for l in lines) == Decimal("49990.00")
assert sum(l.tax_value for l in lines) == Decimal("7982.00")
assert sum(l.price - l.tax_value for l in lines) == Decimal("42008.00")
@pytest.mark.django_db
@pytest.mark.parametrize("rounding_mode", [
"sum_by_net",
"sum_by_net_keep_gross",
])
def test_do_not_touch_free(rounding_mode):
l1 = OrderPosition(
price=Decimal("0.00"),
)
l1._calculate_tax(tax_rule=TaxRule(rate=Decimal("7.00")), invoice_address=InvoiceAddress())
l2 = OrderPosition(
price=Decimal("23.00"),
)
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.tax_rate == Decimal("7.00")
assert l1.price == Decimal("0.00")
assert l1.price_includes_rounding_correction == Decimal("0.00")
assert l1.tax_value == Decimal("0.00")
assert l1.tax_value_includes_rounding_correction == Decimal("0.00")

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):
@@ -978,7 +991,7 @@ def test_split_fees(event):
op2 = OrderPosition(price=Decimal("10.70"), item=item)
op2._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress())
of1 = OrderFee(value=Decimal("5.00"), fee_type=OrderFee.FEE_TYPE_SHIPPING)
of1._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress())
of1._calculate_tax(tax_rule=tr7, invoice_address=InvoiceAddress(), event=event)
# Example of a 10% service fee
assert split_fee_for_taxes([op1, op2], Decimal("2.26"), event) == [