diff --git a/doc/development/api/payment.rst b/doc/development/api/payment.rst index 60907b6e6..a29b0ce6b 100644 --- a/doc/development/api/payment.rst +++ b/doc/development/api/payment.rst @@ -138,6 +138,10 @@ The provider class .. autoattribute:: is_meta + .. autoattribute:: execute_payment_needs_user + + .. autoattribute:: multi_use_supported + .. autoattribute:: test_mode_message .. autoattribute:: requires_invoice_immediately diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 155fbab2a..a87a2c463 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -699,8 +699,8 @@ class OrderViewSet(viewsets.ModelViewSet): subject_attendees_template = request.event.settings.mail_subject_order_placed_attendee _order_placed_email( - request.event, order, payment.payment_provider if payment else None, email_template, subject_template, - log_entry, invoice, payment, is_free=free_flow + request.event, order, email_template, subject_template, + log_entry, invoice, [payment] if payment else [], is_free=free_flow ) if email_attendees: for p in order.positions.all(): diff --git a/src/pretix/base/email.py b/src/pretix/base/email.py index 7da193470..3dc38c0d9 100644 --- a/src/pretix/base/email.py +++ b/src/pretix/base/email.py @@ -320,13 +320,16 @@ def get_email_context(**kwargs): return ctx -def _placeholder_payment(order, payment): - if not payment: - return None - if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters: - return str(payment.payment_provider.order_pending_mail_render(order, payment)) - else: - return str(payment.payment_provider.order_pending_mail_render(order)) +def _placeholder_payments(order, payments): + d = [] + for payment in payments: + if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters: + d.append(str(payment.payment_provider.order_pending_mail_render(order, payment))) + else: + d.append(str(payment.payment_provider.order_pending_mail_render(order))) + d = [line for line in d if line.strip()] + if d: + return '\n\n'.join(d) def get_best_name(position_or_address, parts=False): @@ -617,7 +620,7 @@ def base_placeholders(sender, **kwargs): _('An individual text with a reason can be inserted here.'), ), SimpleFunctionalMailTextPlaceholder( - 'payment_info', ['order', 'payment'], _placeholder_payment, + 'payment_info', ['order', 'payments'], _placeholder_payments, _('The amount has been charged to your card.'), ), SimpleFunctionalMailTextPlaceholder( diff --git a/src/pretix/base/migrations/0225_orderpayment_process_initiated.py b/src/pretix/base/migrations/0225_orderpayment_process_initiated.py new file mode 100644 index 000000000..f922b6c14 --- /dev/null +++ b/src/pretix/base/migrations/0225_orderpayment_process_initiated.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.16 on 2022-11-17 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0224_eventmetaproperty_filter_allowed'), + ] + + operations = [ + migrations.AddField( + model_name='orderpayment', + name='process_initiated', + field=models.BooleanField(null=True), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 12b2b43c8..f00afe2f2 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1509,6 +1509,9 @@ class OrderPayment(models.Model): :type info: str :param fee: The ``OrderFee`` object used to track the fee for this order. :type fee: pretix.base.models.OrderFee + :param process_initiated: Only for internal use inside pretix.presale to check which payments have started + the execution process. + :type process_initiated: bool """ PAYMENT_STATE_CREATED = 'created' PAYMENT_STATE_PENDING = 'pending' @@ -1559,6 +1562,9 @@ class OrderPayment(models.Model): null=True, blank=True, related_name='payments', on_delete=models.SET_NULL ) migrated = models.BooleanField(default=False) + process_initiated = models.BooleanField( + null=True # null = created before this field was introduced + ) objects = ScopedManager(organizer='order__event__organizer') diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 5cd20b822..cd4af173f 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -63,14 +63,13 @@ from pretix.base.models import ( OrderRefund, Quota, ) from pretix.base.reldate import RelativeDateField, RelativeDateWrapper -from pretix.base.services.cart import get_fees from pretix.base.settings import SettingsSandbox from pretix.base.signals import register_payment_providers from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.rich_text import rich_text from pretix.helpers.countries import CachedCountries from pretix.helpers.money import DecimalTextInput -from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse +from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.views import get_cart, get_cart_total from pretix.presale.views.cart import cart_session, get_or_create_cart_id @@ -138,6 +137,48 @@ class BasePaymentProvider: """ return self.settings.get('_enabled', as_type=bool) + @property + def multi_use_supported(self) -> bool: + """ + Returns whether or whether not this payment provider supports being used multiple times in the same + checkout, or in addition to a different payment provider. This is usually only useful for payment providers + that represent gift cards, i.e. payment methods with an upper limit per payment instrument that can usually + be combined with other instruments. + + If you set this property to ``True``, the behavior of how pretix interacts with your payment provider changes + and you will need to respect the following rules: + + - ``payment_form_render`` must not depend on session state, it must always allow a user to add a new payment. + Editing a payment is not possible, but pretix will give users an option to delete it. + + - Returning ``True`` from ``checkout_prepare`` is no longer enough. Instead, you must *also* call + ``pretix.base.services.cart.add_payment_to_cart(request, provider, min_value, max_value, info_data)`` + to add the payment to the session. You are still allowed to do a redirect from ``checkout_prepare`` and then + call this function upon return. + + - Unlike in the general case, when ``checkout_prepare`` is called, the ``cart['total']`` parameter will _not yet_ + include payment fees charged by your provider as we don't yet know the amount of the charge, so you need to + take care of that yourself when setting your maximum amount. + + - ``payment_is_valid_session`` will not be called during checkout, don't rely on it. If you called + ``add_payment_to_cart``, we'll trust the payment is okay and your next chance to change that will be + ``execute_payment``. + + The changed behavior currently only affects the behavior during initial checkout (i.e. ``checkout_prepare``), + for ``payment_prepare`` the regular behavior applies and you are expected to just modify the amount of the + ``OrderPayment`` object if you need to. + """ + return False + + @property + def execute_payment_needs_user(self) -> bool: + """ + Set this to ``True`` if your ``execute_payment`` function needs to be triggered by a user request, i.e. either + needs the ``request`` object or might require a browser redirect. If this is ``False``, you will not receive + a ``request`` and may not redirect since execute_payment might be called server-side. + """ + return True + @property def test_mode_message(self) -> str: """ @@ -574,7 +615,7 @@ class BasePaymentProvider: ctx = {'request': request, 'form': form} return template.render(ctx) - def checkout_confirm_render(self, request, order: Order=None) -> str: + def checkout_confirm_render(self, request, order: Order=None, info_data: dict=None) -> str: """ If the user has successfully filled in their payment data, they will be redirected to a confirmation page which lists all details of their order for a final review. @@ -584,7 +625,9 @@ class BasePaymentProvider: In most cases, this should include a short summary of the user's input and a short explanation on how the payment process will continue. + :param request: The current HTTP request. :param order: Only set when this is a change to a new payment method for an existing order. + :param info_data: The ``info_data`` dictionary you set during ``add_payment_to_cart`` (only filled if ``multi_use_supported`` is set) """ raise NotImplementedError() # NOQA @@ -618,6 +661,10 @@ class BasePaymentProvider: .. IMPORTANT:: If this is called, the user has not yet confirmed their order. You may NOT do anything which actually moves money. + Note: The behavior of this method changes significantly when you set + ``multi_use_supported``. Please refer to the ``multi_use_supported`` documentation + for more information. + :param cart: This dictionary contains at least the following keys: positions: @@ -657,9 +704,9 @@ class BasePaymentProvider: You will be passed an :py:class:`pretix.base.models.OrderPayment` object that contains the amount of money that should be paid. - If you need any special behavior, you can return a string - containing the URL the user will be redirected to. If you are done with your process - you should return the user to the order's detail page. + If you need any special behavior, you can return a string containing the URL the user will be redirected to. + If you are done with your process you should return the user to the order's detail page. Redirection is not + allowed if you set ``execute_payment_needs_user`` to ``True``. If the payment is completed, you should call ``payment.confirm()``. Please note that this might raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and @@ -671,7 +718,7 @@ class BasePaymentProvider: On errors, you should raise a ``PaymentException``. - :param order: The order object + :param request: A HTTP request, except if ``execute_payment_needs_user`` is ``False`` :param payment: An ``OrderPayment`` instance """ return None @@ -905,6 +952,7 @@ class FreeOrderProvider(BasePaymentProvider): is_implicit = True is_enabled = True identifier = "free" + execute_payment_needs_user = False def checkout_confirm_render(self, request: HttpRequest) -> str: return _("No payment is required as this order only includes products which are free of charge.") @@ -991,6 +1039,7 @@ class BoxOfficeProvider(BasePaymentProvider): class ManualPayment(BasePaymentProvider): identifier = 'manual' verbose_name = _('Manual payment') + execute_payment_needs_user = False @property def test_mode_message(self): @@ -1133,6 +1182,8 @@ class GiftCardPayment(BasePaymentProvider): identifier = "giftcard" verbose_name = _("Gift card") priority = 10 + multi_use_supported = True + execute_payment_needs_user = False @property def settings_form_fields(self): @@ -1158,8 +1209,10 @@ class GiftCardPayment(BasePaymentProvider): def payment_form_render(self, request: HttpRequest, total: Decimal) -> str: return get_template('pretixcontrol/giftcards/checkout.html').render({}) - def checkout_confirm_render(self, request) -> str: - return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({}) + def checkout_confirm_render(self, request, order=None, info_data=None) -> str: + return get_template('pretixcontrol/giftcards/checkout_confirm.html').render({ + 'info_data': info_data, + }) def refund_control_render(self, request, refund) -> str: from .models import GiftCard @@ -1213,6 +1266,8 @@ class GiftCardPayment(BasePaymentProvider): return True def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: + from pretix.base.services.cart import add_payment_to_cart + for p in get_cart(request): if p.item.issue_giftcard: messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) @@ -1221,7 +1276,7 @@ class GiftCardPayment(BasePaymentProvider): cs = cart_session(request) try: gc = self.event.organizer.accepted_gift_cards.get( - secret=request.POST.get("giftcard") + secret=request.POST.get("giftcard").strip() ) if gc.currency != self.event.currency: messages.error(request, _("This gift card does not support this currency.")) @@ -1238,34 +1293,22 @@ class GiftCardPayment(BasePaymentProvider): if gc.value <= Decimal("0.00"): messages.error(request, _("All credit on this gift card has been used.")) return - if 'gift_cards' not in cs: - cs['gift_cards'] = [] - elif gc.pk in cs['gift_cards']: - messages.error(request, _("This gift card is already used for your payment.")) - return - cs['gift_cards'] = cs['gift_cards'] + [gc.pk] - total = sum(p.total for p in cart['positions']) - # Recompute fees. Some plugins, e.g. pretix-servicefees, change their fee schedule if a gift card is - # applied. - fees = get_fees( - self.event, request, total, cart['invoice_address'], cs.get('payment'), - cart['raw'] + for p in cs.get('payments', []): + if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk: + messages.error(request, _("This gift card is already used for your payment.")) + return + + add_payment_to_cart( + request, + self, + max_value=gc.value, + info_data={ + 'gift_card': gc.pk, + 'gift_card_secret': gc.secret, + } ) - total += sum([f.value for f in fees]) - remainder = total - if remainder > Decimal('0.00'): - del cs['payment'] - messages.success(request, _("Your gift card has been applied, but {} still need to be paid. Please select a payment method.").format( - money_filter(remainder, self.event.currency) - )) - else: - messages.success(request, _("Your gift card has been applied.")) - - kwargs = {'step': 'payment'} - if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs: - kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace'] - return eventreverse(self.event, 'presale:event.checkout', kwargs=kwargs) + return True except GiftCard.DoesNotExist: if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " @@ -1283,7 +1326,7 @@ class GiftCardPayment(BasePaymentProvider): try: gc = self.event.organizer.accepted_gift_cards.get( - secret=request.POST.get("giftcard") + secret=request.POST.get("giftcard").strip() ) if gc.currency != self.event.currency: messages.error(request, _("This gift card does not support this currency.")) @@ -1302,6 +1345,7 @@ class GiftCardPayment(BasePaymentProvider): return payment.info_data = { 'gift_card': gc.pk, + 'gift_card_secret': gc.secret, 'retry': True } payment.amount = min(payment.amount, gc.value) @@ -1309,7 +1353,7 @@ class GiftCardPayment(BasePaymentProvider): return True except GiftCard.DoesNotExist: - if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): + if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard").strip()).exists(): messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " "the product selection.")) else: @@ -1318,36 +1362,45 @@ class GiftCardPayment(BasePaymentProvider): messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str: - # This method will only be called when retrying payments, e.g. after a payment_prepare call. It is not called - # during the order creation phase because this payment provider is a special case. - for p in payment.order.positions.all(): # noqa - just a safeguard + for p in payment.order.positions.all(): if p.item.issue_giftcard: raise PaymentException(_("You cannot pay with gift cards when buying a gift card.")) gcpk = payment.info_data.get('gift_card') - if not gcpk or not payment.info_data.get('retry'): + if not gcpk: raise PaymentException("Invalid state, should never occur.") - with transaction.atomic(): - gc = GiftCard.objects.select_for_update().get(pk=gcpk) - if gc.currency != self.event.currency: # noqa - just a safeguard - raise PaymentException(_("This gift card does not support this currency.")) - if not gc.accepted_by(self.event.organizer): # noqa - just a safeguard - raise PaymentException(_("This gift card is not accepted by this event organizer.")) - if payment.amount > gc.value: # noqa - just a safeguard - raise PaymentException(_("This gift card was used in the meantime. Please try again.")) - if gc.expires and gc.expires < now(): # noqa - just a safeguard - messages.error(request, _("This gift card is no longer valid.")) - return - trans = gc.transactions.create( - value=-1 * payment.amount, - order=payment.order, - payment=payment - ) - payment.info_data = { - 'gift_card': gc.pk, - 'transaction_id': trans.pk, - } - payment.confirm() + try: + with transaction.atomic(): + try: + gc = GiftCard.objects.select_for_update().get(pk=gcpk) + except GiftCard.DoesNotExist: + raise PaymentException(_("This gift card does not support this currency.")) + if gc.currency != self.event.currency: # noqa - just a safeguard + raise PaymentException(_("This gift card does not support this currency.")) + if not gc.accepted_by(self.event.organizer): + raise PaymentException(_("This gift card is not accepted by this event organizer.")) + if payment.amount > gc.value: + raise PaymentException(_("This gift card was used in the meantime. Please try again.")) + if gc.testmode and not payment.order.testmode: + raise PaymentException(_("This gift card can only be used in test mode.")) + if not gc.testmode and payment.order.testmode: + raise PaymentException(_("Only test gift cards can be used in test mode.")) + if gc.expires and gc.expires < now(): + raise PaymentException(_("This gift card is no longer valid.")) + + trans = gc.transactions.create( + value=-1 * payment.amount, + order=payment.order, + payment=payment + ) + payment.info_data = { + 'gift_card': gc.pk, + 'transaction_id': trans.pk, + } + payment.confirm() + except PaymentException as e: + payment.fail(info={'error': str(e)}) + raise e def payment_is_valid_session(self, request: HttpRequest) -> bool: return True diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 1bd1a2748..0d2ce77ac 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -31,7 +31,7 @@ # Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # License for the specific language governing permissions and limitations under the License. - +import uuid from collections import Counter, defaultdict, namedtuple from datetime import datetime, time, timedelta from decimal import Decimal @@ -1265,44 +1265,71 @@ class CartManager: raise CartError(err) -def get_fees(event, request, total, invoice_address, provider, positions): +def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): + """ + :param request: The current HTTP request context. + :param provider: The instance of your payment provider. + :param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited. + :param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged + to use for payment providers which charge a payment fee, as this can be very user-unfriendly if + users need a second payment method just for the payment fee of the first method. + :param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute. + :return: + """ from pretix.presale.views.cart import cart_session + cs = cart_session(request) + cs.setdefault('payments', []) + + cs['payments'].append({ + 'id': str(uuid.uuid4()), + 'provider': provider.identifier, + 'multi_use_supported': provider.multi_use_supported, + 'min_value': str(min_value) if min_value is not None else None, + 'max_value': str(max_value) if max_value is not None else None, + 'info_data': info_data or {}, + }) + + +def get_fees(event, request, total, invoice_address, payments, positions): + if payments and not isinstance(payments, list): + raise TypeError("payments must now be a list") + fees = [] for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address, - total=total, positions=positions): + total=total, positions=positions, payment_requests=payments): if resp: fees += resp total = total + sum(f.value for f in fees) - cs = cart_session(request) - if cs.get('gift_cards'): - gcs = cs['gift_cards'] - gc_qs = event.organizer.accepted_gift_cards.filter(pk__in=cs.get('gift_cards'), currency=event.currency) - for gc in gc_qs: - if gc.testmode != event.testmode: - gcs.remove(gc.pk) + if total != 0 and payments: + total_remaining = total + for p in payments: + # This algorithm of treating min/max values and fees needs to stay in sync between the following + # places in the code base: + # - pretix.base.services.cart.get_fees + # - pretix.base.services.orders._get_fees + # - pretix.presale.views.CartMixin.current_selected_payments + if p.get('min_value') and total_remaining < Decimal(p['min_value']): continue - fval = Decimal(gc.value) # TODO: don't require an extra query - fval = min(fval, total) - if fval > 0: - total -= fval - fees.append(OrderFee( - fee_type=OrderFee.FEE_TYPE_GIFTCARD, - internal_type='giftcard', - description=gc.secret, - value=-1 * fval, - tax_rate=Decimal('0.00'), - tax_value=Decimal('0.00'), - tax_rule=TaxRule.zero() - )) - cs['gift_cards'] = gcs - if provider and total != 0: - provider = event.get_payment_providers().get(provider) - if provider: - payment_fee = provider.calculate_fee(total) + to_pay = total_remaining + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) + + pprov = event.get_payment_providers(cached=True).get(p['provider']) + if not pprov: + continue + + payment_fee = pprov.calculate_fee(to_pay) + total_remaining += payment_fee + to_pay += payment_fee + + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) + + total_remaining -= to_pay if payment_fee: payment_fee_tax_rule = event.settings.tax_rate_default or TaxRule.zero() diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2fec21d91..cef0bfc47 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -74,7 +74,7 @@ from pretix.base.models.orders import ( ) from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule -from pretix.base.payment import BasePaymentProvider, PaymentException +from pretix.base.payment import PaymentException from pretix.base.reldate import RelativeDateWrapper from pretix.base.secrets import assign_ticket_secret from pretix.base.services import tickets @@ -793,68 +793,75 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio raise OrderError(err, errargs) -def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvider, address: InvoiceAddress, - meta_info: dict, event: Event, gift_cards: List[GiftCard]): +def _get_fees(positions: List[CartPosition], payment_requests: List[dict], address: InvoiceAddress, + meta_info: dict, event: Event, require_approval=False): fees = [] total = sum([c.price for c in positions]) - for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, + gift_cards = [] # for backwards compatibility + for p in payment_requests: + if p['provider'] == 'giftcard': + gift_cards.append(GiftCard.objects.get(pk=p['info_data']['gift_card'])) + + for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total, payment_requests=payment_requests, meta_info=meta_info, positions=positions, gift_cards=gift_cards): if resp: fees += resp total += sum(f.value for f in fees) - gift_card_values = {} - for gc in gift_cards: - fval = Decimal(gc.value) # TODO: don't require an extra query - fval = min(fval, total) - if fval > 0: - total -= fval - gift_card_values[gc] = fval + total_remaining = total + for p in payment_requests: + # This algorithm of treating min/max values and fees needs to stay in sync between the following + # places in the code base: + # - pretix.base.services.cart.get_fees + # - pretix.base.services.orders._get_fees + # - pretix.presale.views.CartMixin.current_selected_payments + if p.get('min_value') and total_remaining < Decimal(p['min_value']): + p['payment_amount'] = Decimal('0.00') + continue - if payment_provider: - payment_fee = payment_provider.calculate_fee(total) - else: - payment_fee = 0 - pf = None - if payment_fee: - pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, - internal_type=payment_provider.identifier) - fees.append(pf) + to_pay = total_remaining + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) - return fees, pf, gift_card_values + payment_fee = p['pprov'].calculate_fee(to_pay) + total_remaining += payment_fee + to_pay += payment_fee + + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) + + total_remaining -= to_pay + + p['payment_amount'] = to_pay + if payment_fee: + pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee, + internal_type=p['pprov'].identifier) + fees.append(pf) + p['fee'] = pf + + if total_remaining != Decimal('0.00') and not require_approval: + raise OrderError(_("The selected payment methods do not cover the total balance.")) + + return fees def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, - payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, - meta_info: dict=None, sales_channel: str='web', gift_cards: list=None, shown_total=None, + payment_requests: List[dict], locale: str=None, address: InvoiceAddress=None, + meta_info: dict=None, sales_channel: str='web', shown_total=None, customer=None): - p = None + payments = [] sales_channel = get_all_sales_channels()[sales_channel] with transaction.atomic(): - checked_gift_cards = [] - if gift_cards: - gc_qs = GiftCard.objects.select_for_update().filter(pk__in=gift_cards) - for gc in gc_qs: - if gc.currency != event.currency: - raise OrderError(_("This gift card does not support this currency.")) - if gc.testmode and not event.testmode: - raise OrderError(_("This gift card can only be used in test mode.")) - if not gc.testmode and event.testmode: - raise OrderError(_("Only test gift cards can be used in test mode.")) - if not gc.accepted_by(event.organizer): - raise OrderError(_("This gift card is not accepted by this event organizer.")) - checked_gift_cards.append(gc) - if checked_gift_cards and any(c.item.issue_giftcard for c in positions): - raise OrderError(_("You cannot pay with gift cards when buying a gift card.")) try: validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode) except ValidationError as e: raise OrderError(e.message) - fees, pf, gift_card_values = _get_fees(positions, payment_provider, address, meta_info, event, checked_gift_cards) + require_approval = any(p.requires_approval(invoice_address=address) for p in positions) + fees = _get_fees(positions, payment_requests, address, meta_info, event, require_approval=require_approval) total = pending_sum = sum([c.price for c in positions]) + sum([c.value for c in fees]) order = Order( @@ -867,7 +874,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d total=total, testmode=True if sales_channel.testmode_supported and event.testmode else False, meta_info=json.dumps(meta_info or {}), - require_approval=any(p.requires_approval(invoice_address=address) for p in positions), + require_approval=require_approval, sales_channel=sales_channel.identifier, customer=customer, ) @@ -891,28 +898,11 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d fee.tax_rule = None # TODO: deprecate fee.save() - for gc, val in gift_card_values.items(): - p = order.payments.create( - state=OrderPayment.PAYMENT_STATE_CONFIRMED, - provider='giftcard', - amount=val, - fee=pf - ) - trans = gc.transactions.create( - value=-1 * val, - order=order, - payment=p - ) - p.info_data = { - 'gift_card': gc.pk, - 'transaction_id': trans.pk, - } - p.save() - pending_sum -= val - # Safety check: Is the amount we're now going to charge the same amount the user has been shown when they # pressed "Confirm purchase"? If not, we should better warn the user and show the confirmation page again. - # The only *known* case where this happens is if a gift card is used in two concurrent sessions. + # We used to have a *known* case where this happened is if a gift card is used in two concurrent sessions, + # but this is now a payment error instead. So currently this code branch is usually only triggered by bugs + # in other places (e.g. tax calculation). if shown_total is not None: if Decimal(shown_total) != pending_sum: raise OrderError( @@ -921,13 +911,17 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d 'check the prices below and try again.') ) - if payment_provider and not order.require_approval: - p = order.payments.create( - state=OrderPayment.PAYMENT_STATE_CREATED, - provider=payment_provider.identifier, - amount=pending_sum, - fee=pf - ) + if payment_requests and not order.require_approval: + for p in payment_requests: + if not p.get('multi_use_supported') or p['payment_amount'] > Decimal('0.00'): + payments.append(order.payments.create( + state=OrderPayment.PAYMENT_STATE_CREATED, + provider=p['provider'], + amount=p['payment_amount'], + fee=p.get('fee'), + info=json.dumps(p['info_data']), + process_initiated=False, + )) orderpositions = OrderPosition.transform_cart_positions(positions, order) order.create_transactions(positions=orderpositions, fees=fees, is_new=True) @@ -939,12 +933,12 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d order.log_action('pretix.event.order.consent', data={'msg': msg}) order_placed.send(event, order=order) - return order, p + return order, payments -def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, subject_template, - log_entry: str, invoice, payment: OrderPayment, is_free=False): - email_context = get_email_context(event=event, order=order, payment=payment if pprov else None) +def _order_placed_email(event: Event, order: Order, email_template, subject_template, + log_entry: str, invoice, payments: List[OrderPayment], is_free=False): + email_context = get_email_context(event=event, order=order, payments=payments) try: order.send_mail( subject_template, email_template, email_context, @@ -979,15 +973,13 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi logger.exception('Order received email could not be sent to attendee') -def _perform_order(event: Event, payment_provider: str, position_ids: List[str], +def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str], email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web', - gift_cards: list=None, shown_total=None, customer=None): - if payment_provider: - pprov = event.get_payment_providers().get(payment_provider) - if not pprov: + shown_total=None, customer=None): + for p in payment_requests: + p['pprov'] = event.get_payment_providers(cached=True)[p['provider']] + if not p['pprov']: raise OrderError(error_messages['internal']) - else: - pprov = None if customer: customer = event.organizer.customers.get(pk=customer) @@ -1017,8 +1009,17 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], id__in=position_ids, event=event ) - validate_order.send(event, payment_provider=pprov, email=email, positions=positions, locale=locale, - invoice_address=addr, meta_info=meta_info, customer=customer) + validate_order.send( + event, + payment_provider=payment_requests[0]['provider'] if payment_requests else None, # only for backwards compatibility + payments=payment_requests, + email=email, + positions=positions, + locale=locale, + invoice_address=addr, + meta_info=meta_info, + customer=customer, + ) lockfn = NoLockManager locked = False @@ -1038,21 +1039,28 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], if len(position_ids) != len(positions): raise OrderError(error_messages['internal']) _check_positions(event, now_dt, positions, address=addr, sales_channel=sales_channel, customer=customer) - order, payment = _create_order(event, email, positions, now_dt, pprov, - locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, - gift_cards=gift_cards, shown_total=shown_total, customer=customer) + order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests, + locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, + shown_total=shown_total, customer=customer) - free_order_flow = payment and payment_provider == 'free' and order.pending_sum == Decimal('0.00') and not order.require_approval + free_order_flow = ( + payment_objs and + any(p['provider'] == 'free' for p in payment_requests) and + order.pending_sum == Decimal('0.00') and + not order.require_approval + ) if free_order_flow: try: - payment.confirm(send_mail=False, lock=not locked) + for p in payment_objs: + if p.provider == 'free': + p.confirm(send_mail=False, lock=not locked) except Quota.QuotaExceededException: pass invoice = order.invoices.last() # Might be generated by plugin already if not invoice and invoice_qualified(order): if event.settings.get('invoice_generate') == 'True' or ( - event.settings.get('invoice_generate') == 'paid' and payment.payment_provider.requires_invoice_immediately): + event.settings.get('invoice_generate') == 'paid' and any(p['pprov'].requires_invoice_immediately for p in payment_requests)): invoice = generate_invoice( order, trigger_pdf=not event.settings.invoice_email_attachment or not order.email @@ -1084,7 +1092,7 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], subject_attendees_template = event.settings.mail_subject_order_placed_attendee if sales_channel in event.settings.mail_sales_channel_placed_paid: - _order_placed_email(event, order, pprov, email_template, subject_template, log_entry, invoice, payment, + _order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs, is_free=free_order_flow) if email_attendees: for p in order.positions.all(): @@ -1092,7 +1100,33 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], _order_placed_email_attendee(event, order, p, email_attendees_template, subject_attendees_template, log_entry, is_free=free_order_flow) - return order.id + warnings = [] + any_failed = False + for p in payment_objs: + if not p.payment_provider.execute_payment_needs_user: + try: + p.process_initiated = True + p.save(update_fields=['process_initiated']) + resp = p.payment_provider.execute_payment(None, p) + if isinstance(resp, str): + logger.warning('Payment provider returned URL from execute_payment even though execute_payment_needs_user is not set') + except PaymentException as e: + warnings.append(str(e)) + any_failed = True + except Exception: + logger.exception('Error during payment attempt') + + if any_failed: + # Cancel all other payments because their amount might be wrong now. + for p in payment_objs: + if p.state == OrderPayment.PAYMENT_STATE_CREATED: + p.state = OrderPayment.PAYMENT_STATE_CANCELED + p.save(update_fields=['state']) + + return { + 'order_id': order.id, + 'warnings': warnings, + } @receiver(signal=periodic_task) @@ -2394,14 +2428,14 @@ class OrderChangeManager: @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) -def perform_order(self, event: Event, payment_provider: str, positions: List[str], +def perform_order(self, event: Event, payments: List[dict], positions: List[str], email: str=None, locale: str=None, address: int=None, meta_info: dict=None, - sales_channel: str='web', gift_cards: list=None, shown_total=None, customer=None): + sales_channel: str='web', shown_total=None, customer=None): with language(locale): try: try: - return _perform_order(event, payment_provider, positions, email, locale, address, meta_info, - sales_channel, gift_cards, shown_total, customer) + return _perform_order(event, payments, positions, email, locale, address, meta_info, + sales_channel, shown_total, customer) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 51387ae0e..dc0549a71 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -307,7 +307,7 @@ The ``sender`` keyword argument will contain an organizer. validate_order = EventPluginSignal( ) """ -Arguments: ``payment_provider``, ``positions``, ``email``, ``locale``, ``invoice_address``, +Arguments: ``payments``, ``positions``, ``email``, ``locale``, ``invoice_address``, ``meta_info``, ``customer`` This signal is sent out when the user tries to confirm the order, before we actually create @@ -316,6 +316,9 @@ but you can raise an OrderError with an appropriate exception message if you lik the order. We strongly discourage making changes to the order here. As with all event-plugin signals, the ``sender`` keyword argument will contain the event. + +**DEPRECTATION:** Stop listening to the ``payment_provider`` attribute, it will be removed +in the future, as the ``payments`` attribute gives more information. """ validate_cart = EventPluginSignal() @@ -564,7 +567,7 @@ an OrderedDict of (setting name, form field). order_fee_calculation = EventPluginSignal() """ -Arguments: ``positions``, ``invoice_address``, ``meta_info``, ``total``, ``gift_cards`` +Arguments: ``positions``, ``invoice_address``, ``meta_info``, ``total``, ``gift_cards``, ``payment_requests`` This signals allows you to add fees to an order while it is being created. You are expected to return a list of ``OrderFee`` objects that are not yet saved to the database @@ -574,8 +577,10 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve argument will contain the cart positions and ``invoice_address`` the invoice address (useful for tax calculation). The argument ``meta_info`` contains the order's meta dictionary. The ``total`` keyword argument will contain the total cart sum without any fees. You should not rely on this -``total`` value for fee calculations as other fees might interfere. The ``gift_cards`` argument lists -the gift cards in use. +``total`` value for fee calculations as other fees might interfere. The ``gift_cards`` argument +lists the gift cards in use. + +**DEPRECTATION:** Stop listening to the ``gift_cards`` attribute, it will be removed in the future. """ order_fee_type_name = EventPluginSignal() diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 4519078fc..dd50e5b9d 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -1171,8 +1171,8 @@ class MailSettingsForm(SettingsForm): widget=I18nTextarea, ) base_context = { - 'mail_text_order_placed': ['event', 'order', 'payment'], - 'mail_subject_order_placed': ['event', 'order', 'payment'], + 'mail_text_order_placed': ['event', 'order', 'payments'], + 'mail_subject_order_placed': ['event', 'order', 'payments'], 'mail_text_order_placed_attendee': ['event', 'order', 'position'], 'mail_subject_order_placed_attendee': ['event', 'order', 'position'], 'mail_text_order_placed_require_approval': ['event', 'order'], diff --git a/src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html b/src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html index 49108cbb4..d452608fe 100644 --- a/src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html +++ b/src/pretix/control/templates/pretixcontrol/giftcards/checkout_confirm.html @@ -1,8 +1,8 @@ {% load i18n %}
- {% blocktrans %} - Your gift card will be used to pay for this order. If the credit on the gift card is lower than the order total, you will be able to pay the + {% blocktrans trimmed with card=info_data.gift_card_secret %} + Your gift card {{ card }} will be used to pay for this order. If the credit on the gift card is lower than the order total, you will be able to pay the difference with a different payment method. If the credit is higher than the order total, you will be able to re-use the gift card in the future. {% endblocktrans %}
diff --git a/src/pretix/plugins/paypal2/payment.py b/src/pretix/plugins/paypal2/payment.py index 710d580e6..139c0d60f 100644 --- a/src/pretix/plugins/paypal2/payment.py +++ b/src/pretix/plugins/paypal2/payment.py @@ -495,7 +495,7 @@ class PaypalMethod(BasePaymentProvider): def abort_pending_allowed(self): return False - def _create_paypal_order(self, request, payment=None, cart=None): + def _create_paypal_order(self, request, payment=None, cart_total=None): self.init_api() kwargs = {} if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs: @@ -510,7 +510,7 @@ class PaypalMethod(BasePaymentProvider): else: payee = {} - if payment and not cart: + if payment and not cart_total: value = self.format_price(payment.amount) currency = payment.order.event.currency description = '{prefix}{orderstring}{postfix}'.format( @@ -528,8 +528,8 @@ class PaypalMethod(BasePaymentProvider): postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else '' ) request.session['payment_paypal_payment'] = payment.pk - elif cart and not payment: - value = self.format_price(cart['cart_total'] + cart['cart_fees'] + cart['payment_fee']) + elif cart_total and not payment: + value = self.format_price(cart_total) currency = request.event.currency description = __('Event tickets for {event}').format(event=request.event.name) custom_id = '{prefix}{slug}{postfix}'.format( diff --git a/src/pretix/plugins/paypal2/views.py b/src/pretix/plugins/paypal2/views.py index a5863b670..e571a3a87 100644 --- a/src/pretix/plugins/paypal2/views.py +++ b/src/pretix/plugins/paypal2/views.py @@ -68,6 +68,7 @@ from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_re from pretix.plugins.paypal2.payment import PaypalMethod, PaypalMethod as Paypal from pretix.plugins.paypal.models import ReferencedPayPalObject from pretix.presale.views import get_cart, get_cart_total +from pretix.presale.views.cart import cart_session logger = logging.getLogger('pretix.plugins.paypal2') @@ -142,26 +143,27 @@ class XHRView(View): else: fee = prov.calculate_fee(order.pending_sum) - cart = { - 'positions': order.positions, - 'cart_total': order.pending_sum, - 'cart_fees': Decimal('0.00'), - 'payment_fee': fee, - } + cart_total = order.pending_sum + fee else: cart_total = get_cart_total(request) - cart_fees = Decimal('0.00') - for fee in get_fees(request.event, request, cart_total, None, None, get_cart(request)): - cart_fees += fee.value + cart_payments = cart_session(request).get('payments', []) + for fee in get_fees(request.event, request, cart_total, None, cart_payments, get_cart(request)): + cart_total += fee.value - cart = { - 'positions': get_cart(request), - 'cart_total': cart_total, - 'cart_fees': cart_fees, - 'payment_fee': prov.calculate_fee(cart_total + cart_fees), - } + total_remaining = cart_total + for p in cart_session(request).get('payments', []): + if p['provider'] != 'paypal': + if p.get('min_value') and total_remaining < Decimal(p['min_value']): + continue - paypal_order = prov._create_paypal_order(request, None, cart) + to_pay = total_remaining + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) + total_remaining -= to_pay + + cart_total = total_remaining + + paypal_order = prov._create_paypal_order(request, None, cart_total) r = JsonResponse(paypal_order.dict() if paypal_order else {}) r._csp_ignore = True return r diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 4c1a076cc..ea4c28caf 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -33,12 +33,13 @@ # License for the specific language governing permissions and limitations under the License. import copy import inspect +import uuid from collections import defaultdict from decimal import Decimal from django.conf import settings from django.contrib import messages -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.core.signing import BadSignature, loads from django.core.validators import EmailValidator from django.db.models import F, Q @@ -56,12 +57,14 @@ from pretix.base.models import Customer, Membership, Order from pretix.base.models.orders import InvoiceAddress, OrderPayment from pretix.base.models.tax import TaxedPrice, TaxRule from pretix.base.services.cart import ( - CartError, CartManager, error_messages, get_fees, set_cart_addons, + CartError, CartManager, add_payment_to_cart, error_messages, get_fees, + set_cart_addons, ) from pretix.base.services.memberships import validate_memberships_in_order from pretix.base.services.orders import perform_order from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import validate_cart_addons +from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.phone_format import phone_format from pretix.base.templatetags.rich_text import rich_text_snippet from pretix.base.views.tasks import AsyncAction @@ -1144,53 +1147,153 @@ class PaymentStep(CartMixin, TemplateFlowStep): }) return providers + @cached_property + def single_use_payment(self): + singleton_payments = [p for p in self.cart_session.get('payments', []) if not p.get('multi_use_supported')] + if not singleton_payments: + return None + return singleton_payments[0] + + def current_payments_valid(self, amount): + singleton_payments = [p for p in self.cart_session.get('payments', []) if not p.get('multi_use_supported')] + if len(singleton_payments) > 1: + return False + + matched = Decimal('0.00') + for p in self.cart_session.get('payments', []): + if p.get('min_value') and (amount - matched) < Decimal(p['min_value']): + continue + if p.get('max_value') and (amount - matched) > Decimal(p['max_value']): + matched += Decimal(p['max_value']) + else: + matched = Decimal('0.00') + + return matched == Decimal('0.00'), amount - matched + def post(self, request): self.request = request + + if "remove_payment" in request.POST: + self._remove_payment(request.POST["remove_payment"]) + return redirect(self.get_step_url(request)) + for p in self.provider_forms: - if p['provider'].identifier == request.POST.get('payment', ''): - self.cart_session['payment'] = p['provider'].identifier - resp = p['provider'].checkout_prepare( + pprov = p['provider'] + if pprov.identifier == request.POST.get('payment', ''): + if not pprov.multi_use_supported: + # Providers with multi_use_supported will call this themselves + simulated_payments = self.cart_session.get('payments', {}) + simulated_payments = [p for p in simulated_payments if p.get('multi_use_supported')] + simulated_payments.append({ + 'provider': pprov.identifier, + 'multi_use_supported': False, + 'min_value': None, + 'max_value': None, + 'info_data': {}, + }) + cart = self.get_cart(payments=simulated_payments) + else: + cart = self.get_cart() + resp = pprov.checkout_prepare( request, - self.get_cart() + cart, ) if isinstance(resp, str): return redirect(resp) elif resp is True: - return redirect(self.get_next_url(request)) + if pprov.multi_use_supported: + # Provider needs to call add_payment_to_cart itself, but we need to remove all previously + # selected ones that don't have multi_use supported. Otherwise, if you first select a credit + # card, then go back and switch to a gift card, you'll have both in the session and the credit + # card has preference, which is unexpected. + self.cart_session['payments'] = [p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')] + + if pprov.identifier not in [p['provider'] for p in self.cart_session.get('payments', [])]: + raise ImproperlyConfigured(f'Payment provider {pprov.identifier} set multi_use_supported ' + f'and returned True from payment_prepare, but did not call ' + f'add_payment_to_cart') + + valid, remainder = self.current_payments_valid(cart['total']) + if valid: + return redirect(self.get_next_url(request)) + else: + # Show payment step again to select another method + messages.success( + request, + _("Your payment method has been applied, but {} still need to be paid. Please select " + "a payment method for the remainder.").format( + money_filter(remainder, self.event.currency) + ) + ) + return redirect(self.get_step_url(request)) + else: + # There can only be one payment method that does not have multi_use_supported, remove all + # previous ones. + self.cart_session['payments'] = [p for p in self.cart_session.get('payments', []) if p.get('multi_use_supported')] + add_payment_to_cart(request, pprov, None, None, None) + return redirect(self.get_next_url(request)) else: return self.render() + + if self.is_completed(request, warn=False): + # All payments already accounted for, no need to select one + return redirect(self.get_next_url(request)) + messages.error(self.request, _("Please select a payment method.")) return self.render() def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) + ctx['current_payments'] = [p for p in self.current_selected_payments(self._total_order_value) if p.get('multi_use_supported')] + ctx['remaining'] = self._total_order_value - sum(p['payment_amount'] for p in ctx['current_payments']) + sum(p['fee'] for p in ctx['current_payments']) ctx['providers'] = self.provider_forms ctx['show_fees'] = any(p['fee'] for p in self.provider_forms) - ctx['selected'] = self.request.POST.get('payment', self.cart_session.get('payment', '')) + if len(self.provider_forms) == 1: ctx['selected'] = self.provider_forms[0]['provider'].identifier + elif 'payment' in self.request.POST: + ctx['selected'] = self.request.POST['payment'] + elif self.single_use_payment: + ctx['selected'] = self.single_use_payment['provider'] + else: + ctx['selected'] = '' ctx['cart'] = self.get_cart() return ctx - @cached_property - def payment_provider(self): - return self.request.event.get_payment_providers().get(self.cart_session['payment']) - def _is_allowed(self, prov, request): return prov.is_allowed(request, total=self._total_order_value) def is_completed(self, request, warn=False): - self.request = request - if 'payment' not in self.cart_session or not self.payment_provider: + if not self.cart_session.get('payments'): if warn: - messages.error(request, _('The payment information you entered was incomplete.')) + messages.error(request, _('Please select a payment method to proceed.')) return False - if not self.payment_provider.payment_is_valid_session(request) or \ - not self.payment_provider.is_enabled or \ - not self._is_allowed(self.payment_provider, request): + + cart = get_cart(self.request) + total = get_cart_total(self.request) + total += sum([f.value for f in get_fees(self.request.event, self.request, total, self.invoice_address, + self.cart_session.get('payments', []), cart)]) + selected = self.current_selected_payments(total, warn=warn, total_includes_payment_fees=True) + if sum(p['payment_amount'] for p in selected) != total: if warn: - messages.error(request, _('The payment information you entered was incomplete.')) + messages.error(request, _('Please select a payment method to proceed.')) return False + + if len([p for p in selected if not p['multi_use_supported']]) > 1: + raise ImproperlyConfigured('Multiple non-multi-use providers in session, should never happen') + + for p in selected: + if not p['pprov'] or not p['pprov'].is_enabled or not self._is_allowed(p['pprov'], request): + self._remove_payment(p['id']) + if p['payment_amount']: + if warn: + messages.error(request, _('Please select a payment method to proceed.')) + return False + + if not p['multi_use_supported'] and not p['pprov'].payment_is_valid_session(request): + if warn: + messages.error(request, _('The payment information you entered was incomplete.')) + return False return True def is_applicable(self, request): @@ -1198,18 +1301,28 @@ class PaymentStep(CartMixin, TemplateFlowStep): for cartpos in get_cart(self.request): if cartpos.requires_approval(invoice_address=self.invoice_address): - if 'payment' in self.cart_session: - del self.cart_session['payment'] + if 'payments' in self.cart_session: + del self.cart_session['payments'] return False + used_providers = {p['provider'] for p in self.cart_session.get('payments', [])} for p in self.request.event.get_payment_providers().values(): if p.is_implicit(request) if callable(p.is_implicit) else p.is_implicit: if self._is_allowed(p, request): - self.cart_session['payment'] = p.identifier + self.cart_session['payments'] = [ + { + 'id': str(uuid.uuid4()), + 'provider': p.identifier, + 'multi_use_supported': False, + 'min_value': None, + 'max_value': None, + 'info_data': {}, + } + ] return False - elif self.cart_session.get('payment') == p.identifier: + elif p.identifier in used_providers: # is_allowed might have changed, e.g. after add-on selection - del self.cart_session['payment'] + self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p['provider'] != p.identifier] return True @@ -1239,9 +1352,16 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['cart'] = self.get_cart(answers=True) - if self.payment_provider: - ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request) - ctx['payment_provider'] = self.payment_provider + + selected_payments = self.current_selected_payments(ctx['cart']['total'], total_includes_payment_fees=True) + ctx['payments'] = [] + for p in selected_payments: + if 'info_data' in inspect.signature(p['pprov'].checkout_confirm_render).parameters: + block = p['pprov'].checkout_confirm_render(self.request, info_data=p['info_data']) + else: + block = p['pprov'].checkout_confirm_render(self.request) + ctx['payments'].append((p, block)) + ctx['require_approval'] = any(cp.requires_approval(invoice_address=self.invoice_address) for cp in ctx['cart']['positions']) ctx['addr'] = self.invoice_address ctx['confirm_messages'] = self.confirm_messages @@ -1326,23 +1446,27 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): return self.do( self.request.event.id, - payment_provider=self.payment_provider.identifier if self.payment_provider else None, + payments=self.cart_session.get('payments', []), positions=[p.id for p in self.positions], email=self.cart_session.get('email'), locale=translation.get_language(), address=self.invoice_address.pk, meta_info=meta_info, sales_channel=request.sales_channel.identifier, - gift_cards=self.cart_session.get('gift_cards'), shown_total=self.cart_session.get('shown_total'), customer=self.cart_session.get('customer'), ) def get_success_message(self, value): create_empty_cart_id(self.request) + if isinstance(value, dict): + for w in value.get('warnings', []): + messages.warning(self.request, w) return None def get_success_url(self, value): + if isinstance(value, dict): + value = value['order_id'] order = Order.objects.get(id=value) return self.get_order_url(order) diff --git a/src/pretix/presale/signals.py b/src/pretix/presale/signals.py index 49e7acced..914834be8 100644 --- a/src/pretix/presale/signals.py +++ b/src/pretix/presale/signals.py @@ -184,9 +184,10 @@ You will receive the request triggering the order creation as the ``request`` ke As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ + checkout_confirm_page_content = EventPluginSignal() """ -Arguments: 'request' +Arguments: ``request`` This signals allows you to add HTML content to the confirmation page that is presented at the end of the checkout process, just before the order is being created. @@ -197,7 +198,7 @@ argument will contain the request object. fee_calculation_for_cart = EventPluginSignal() """ -Arguments: 'request', 'invoice_address', 'total', 'positions' +Arguments: ``request``, ``invoice_address``, ``total``, ``positions``, ``payment_requetss`` This signals allows you to add fees to a cart. You are expected to return a list of ``OrderFee`` objects that are not yet saved to the database (because there is no order yet). diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html index ca2340cdc..bb35e2eea 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -1,6 +1,7 @@ {% extends "pretixpresale/event/base.html" %} {% load i18n %} {% load bootstrap3 %} +{% load money %} {% load eventurl %} {% load eventsignal %} {% block title %}{% trans "Review order" %}{% endblock %} @@ -37,7 +38,7 @@ {% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=False %} - {% if payment_provider %} + {% if payments %}{% trans "Please select how you want to pay." %}
- {% if event.settings.payment_explanation %} - {{ event.settings.payment_explanation|rich_text }} + {% if current_payments %} +{% trans "You already selected the following payment methods:" %}
+ + {% if remaining %} +{% trans "Please select how you want to pay the remaining balance:" %}
+ {% endif %} + {% else %} +{% trans "Please select how you want to pay." %}
{% endif %}