From cb2815a7f00c4a3f2fe4151faca1fa94cfdcf5e9 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 13 Aug 2025 13:08:07 +0200 Subject: [PATCH] Add tests --- src/pretix/base/services/orders.py | 67 +++---- src/pretix/presale/views/order.py | 21 +- src/tests/base/test_orders.py | 73 +++++++ src/tests/base/test_pricing_rounding.py | 52 +++-- src/tests/presale/test_checkout.py | 250 +++++++++++++++++++++++- src/tests/presale/test_orders.py | 50 +++++ src/tests/testdummy/payment.py | 2 +- 7 files changed, 449 insertions(+), 66 deletions(-) diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 953ff6b67b..601e4154b6 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1619,7 +1619,7 @@ class OrderChangeManager: self.split_order = None self.reissue_invoice = reissue_invoice self._committed = False - self._totaldiff = 0 + self._totaldiff_guesstimate = 0 self._quotadiff = Counter() self._seatdiff = Counter() self._operations = [] @@ -1736,7 +1736,7 @@ class OrderChangeManager: if position.issued_gift_cards.exists(): raise OrderError(self.error_messages['gift_card_change']) - self._totaldiff += price.gross - position.price + self._totaldiff_guesstimate += price.gross - position.price if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00') or position.price != Decimal('0.00'): self._invoice_dirty = True @@ -1781,29 +1781,29 @@ class OrderChangeManager: else: new_tax = tax_rule.tax(pos.price, base_price_is='gross', currency=self.event.currency, override_tax_rate=new_rate, override_tax_code=new_code) - self._totaldiff += new_tax.gross - pos.price + self._totaldiff_guesstimate += new_tax.gross - pos.price self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price)) self._invoice_dirty = True def cancel_fee(self, fee: OrderFee): - self._totaldiff -= fee.value + self._totaldiff_guesstimate -= fee.value self._operations.append(self.CancelFeeOperation(fee, -fee.value)) self._invoice_dirty = True def add_fee(self, fee: OrderFee): - self._totaldiff += fee.value + self._totaldiff_guesstimate += fee.value self._invoice_dirty = True self._operations.append(self.AddFeeOperation(fee, fee.value)) def change_fee(self, fee: OrderFee, value: Decimal): value = (fee.tax_rule or TaxRule.zero()).tax(value, base_price_is='gross', invoice_address=self._invoice_address, force_fixed_gross_price=True) - self._totaldiff += value.gross - fee.value + self._totaldiff_guesstimate += value.gross - fee.value self._invoice_dirty = True self._operations.append(self.FeeValueOperation(fee, value, value.gross - fee.value)) def cancel(self, position: OrderPosition): - self._totaldiff -= position.price + self._totaldiff_guesstimate -= position.price self._quotadiff.subtract(position.quotas) self._operations.append(self.CancelOperation(position, -position.price)) if position.seat: @@ -1869,7 +1869,7 @@ class OrderChangeManager: if self.order.event.settings.invoice_include_free or price.gross != Decimal('0.00'): self._invoice_dirty = True - self._totaldiff += price.gross + self._totaldiff_guesstimate += price.gross self._quotadiff.update(new_quotas) if seat: self._seatdiff.update([seat]) @@ -2165,8 +2165,8 @@ class OrderChangeManager: if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff): raise OrderError(self.error_messages['quota'].format(name=quota.name)) - def _check_paid_price_change(self): - if self.order.status == Order.STATUS_PAID and self._totaldiff > 0: + def _check_paid_price_change(self, totaldiff): + if self.order.status == Order.STATUS_PAID and totaldiff > 0: if self.order.pending_sum > Decimal('0.00'): self.order.status = Order.STATUS_PENDING self.order.set_expires( @@ -2174,7 +2174,7 @@ class OrderChangeManager: self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) ) self.order.save() - elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0: + elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff < 0: if self.order.pending_sum <= Decimal('0.00') and not self.order.require_approval: self.order.status = Order.STATUS_PAID self.order.save() @@ -2201,7 +2201,7 @@ class OrderChangeManager: user=self.user, auth=self.auth ) - elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff > 0: + elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and totaldiff > 0: if self.open_payment: try: self.open_payment.payment_provider.cancel_payment(self.open_payment) @@ -2221,11 +2221,11 @@ class OrderChangeManager: auth=self.auth, ) - def _check_paid_to_free(self): - if self.event.currency == 'XXX' and self.order.total + self._totaldiff > Decimal("0.00"): + def _check_paid_to_free(self, totaldiff): + if self.event.currency == 'XXX' and self.order.total + totaldiff > Decimal("0.00"): raise OrderError(error_messages['currency_XXX']) - if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval: + if self.order.total == 0 and (totaldiff < 0 or (self.split_order and self.split_order.total > 0)) and not self.order.require_approval: if not self.order.fees.exists() and not self.order.positions.exists(): # The order is completely empty now, so we cancel it. self.order.status = Order.STATUS_CANCELED @@ -2233,7 +2233,7 @@ class OrderChangeManager: order_canceled.send(self.order.event, order=self.order) elif self.order.status != Order.STATUS_CANCELED: # if the order becomes free, mark it paid using the 'free' provider - # this could happen if positions have been made cheaper or removed (_totaldiff < 0) + # this could happen if positions have been made cheaper or removed (totaldiff < 0) # or positions got split off to a new order (split_order with positive total) p = self.order.payments.create( state=OrderPayment.PAYMENT_STATE_CREATED, @@ -2788,6 +2788,7 @@ class OrderChangeManager: self.order.total = total self.order.save() + return total def _check_order_size(self): if (len(self.order.positions.all()) + len([op for op in self._operations if isinstance(op, self.AddOperation)])) > settings.PRETIX_MAX_ORDER_SIZE: @@ -2797,23 +2798,6 @@ class OrderChangeManager: } ) - def _payment_fee_diff(self): - total = self.order.total + self._totaldiff - if self.open_payment: - current_fee = Decimal('0.00') - if self.open_payment and self.open_payment.fee: - current_fee = self.open_payment.fee.value - total -= current_fee - - # Do not change payment fees of paid orders - payment_fee = Decimal('0.00') - if self.order.pending_sum - current_fee != 0: - prov = self.open_payment.payment_provider - if prov: - payment_fee = prov.calculate_fee(total - self.completed_payment_sum) - - self._totaldiff += payment_fee - current_fee - def _reissue_invoice(self): i = self.order.invoices.filter(is_cancellation=False).last() if self.reissue_invoice and self._invoice_dirty: @@ -2932,6 +2916,13 @@ class OrderChangeManager: shared_lock_objects=[self.event] ) + def guess_totaldiff(self): + """ + Return the estimated difference of ``order.total`` based on the currently queued operations. This is only + a guess since it does not account for (a) tax rounding or (b) payment fee changes. + """ + return self._totaldiff_guesstimate + def commit(self, check_quotas=True): if self._committed: # an order change can only be committed once @@ -2947,8 +2938,6 @@ class OrderChangeManager: # so it's dangerous to keep the cache around. self.order._prefetched_objects_cache = {} - # finally, incorporate difference in payment fees - self._payment_fee_diff() self._check_order_size() with transaction.atomic(): @@ -2956,6 +2945,7 @@ class OrderChangeManager: if locked_instance.last_modified != self.order.last_modified: raise OrderError(error_messages['race_condition']) + original_total = self.order.total if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): if check_quotas: self._check_quotas() @@ -2967,9 +2957,10 @@ class OrderChangeManager: self._perform_operations() except TaxRule.SaleNotAllowed: raise OrderError(self.error_messages['tax_rule_country_blocked']) - self._recalculate_rounding_total_and_payment_fee() - self._check_paid_price_change() - self._check_paid_to_free() + new_total = self._recalculate_rounding_total_and_payment_fee() + totaldiff = new_total - original_total + self._check_paid_price_change(totaldiff) + self._check_paid_to_free(totaldiff) if self.order.status in (Order.STATUS_PENDING, Order.STATUS_PAID): self._reissue_invoice() self._clear_tickets_cache() diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index 3d0f441f6f..f00d93401a 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1525,6 +1525,7 @@ class OrderChangeMixin: def post(self, request, *args, **kwargs): was_paid = self.order.status == Order.STATUS_PAID + original_total = self.order.total ocm = OrderChangeManager( self.order, notify=True, @@ -1576,7 +1577,8 @@ class OrderChangeMixin: except OrderError as e: messages.error(self.request, str(e)) else: - if self.order.pending_sum < Decimal('0.00') and ocm._totaldiff < Decimal('0.00'): + totaldiff = self.order.total - original_total + if self.order.pending_sum < Decimal('0.00') and totaldiff < Decimal('0.00'): auto_refund = ( not self.request.event.settings.cancel_allow_user_paid_require_approval and self.request.event.settings.cancel_allow_user_paid_refund_as_giftcard != "manually" @@ -1604,7 +1606,7 @@ class OrderChangeMixin: messages.info(self.request, _('You did not make any changes.')) return redirect(self.get_self_url()) else: - new_pending_sum = self.order.pending_sum + ocm._totaldiff + new_pending_sum = self.order.pending_sum + ocm.guess_totaldiff() can_auto_refund = False if new_pending_sum < Decimal('0.00'): proposals = self.order.propose_auto_refunds(Decimal('-1.00') * new_pending_sum) @@ -1612,7 +1614,7 @@ class OrderChangeMixin: return render(request, self.confirm_template_name, { 'operations': ocm._operations, - 'totaldiff': ocm._totaldiff, + 'totaldiff': ocm.guess_totaldiff(), 'order': self.order, 'payment_refund_sum': self.order.payment_refund_sum, 'new_pending_sum': new_pending_sum, @@ -1624,16 +1626,17 @@ class OrderChangeMixin: def _validate_total_diff(self, ocm): pr = self.get_price_requirement() - if ocm._totaldiff < Decimal('0.00') and pr == 'gte': + totaldiff = ocm.guess_totaldiff() + if totaldiff < Decimal('0.00') and pr == 'gte': raise OrderError(_('You may not change your order in a way that reduces the total price.')) - if ocm._totaldiff <= Decimal('0.00') and pr == 'gt': + if totaldiff <= Decimal('0.00') and pr == 'gt': raise OrderError(_('You may only change your order in a way that increases the total price.')) - if ocm._totaldiff != Decimal('0.00') and pr == 'eq': + if totaldiff != Decimal('0.00') and pr == 'eq': raise OrderError(_('You may not change your order in a way that changes the total price.')) - if ocm._totaldiff < Decimal('0.00') and self.order.total + ocm._totaldiff < self.order.payment_refund_sum and pr == 'gte_paid': + if totaldiff < Decimal('0.00') and self.order.total + totaldiff < self.order.payment_refund_sum and pr == 'gte_paid': raise OrderError(_('You may not change your order in a way that would require a refund.')) - if ocm._totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PAID: + if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PAID: self.order.set_expires( now(), self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True)) @@ -1642,7 +1645,7 @@ class OrderChangeMixin: raise OrderError(_('You may not change your order in a way that increases the total price since ' 'payments are no longer being accepted for this event.')) - if ocm._totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING: + if totaldiff > Decimal('0.00') and self.order.status == Order.STATUS_PENDING: for p in self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_PENDING): if not p.payment_provider.abort_pending_allowed: raise OrderError(_('You may not change your order in a way that requires additional payment while ' diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index b2de86fca7..ec69a92cec 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -2156,6 +2156,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) @@ -2563,6 +2581,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 diff --git a/src/tests/base/test_pricing_rounding.py b/src/tests/base/test_pricing_rounding.py index d0c9ec62cb..40902ff5cf 100644 --- a/src/tests/base/test_pricing_rounding.py +++ b/src/tests/base/test_pricing_rounding.py @@ -23,7 +23,7 @@ from decimal import Decimal import pytest -from pretix.base.models import OrderPosition +from pretix.base.models import InvoiceAddress, OrderPosition, TaxRule from pretix.base.services.pricing import apply_rounding @@ -41,26 +41,26 @@ def sample_lines(): def _validate_sample_lines(sample_lines, rounding_mode): apply_rounding(rounding_mode, "EUR", sample_lines) if rounding_mode == "line": - for l in sample_lines: - assert l.price == Decimal("100.00") - assert l.tax_value == Decimal("15.97") - assert l.tax_rate == Decimal("19.00") - assert sum(l.price for l in sample_lines) == Decimal("500.00") - assert sum(l.tax_value for l in sample_lines) == Decimal("79.85") + 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 l in sample_lines: + for line in sample_lines: # gross price may vary - assert l.price - l.tax_value == Decimal("84.03") - assert l.tax_rate == Decimal("19.00") - assert sum(l.price for l in sample_lines) == Decimal("499.98") - assert sum(l.tax_value for l in sample_lines) == Decimal("79.83") + 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") elif rounding_mode == "sum_by_gross": - for l in sample_lines: - assert l.price == Decimal("100.00") + for line in sample_lines: + assert line.price == Decimal("100.00") # net price may vary - assert l.tax_rate == Decimal("19.00") - assert sum(l.price for l in sample_lines) == Decimal("500.00") - assert sum(l.tax_value for l in sample_lines) == Decimal("79.83") + 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") @pytest.mark.django_db @@ -98,7 +98,9 @@ def test_revert_net_rounding_to_single_line(sample_lines): ) 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") @@ -114,5 +116,21 @@ def test_revert_gross_rounding_to_single_line(sample_lines): ) apply_rounding("sum_by_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 +def test_rounding_of_impossible_price(sample_lines): + 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]) + 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") diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 978eac9c4d..522ee2254a 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -71,7 +71,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', @@ -483,6 +483,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'}, @@ -2091,6 +2093,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_gross(self): + self.event.settings.tax_rounding = "sum_by_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() diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index 5af1203ff9..e2009681c9 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -1280,6 +1280,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( diff --git a/src/tests/testdummy/payment.py b/src/tests/testdummy/payment.py index c8b4f2345e..cc47dff77e 100644 --- a/src/tests/testdummy/payment.py +++ b/src/tests/testdummy/payment.py @@ -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