From 7f6d645a8b1cec266a0e81885734d76f2c3da59d Mon Sep 17 00:00:00 2001 From: Lukas Bockstaller Date: Mon, 7 Sep 2026 11:56:53 +0200 Subject: [PATCH] handle payment provider (de-)selection and partial payments --- src/pretix/base/services/cart.py | 1 + src/pretix/presale/checkoutflow.py | 8 ++- .../pretixpresale/event/checkout_payment.html | 7 ++- src/tests/presale/test_checkout.py | 58 +++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 03e5ad36f2..9e1c2e45fc 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1605,6 +1605,7 @@ def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None, 'max_value': str(max_value) if max_value is not None else None, 'info_data': info_data or {}, }) + cart_session['payments_postpone'] = False def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 007d43d2bc..3d8df897d9 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1440,10 +1440,12 @@ class PaymentStep(CartMixin, TemplateFlowStep): ctx['providers'] = self.provider_forms ctx['show_fees'] = any(p['fee'] for p in self.provider_forms) - if len(self.provider_forms) == 1: - ctx['selected'] = self.provider_forms[0]['provider'].identifier - elif 'payment' in self.request.POST: + if 'payment' in self.request.POST: ctx['selected'] = self.request.POST['payment'] + elif self.cart_session.get('payments_postpone') and self._allow_postpone: + ctx['selected'] = '' + elif len(self.provider_forms) == 1: + ctx['selected'] = self.provider_forms[0]['provider'].identifier elif self.single_use_payment: ctx['selected'] = self.single_use_payment['provider'] else: diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index e8ed7620f1..fa75e636e4 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -135,7 +135,9 @@ {% trans "Not sure yet? You can complete your order first and then select a payment method later." %}
- {% if payment_deadline_minutes %} + {% if current_payments %} + {% trans "To do so, please first remove the payment methods you already selected above." %} + {% elif payment_deadline_minutes %} {% blocktrans trimmed with minutes=payment_deadline_minutes %} Your payment needs to be completed within {{ minutes }} minutes. {% endblocktrans %} @@ -147,7 +149,8 @@
-
diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 9411a4ad8c..3298bdc67c 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -2405,6 +2405,64 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase): o = Order.objects.last() assert not o.payments.exists() + def test_payment_postpone_cleared_on_selection(self): + self.event.settings.set('payment_banktransfer__enabled', True) + self.event.settings.payment_choice_postpone_allowed_channels = ['web'] + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'postpone': 'on', + }, follow=False) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert self.client.session['carts'][self.session_key].get('payments_postpone') + + # The only available provider must not be preselected while the choice is postponed + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select('input[name="payment"]')), 1) + self.assertEqual(len(doc.select('input[name="payment"][checked]')), 0) + + # Selecting a payment method takes the order out of the postponed state again + response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), { + 'payment': 'banktransfer', + }, follow=False) + self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert not self.client.session['carts'][self.session_key].get('payments_postpone') + + def test_payment_postpone_disabled_with_partial_payment(self): + self.event.settings.set('payment_banktransfer__enabled', True) + self.event.settings.payment_choice_postpone_allowed_channels = ['web'] + gc = self.orga.issued_gift_cards.create(currency="EUR") + gc.transactions.create(value=20, acceptor=self.orga) + with scopes_disabled(): + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10) + ) + + response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select('button[name="postpone"]')), 1) + self.assertEqual(len(doc.select('button[name="postpone"][disabled]')), 0) + + # Apply a gift card that only covers part of the total + 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) + + # Postponing would silently drop the gift card, so it is no longer offered + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select('button[name="postpone"][disabled]')), 1) + def test_premature_confirm(self): response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) self.assertRedirects(response, '/%s/%s/?require_cookie=true' % (self.orga.slug, self.event.slug),