mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
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:
@@ -420,6 +420,7 @@ def test_order_create_simulate(token_client, organizer, event, item, quota, ques
|
||||
}
|
||||
],
|
||||
'total': '21.75',
|
||||
'tax_rounding_mode': 'line',
|
||||
'comment': '',
|
||||
'api_meta': {},
|
||||
"custom_followup_at": None,
|
||||
@@ -3259,3 +3260,79 @@ def test_order_create_auto_pricing_explicit_discount_not_allowed(token_client, o
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_rounding_mode(token_client, organizer, event, item, quota, question, taxrule):
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res["tax_rounding_mode"] = "sum_by_net"
|
||||
res['fees'][0]['_split_taxes_like_products'] = True
|
||||
res['fees'][0]['value'] = Decimal("100.00")
|
||||
res['positions'] = [
|
||||
{
|
||||
"item": item.pk,
|
||||
"price": "100.00",
|
||||
}
|
||||
] * 4
|
||||
|
||||
for simulate in (True, False):
|
||||
res["simulate"] = simulate
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "499.98"
|
||||
assert resp.data["positions"][0]["price"] == "99.99"
|
||||
assert resp.data["positions"][-1]["price"] == "100.00"
|
||||
|
||||
res["tax_rounding_mode"] = "sum_by_net_keep_gross"
|
||||
for simulate in (True, False):
|
||||
res["simulate"] = simulate
|
||||
resp = token_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "500.00"
|
||||
assert resp.data["positions"][0]["tax_value"] == "15.96"
|
||||
assert resp.data["positions"][-1]["tax_value"] == "15.97"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_order_create_rounding_default_pretixpos_fallback(device, device_client, organizer, event, item, quota, question, taxrule):
|
||||
res = copy.deepcopy(ORDER_CREATE_PAYLOAD)
|
||||
res['fees'][0]['_split_taxes_like_products'] = True
|
||||
res['fees'][0]['value'] = Decimal("100.00")
|
||||
res['positions'] = [
|
||||
{
|
||||
"item": item.pk,
|
||||
"price": "100.00",
|
||||
}
|
||||
] * 4
|
||||
|
||||
event.settings.tax_rounding = "sum_by_net"
|
||||
|
||||
resp = device_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "499.98"
|
||||
assert resp.data["positions"][0]["price"] == "99.99"
|
||||
assert resp.data["positions"][-1]["price"] == "100.00"
|
||||
|
||||
device.software_brand = "pretixPOS Android"
|
||||
device.save()
|
||||
resp = device_client.post(
|
||||
'/api/v1/organizers/{}/events/{}/orders/'.format(
|
||||
organizer.slug, event.slug
|
||||
), format='json', data=res
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data["total"] == "500.00"
|
||||
assert resp.data["positions"][0]["price"] == "100.00"
|
||||
assert resp.data["positions"][-1]["price"] == "100.00"
|
||||
|
||||
@@ -306,6 +306,7 @@ TEST_ORDER_RES = {
|
||||
"url": "http://example.com/dummy/dummy/order/FOO/k24fiuwvu8kxz3y1/",
|
||||
"payment_provider": "banktransfer",
|
||||
"total": "23.00",
|
||||
"tax_rounding_mode": "line",
|
||||
"comment": "",
|
||||
"api_meta": {},
|
||||
"custom_followup_at": None,
|
||||
|
||||
@@ -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
|
||||
|
||||
247
src/tests/base/test_pricing_rounding.py
Normal file
247
src/tests/base/test_pricing_rounding.py
Normal 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")
|
||||
@@ -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) == [
|
||||
|
||||
@@ -77,7 +77,7 @@ class BaseCheckoutTestCase:
|
||||
plugins='pretix.plugins.stripe,pretix.plugins.banktransfer,tests.testdummy',
|
||||
live=True
|
||||
)
|
||||
self.tr19 = self.event.tax_rules.create(rate=19)
|
||||
self.tr19 = self.event.tax_rules.create(rate=19, default=True)
|
||||
self.category = ItemCategory.objects.create(event=self.event, name="Everything", position=0)
|
||||
self.quota_tickets = Quota.objects.create(event=self.event, name='Tickets', size=5)
|
||||
self.ticket = Item.objects.create(event=self.event, name='Early-bird ticket',
|
||||
@@ -501,6 +501,8 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
||||
assert cr1.price == Decimal('23.00')
|
||||
|
||||
def test_custom_tax_rules_blocked_on_fee(self):
|
||||
self.tr19.default = False
|
||||
self.tr19.save()
|
||||
self.tr7 = self.event.tax_rules.create(rate=7, default=True)
|
||||
self.tr7.custom_rules = json.dumps([
|
||||
{'country': 'AT', 'address_type': 'business_vat_id', 'action': 'reverse'},
|
||||
@@ -2352,6 +2354,252 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
||||
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
def test_rounding_sum_by_net(self):
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.ticket.default_price = Decimal("100.00")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 2
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"199.99" in response.content
|
||||
assert b"200.00" not in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('199.99')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('199.99')
|
||||
assert o.total == Decimal("199.99")
|
||||
op1, op2 = o.positions.all()
|
||||
assert op1.price == Decimal("99.99")
|
||||
assert op1.price_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op1.tax_value == Decimal("15.96")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op2.price == Decimal("100.00")
|
||||
assert op2.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.tax_value == Decimal("15.97")
|
||||
assert op2.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
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()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 2
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"199.99" not in response.content
|
||||
assert b"200.00" in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('200.00')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') + Decimal('84.04')
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('200.00')
|
||||
assert o.total == Decimal("200.00")
|
||||
op1, op2 = o.positions.all()
|
||||
assert op1.price == Decimal("100.00")
|
||||
assert op1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op1.tax_value == Decimal("15.96")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op2.price == Decimal("100.00")
|
||||
assert op2.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.tax_value == Decimal("15.97")
|
||||
assert op2.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_rounding_line(self):
|
||||
self.event.settings.tax_rounding = "line"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.ticket.default_price = Decimal("100.00")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 2
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"199.99" not in response.content
|
||||
assert b"200.00" in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('200.00')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('200.00')
|
||||
assert o.total == Decimal("200.00")
|
||||
op1, op2 = o.positions.all()
|
||||
assert op1.price == Decimal("100.00")
|
||||
assert op1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op1.tax_value == Decimal("15.97")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.price == Decimal("100.00")
|
||||
assert op2.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert op2.tax_value == Decimal("15.97")
|
||||
assert op2.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_rounding_sum_by_net_with_payment_fee(self):
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.tax_rule_payment = "default"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.event.settings.set('payment_banktransfer__fee_abs', Decimal("100.00"))
|
||||
self.ticket.default_price = Decimal("100.00")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 1
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"100.00" in response.content
|
||||
assert b"99.99" not in response.content
|
||||
assert b"199.99" not in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('199.99')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.03') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1 = o.payments.get()
|
||||
assert p1.amount == Decimal('199.99')
|
||||
assert o.total == Decimal("199.99")
|
||||
op1 = o.positions.get()
|
||||
of1 = o.fees.get()
|
||||
assert op1.price == Decimal("99.99")
|
||||
assert op1.price_includes_rounding_correction == Decimal("-0.01")
|
||||
assert op1.tax_value == Decimal("15.96")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("-0.01")
|
||||
assert of1.price == Decimal("100.00")
|
||||
assert of1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert of1.tax_value == Decimal("15.97")
|
||||
assert of1.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_rounding_sum_by_net_with_payment_fee_that_makes_card_insufficient(self):
|
||||
# Our built-in gift card payment does not actually support setting a payment fee, but we still want to
|
||||
# test the core behavior in case a gift-card plugin does
|
||||
gc = self.orga.issued_gift_cards.create(currency="EUR")
|
||||
gc.transactions.create(value=199.96, acceptor=self.orga)
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.event.settings.set('payment_giftcard__fee_abs', "99.98")
|
||||
self.event.settings.set('payment_giftcard__fee_reverse_calc', False)
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.tax_rule_payment = "default"
|
||||
self.ticket.default_price = Decimal("99.98")
|
||||
self.ticket.save()
|
||||
with scopes_disabled():
|
||||
cm = CartManager(event=self.event, cart_id=self.session_key, sales_channel=self.orga.sales_channels.get(identifier="web"))
|
||||
cm.add_new_items([{
|
||||
'item': self.ticket.pk,
|
||||
'variation': None,
|
||||
'count': 1
|
||||
}])
|
||||
cm.commit()
|
||||
|
||||
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
assert b"99.98" in response.content
|
||||
assert b"99.99" not in response.content
|
||||
assert b"199.97" not in response.content
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'giftcard',
|
||||
'payment_giftcard-code': gc.secret
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
|
||||
'payment': 'banktransfer',
|
||||
}, follow=True)
|
||||
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
|
||||
target_status_code=200)
|
||||
|
||||
assert response.context_data['cart']['total'] == Decimal('199.97')
|
||||
assert response.context_data['cart']['net_total'] == Decimal('84.02') * 2
|
||||
|
||||
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
|
||||
doc = BeautifulSoup(response.content.decode(), "lxml")
|
||||
self.assertEqual(len(doc.select(".thank-you")), 1)
|
||||
with scopes_disabled():
|
||||
o = Order.objects.last()
|
||||
p1, p2 = o.payments.all()
|
||||
assert p1.amount == Decimal('199.96')
|
||||
assert p2.amount == Decimal('0.01')
|
||||
assert o.total == Decimal("199.97")
|
||||
op1 = o.positions.get()
|
||||
of1 = o.fees.get()
|
||||
assert op1.price == Decimal("99.99")
|
||||
assert op1.price_includes_rounding_correction == Decimal("0.01")
|
||||
assert op1.tax_value == Decimal("15.97")
|
||||
assert op1.tax_value_includes_rounding_correction == Decimal("0.01")
|
||||
assert of1.price == Decimal("99.98")
|
||||
assert of1.price_includes_rounding_correction == Decimal("0.00")
|
||||
assert of1.tax_value == Decimal("15.96")
|
||||
assert of1.tax_value_includes_rounding_correction == Decimal("0.00")
|
||||
|
||||
def test_subevent(self):
|
||||
self.event.has_subevents = True
|
||||
self.event.save()
|
||||
|
||||
@@ -1334,6 +1334,56 @@ class OrdersTest(BaseOrdersTest):
|
||||
p.refresh_from_db()
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CREATED
|
||||
|
||||
def test_change_paymentmethod_with_rounding_change(self):
|
||||
tr19 = self.event.tax_rules.create(
|
||||
name='VAT',
|
||||
rate=Decimal('19.00'),
|
||||
default=True
|
||||
)
|
||||
self.ticket.tax_rule = tr19
|
||||
self.ticket.save()
|
||||
self.ticket_pos.price = Decimal("100.00")
|
||||
self.ticket_pos.tax_rule = tr19
|
||||
self.ticket_pos._calculate_tax()
|
||||
self.ticket_pos.save()
|
||||
self.order.total = Decimal("100.00")
|
||||
self.order.tax_rounding_mode = "sum_by_net"
|
||||
self.order.save()
|
||||
|
||||
self.event.settings.tax_rounding = "sum_by_net"
|
||||
self.event.settings.set('payment_banktransfer__enabled', True)
|
||||
self.event.settings.set('payment_testdummy__enabled', True)
|
||||
self.event.settings.set('payment_testdummy__fee_reverse_calc', False)
|
||||
self.event.settings.set('payment_testdummy__fee_abs', '100.00')
|
||||
|
||||
response = self.client.get(
|
||||
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
|
||||
)
|
||||
assert 'Test dummy' in response.content.decode()
|
||||
assert '+ €100.00' in response.content.decode()
|
||||
response = self.client.post(
|
||||
'/%s/%s/order/%s/%s/pay/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret),
|
||||
{
|
||||
'payment': 'testdummy'
|
||||
}, follow=True
|
||||
)
|
||||
assert 'Total: €199.99' in response.content.decode()
|
||||
self.order.refresh_from_db()
|
||||
with scopes_disabled():
|
||||
assert self.order.payments.last().provider == 'testdummy'
|
||||
fee = self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).last()
|
||||
assert fee.value == Decimal('100.00')
|
||||
assert fee.tax_value == Decimal('15.97')
|
||||
self.ticket_pos.refresh_from_db()
|
||||
assert self.ticket_pos.price == Decimal("99.99")
|
||||
assert self.ticket_pos.price_includes_rounding_correction == Decimal("-0.01")
|
||||
self.order.refresh_from_db()
|
||||
assert self.order.total == Decimal('199.99')
|
||||
p = self.order.payments.last()
|
||||
assert p.provider == 'testdummy'
|
||||
assert p.state == OrderPayment.PAYMENT_STATE_CREATED
|
||||
assert p.amount == Decimal('199.99')
|
||||
|
||||
def test_change_paymentmethod_to_same(self):
|
||||
with scopes_disabled():
|
||||
p_old = self.order.payments.create(
|
||||
|
||||
@@ -35,7 +35,7 @@ class DummyPaymentProvider(BasePaymentProvider):
|
||||
abort_pending_allowed = False
|
||||
|
||||
def payment_is_valid_session(self, request: HttpRequest) -> bool:
|
||||
pass
|
||||
return True
|
||||
|
||||
def checkout_confirm_render(self, request) -> str:
|
||||
pass
|
||||
|
||||
Reference in New Issue
Block a user