From 464bec8b4e333538213909343644909705356ce5 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 2 Sep 2026 12:07:05 +0200 Subject: [PATCH] Payment step: Allow to postpone payment choice on some sales channels --- src/pretix/base/models/orders.py | 47 ++---------- src/pretix/base/services/orders.py | 21 ++++- src/pretix/base/services/payment.py | 76 +++++++++++++++++++ src/pretix/base/settings.py | 13 ++++ src/pretix/control/forms/event.py | 9 ++- .../pretixcontrol/event/payment.html | 1 + src/pretix/presale/checkoutflow.py | 27 +++++++ .../pretixpresale/event/checkout_payment.html | 26 +++++++ 8 files changed, 176 insertions(+), 44 deletions(-) create mode 100644 src/pretix/base/services/payment.py diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 096c2ece60..15dba58a98 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -626,47 +626,14 @@ class Order(LockModel, LoggedModel): self.save(update_fields=['last_modified']) def set_expires(self, now_dt=None, subevents=None): - now_dt = now_dt or now() - tz = ZoneInfo(self.event.settings.timezone) + from pretix.base.services.payment import compute_payment_deadline - sales_channel_suffix = "_" + self.sales_channel.identifier.replace(".", "_") - if not (mode := self.event.settings.get(f'payment_term_mode{sales_channel_suffix}')): - mode = self.event.settings.get('payment_term_mode') - sales_channel_suffix = "" - - if mode == 'days': - exp_by_date = now_dt.astimezone(tz) + timedelta(days=self.event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int)) - exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) - if self.event.settings.get('payment_term_weekdays'): - if exp_by_date.weekday() == 5: - exp_by_date += timedelta(days=2) - elif exp_by_date.weekday() == 6: - exp_by_date += timedelta(days=1) - elif mode == 'minutes': - exp_by_date = now_dt.astimezone(tz) + timedelta(minutes=self.event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int)) - else: - raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode)) - - self.expires = exp_by_date - - term_last = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) - if term_last: - if self.event.has_subevents and subevents: - terms = [ - term_last.datetime(se).date() - for se in subevents - ] - if not terms: - return - term_last = min(terms) - else: - term_last = term_last.datetime(self.event).date() - term_last = make_aware(datetime.combine( - term_last, - time(hour=23, minute=59, second=59) - ), tz) - if term_last < self.expires: - self.expires = term_last + self.expires = compute_payment_deadline( + event=self.event, + sales_channel=self.sales_channel, + now_dt=now_dt, + subevents=subevents, + ) @cached_property def tax_total(self): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e333eb6606..890fa06409 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -961,7 +961,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress, - meta_info: dict, event: Event, require_approval=False): + meta_info: dict, event: Event, sales_channel: SalesChannel, require_approval=False): fees = [] # Pre-rounding, pre-fee total is used for fee calculation total = sum([c.gross_price_before_rounding for c in positions]) @@ -1021,7 +1021,14 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li payments_assigned += to_pay p['payment_amount'] = to_pay - if total != payments_assigned and not require_approval: + allow_postponed_payment = ( + require_approval or + ( + sales_channel.identifier in event.settings.payment_choice_postpone_allowed_channels and not payment_requests + ) + ) + + if total != payments_assigned and not allow_postponed_payment: raise OrderError(_("The selected payment methods do not cover the total balance.")) return fees @@ -1043,7 +1050,15 @@ def _create_order(event: Event, *, email: str, positions: List[CartPosition], no # Final calculation of fees, also performs final rounding try: - fees = _apply_rounding_and_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) + fees = _apply_rounding_and_fees( + positions, + payment_requests, + address, + meta_info, + event, + sales_channel=sales_channel, + require_approval=require_approval + ) except TaxRule.SaleNotAllowed: raise OrderError(error_messages['country_blocked']) diff --git a/src/pretix/base/services/payment.py b/src/pretix/base/services/payment.py new file mode 100644 index 0000000000..866fc2c50c --- /dev/null +++ b/src/pretix/base/services/payment.py @@ -0,0 +1,76 @@ +# +# 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 . +# +# 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 +# . +# +from datetime import datetime, time, timedelta +from zoneinfo import ZoneInfo + +from django.utils.timezone import make_aware, now + +from pretix.base.models import Event, SalesChannel +from pretix.base.reldate import RelativeDateWrapper + + +def compute_payment_deadline(event: Event, sales_channel: SalesChannel, now_dt=None, subevents=None) -> datetime: + now_dt = now_dt or now() + tz = ZoneInfo(event.settings.timezone) + + sales_channel_suffix = "_" + sales_channel.identifier.replace(".", "_") + if not (mode := event.settings.get(f'payment_term_mode{sales_channel_suffix}')): + mode = event.settings.get('payment_term_mode') + sales_channel_suffix = "" + + if mode == 'days': + exp_by_date = now_dt.astimezone(tz) + timedelta( + days=event.settings.get(f'payment_term_days{sales_channel_suffix}', as_type=int)) + exp_by_date = exp_by_date.astimezone(tz).replace(hour=23, minute=59, second=59, microsecond=0) + if event.settings.get('payment_term_weekdays'): + if exp_by_date.weekday() == 5: + exp_by_date += timedelta(days=2) + elif exp_by_date.weekday() == 6: + exp_by_date += timedelta(days=1) + elif mode == 'minutes': + exp_by_date = now_dt.astimezone(tz) + timedelta( + minutes=event.settings.get(f'payment_term_minutes{sales_channel_suffix}', as_type=int)) + else: + raise ValueError("'payment_term_mode' has an invalid value '{}'.".format(mode)) + + expires = exp_by_date + + term_last = event.settings.get('payment_term_last', as_type=RelativeDateWrapper) + if term_last: + if event.has_subevents and subevents: + terms = [ + term_last.datetime(se).date() + for se in subevents + ] + if not terms: + return expires + term_last = min(terms) + else: + term_last = term_last.datetime(event).date() + term_last = make_aware(datetime.combine( + term_last, + time(hour=23, minute=59, second=59) + ), tz) + if term_last < expires: + return term_last + + return expires diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index f2229a8212..2fbc04e805 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -1159,6 +1159,19 @@ DEFAULTS = { "configured above."), ) }, + 'payment_choice_postpone_allowed_channels': { + 'default': [], + 'type': list, + 'form_class': forms.MultipleChoiceField, + 'form_kwargs': dict( + label=_('Allow postponed payment choice for sales channels'), + help_text=_("If postponed payment is allowed on a sales channel, customers can complete their order without " + "selecting a payment method. This is useful whenever orders are not created by the same " + "person who is making the payment."), + widget=forms.CheckboxSelectMultiple, + choices=[], + ) + }, 'presale_start_show_date': { 'default': 'True', 'type': bool, diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index f524ea3181..068d306d42 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -855,14 +855,21 @@ class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm): 'payment_term_accept_late', 'payment_pending_hidden', 'payment_explanation', + 'payment_choice_postpone_allowed_channels', 'tax_rule_payment', ] def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + channels = list(self.obj.organizer.sales_channels.all()) + self.fields['payment_choice_postpone_allowed_channels'].choices = [ + (c.identifier, c.label) for c in channels + if c.type_instance.payment_restrictions_supported + ] + self.term_channel_fields = {} - for c in self.obj.organizer.sales_channels.all(): + for c in channels: if c.type_instance.payment_restrictions_supported and c.identifier != "web": # At the moment, it seems sufficient to allow this for the same channel types as other payment settings # We can always introduce more flags later if needed diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index 55a53f35f8..e28bf2e7f8 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -109,6 +109,7 @@ {% bootstrap_form_errors form layout="control" %} {% bootstrap_field form.tax_rule_payment layout="control" %} {% bootstrap_field form.payment_explanation layout="control" %} + {% bootstrap_field form.payment_choice_postpone_allowed_channels layout="control" %} {% if "event.settings.payment:write" in request.eventpermset %} diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index a4981316cb..007d43d2bc 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -35,6 +35,7 @@ import copy import inspect import uuid from collections import defaultdict +from datetime import time from decimal import Decimal from django import forms @@ -52,6 +53,7 @@ from django.shortcuts import redirect from django.utils import translation from django.utils.functional import cached_property from django.utils.html import conditional_escape +from django.utils.timezone import now from django.utils.translation import ( get_language, gettext_lazy as _, pgettext_lazy, ) @@ -71,6 +73,7 @@ from pretix.base.services.cart import ( from pretix.base.services.cross_selling import CrossSellingService from pretix.base.services.memberships import validate_memberships_in_order from pretix.base.services.orders import perform_order +from pretix.base.services.payment import compute_payment_deadline from pretix.base.services.pricing import get_price from pretix.base.services.tasks import EventTask from pretix.base.settings import PERSON_NAME_SCHEMES @@ -1344,6 +1347,11 @@ class PaymentStep(CartMixin, TemplateFlowStep): self.request = request self.request.pci_dss_payment_page = True + if "postpone" in request.POST and self._allow_postpone: + self.cart_session['payments_postpone'] = True + self.cart_session['payments'] = [] + return redirect_to_url(self.get_next_url(request)) + if "remove_payment" in request.POST: self._remove_payment(request.POST["remove_payment"]) return redirect_to_url(self.get_step_url(request)) @@ -1440,12 +1448,31 @@ class PaymentStep(CartMixin, TemplateFlowStep): ctx['selected'] = self.single_use_payment['provider'] else: ctx['selected'] = '' + + ctx['allow_postpone'] = self._allow_postpone + if self._allow_postpone: + now_dt = now() + ctx['payment_deadline'] = compute_payment_deadline( + event=self.request.event, + sales_channel=self.request.sales_channel, + subevents={p.subevent for p in ctx['cart']['raw']}, + now_dt=now_dt, + ) + if ctx['payment_deadline'].time() != time(hour=23, minute=59, second=59): + ctx['payment_deadline_minutes'] = int((ctx['payment_deadline'] - now_dt).total_seconds() // 60) return ctx + @cached_property + def _allow_postpone(self): + return self.request.sales_channel.identifier in self.request.event.settings.payment_choice_postpone_allowed_channels + def _is_allowed(self, prov, request): return prov.is_allowed(request, total=self._total_order_value) def is_completed(self, request, warn=False): + if self.cart_session.get('payments_postpone') and self._allow_postpone: + return True + if not self.cart_session.get('payments'): if warn: messages.error(request, _('Please select a payment method to proceed.')) diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index 224d2d2f28..e8ed7620f1 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -128,6 +128,32 @@ {% endif %} {% endif %} + {% if allow_postpone %} +
+
+
+ {% trans "Not sure yet? You can complete your order first and then select a payment method later." %} +
+ + {% if payment_deadline_minutes %} + {% blocktrans trimmed with minutes=payment_deadline_minutes %} + Your payment needs to be completed within {{ minutes }} minutes. + {% endblocktrans %} + {% else %} + {% blocktrans trimmed with deadline=payment_deadline|date:"SHORT_DATE_FORMAT" %} + Your payment needs to be completed by {{ deadline }}. + {% endblocktrans %} + {% endif %} + +
+
+ +
+
+
+ {% endif %}