diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 81fa9b9e71..786b933cbd 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -722,6 +722,10 @@ class BasePaymentProvider: """ return "" + def storefrontapi_prepare(self, session_data, total, info): + # TODO: docstring + return True + def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]: """ Will be called after the user selects this provider as their payment method. @@ -1447,6 +1451,28 @@ class GiftCardPayment(BasePaymentProvider): } ) + def storefrontapi_prepare(self, session_data, total, info): + # todo: validate gift card not paid with gift card + try: + gc = self.event.organizer.accepted_gift_cards.get( + secret=info.get("giftcard").strip() + ) + try: + self._add_giftcard_to_cart(session_data, gc) + return True + except ValidationError as e: + raise PaymentException(str(e.message)) + except GiftCard.DoesNotExist: + if self.event.vouchers.filter(code__iexact=info.get("giftcard")).exists(): + raise PaymentException( + _("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: + raise PaymentException(_("This gift card is not known.")) + except GiftCard.MultipleObjectsReturned: + raise PaymentException(_("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event.")) + def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: for p in get_cart(request): if p.item.issue_giftcard: diff --git a/src/pretix/base/storelogic/payment.py b/src/pretix/base/storelogic/payment.py new file mode 100644 index 0000000000..055b8a2971 --- /dev/null +++ b/src/pretix/base/storelogic/payment.py @@ -0,0 +1,132 @@ +import copy +import uuid +from decimal import Decimal + +from django.core.exceptions import ImproperlyConfigured +from django.utils.translation import gettext as _ + +from pretix.base.storelogic import IncompleteError +from pretix.base.templatetags.money import money_filter + + +def payment_is_applicable(event, total, cart_positions, invoice_address, cart_session, request): + for cartpos in cart_positions: + if cartpos.requires_approval(invoice_address=invoice_address): + if 'payments' in cart_session: + del cart_session['payments'] + return False + + used_providers = {p['provider'] for p in cart_session.get('payments', [])} + for provider in event.get_payment_providers().values(): + if provider.is_implicit(request) if callable(provider.is_implicit) else provider.is_implicit: + # TODO: do we need a different is_allowed for storefrontapi? + if provider.is_allowed(request, total=total): + cart_session['payments'] = [ + { + 'id': str(uuid.uuid4()), + 'provider': provider.identifier, + 'multi_use_supported': False, + 'min_value': None, + 'max_value': None, + 'info_data': {}, + } + ] + return False + elif provider.identifier in used_providers: + # is_allowed might have changed, e.g. after add-on selection + cart_session['payments'] = [p for p in cart_session['payments'] if + p['provider'] != provider.identifier] + return True + + +def current_selected_payments(event, total, cart_session, total_includes_payment_fees=False, fail=False): + def _remove_payment(payment_id): + cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id] + + raw_payments = copy.deepcopy(cart_session.get('payments', [])) + payments = [] + total_remaining = total + for p in raw_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.storelogic.payment.current_selected_payments + if p.get('min_value') and total_remaining < Decimal(p['min_value']): + _remove_payment(p['id']) + if fail: + raise IncompleteError( + _('Your selected payment method can only be used for a payment of at least {amount}.').format( + amount=money_filter(Decimal(p['min_value']), event.currency) + ) + ) + continue + + 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: + _remove_payment(p['id']) + continue + + if not total_includes_payment_fees: + fee = pprov.calculate_fee(to_pay) + total_remaining += fee + to_pay += fee + else: + fee = Decimal('0.00') + + if p.get('max_value') and to_pay > Decimal(p['max_value']): + to_pay = min(to_pay, Decimal(p['max_value'])) + + p['payment_amount'] = to_pay + p['provider_name'] = pprov.public_name + p['pprov'] = pprov + p['fee'] = fee + total_remaining -= to_pay + payments.append(p) + return payments + + +def ensure_payment_is_completed(event, total, cart_session, request): + def _remove_payment(payment_id): + cart_session['payments'] = [p for p in cart_session['payments'] if p.get('id') != payment_id] + + if not cart_session.get('payments'): + raise IncompleteError(_('Please select a payment method to proceed.')) + + selected = current_selected_payments(event, total, cart_session, fail=True, total_includes_payment_fees=True) + if sum(p['payment_amount'] for p in selected) != total: + raise IncompleteError(_('Please select a payment method to proceed.')) + + 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: + # TODO: do we need a different is_allowed for storefrontapi? + if not p['pprov'] or not p['pprov'].is_enabled or not p['pprov'].is_allowed(request, total=total): + _remove_payment(p['id']) + if p['payment_amount']: + raise IncompleteError(_('Please select a payment method to proceed.')) + + if not p['multi_use_supported'] and not p['pprov'].payment_is_valid_session(request): + raise IncompleteError(_('The payment information you entered was incomplete.')) + + +def current_payments_valid(cart_session, amount): + singleton_payments = [p for p in 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 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 diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index 6697515f6b..6a2306a4d5 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -360,6 +360,9 @@ class BankTransfer(BasePaymentProvider): } return template.render(ctx) + def storefrontapi_prepare(self, session_data, total, info): + return True + def checkout_prepare(self, request, total): form = self.payment_form(request) if form.is_valid(): diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 8af745fa41..863b995c77 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -33,7 +33,6 @@ # License for the specific language governing permissions and limitations under the License. import copy import inspect -import uuid from decimal import Decimal from django.conf import settings @@ -75,6 +74,9 @@ from pretix.base.storelogic.addons import ( addons_is_applicable, addons_is_completed, get_addon_groups, ) from pretix.base.storelogic.fields import ensure_fields_are_completed +from pretix.base.storelogic.payment import ( + current_payments_valid, ensure_payment_is_completed, payment_is_applicable, +) 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 @@ -908,14 +910,15 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): return redirect_to_url(self.get_next_url(request)) def is_completed(self, request, warn=False): + self.request = request try: ensure_fields_are_completed( - self.request.event, + self.event, self._positions_for_questions, self.cart_session, self.invoice_address, self.all_optional, - get_cart_is_free(self.request), + get_cart_is_free(request), ) except IncompleteError as e: if warn: @@ -1121,20 +1124,7 @@ class PaymentStep(CartMixin, TemplateFlowStep): 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 + return current_payments_valid(self.cart_session, amount) def post(self, request): self.request = request @@ -1238,6 +1228,7 @@ class PaymentStep(CartMixin, TemplateFlowStep): def is_completed(self, request, warn=False): if not self.cart_session.get('payments'): + # Is also in ensure_payment_is_completed, but saves us performance of cart evaluation if warn: messages.error(request, _('Please select a payment method to proceed.')) return False @@ -1250,58 +1241,30 @@ class PaymentStep(CartMixin, TemplateFlowStep): except TaxRule.SaleNotAllowed: # ignore for now, will fail on order creation pass - selected = self.current_selected_payments(total, warn=warn, total_includes_payment_fees=True) - if sum(p['payment_amount'] for p in selected) != total: + + try: + ensure_payment_is_completed( + self.event, + total, + self.cart_session, + self.request, + ) + except IncompleteError as e: if warn: - messages.error(request, _('Please select a payment method to proceed.')) + messages.warning(self.request, str(e)) 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): self.request = request - - for cartpos in get_cart(self.request): - if cartpos.requires_approval(invoice_address=self.invoice_address): - 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 provider in self.request.event.get_payment_providers().values(): - if provider.is_implicit(request) if callable(provider.is_implicit) else provider.is_implicit: - if self._is_allowed(provider, request): - self.cart_session['payments'] = [ - { - 'id': str(uuid.uuid4()), - 'provider': provider.identifier, - 'multi_use_supported': False, - 'min_value': None, - 'max_value': None, - 'info_data': {}, - } - ] - return False - elif provider.identifier in used_providers: - # is_allowed might have changed, e.g. after add-on selection - self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p['provider'] != provider.identifier] - - return True + return payment_is_applicable( + self.event, + self._total_order_value, + get_cart(request), + self.invoice_address, + self.cart_session, + request, + ) def get(self, request): self.request.pci_dss_payment_page = True diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 8d067ebf82..a2f5db5555 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -31,7 +31,6 @@ # 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 copy from collections import defaultdict from datetime import datetime, timedelta from decimal import Decimal @@ -44,7 +43,6 @@ from django.db.models import Exists, OuterRef, Prefetch, Sum from django.utils import translation from django.utils.functional import cached_property from django.utils.timezone import now -from django.utils.translation import gettext_lazy as _ from django_scopes import scopes_disabled from pretix.base.i18n import get_language_without_region @@ -54,7 +52,8 @@ from pretix.base.models import ( QuestionAnswer, QuestionOption, TaxRule, ) from pretix.base.services.cart import get_fees -from pretix.base.templatetags.money import money_filter +from pretix.base.storelogic import IncompleteError +from pretix.base.storelogic.payment import current_selected_payments from pretix.helpers.cookies import set_cookie_without_samesite from pretix.multidomain.urlreverse import eventreverse from pretix.presale.signals import question_form_fields @@ -256,52 +255,16 @@ class CartMixin: } def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False): - raw_payments = copy.deepcopy(self.cart_session.get('payments', [])) - payments = [] - total_remaining = total - for p in raw_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']): - if warn: - messages.warning( - self.request, - _('Your selected payment method can only be used for a payment of at least {amount}.').format( - amount=money_filter(Decimal(p['min_value']), self.request.event.currency) - ) - ) - self._remove_payment(p['id']) - continue - - 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 = self.request.event.get_payment_providers(cached=True).get(p['provider']) - if not pprov: - self._remove_payment(p['id']) - continue - - if not total_includes_payment_fees: - fee = pprov.calculate_fee(to_pay) - total_remaining += fee - to_pay += fee - else: - fee = Decimal('0.00') - - if p.get('max_value') and to_pay > Decimal(p['max_value']): - to_pay = min(to_pay, Decimal(p['max_value'])) - - p['payment_amount'] = to_pay - p['provider_name'] = pprov.public_name - p['pprov'] = pprov - p['fee'] = fee - total_remaining -= to_pay - payments.append(p) - return payments + try: + return current_selected_payments( + self.request.event, + total, + self.cart_session, + total_includes_payment_fees=total_includes_payment_fees, + fail=warn + ) + except IncompleteError as e: + messages.warning(self.request, str(e)) def _remove_payment(self, payment_id): self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p.get('id') != payment_id] diff --git a/src/pretix/storefrontapi/endpoints/checkout.py b/src/pretix/storefrontapi/endpoints/checkout.py index 5d1efffabb..c9b31593fe 100644 --- a/src/pretix/storefrontapi/endpoints/checkout.py +++ b/src/pretix/storefrontapi/endpoints/checkout.py @@ -1,7 +1,7 @@ import logging from celery.result import AsyncResult -from django.core.exceptions import ValidationError +from django.core.exceptions import ImproperlyConfigured, ValidationError from django.utils import translation from django.utils.translation import gettext as _ from rest_framework import serializers, status, viewsets @@ -13,13 +13,19 @@ from rest_framework.reverse import reverse from pretix.base.models import Item, ItemVariation, SubEvent, TaxRule from pretix.base.models.orders import CartPosition, CheckoutSession, OrderFee from pretix.base.services.cart import ( - add_items_to_cart, error_messages, get_fees, set_cart_addons, + add_items_to_cart, add_payment_to_cart_session, error_messages, get_fees, + set_cart_addons, ) +from pretix.base.services.orders import perform_order from pretix.base.storelogic.addons import get_addon_groups from pretix.base.storelogic.fields import ( get_checkout_fields, get_position_fields, ) +from pretix.base.storelogic.payment import current_selected_payments from pretix.base.timemachine import time_machine_now +from pretix.presale.signals import ( + order_api_meta_from_request, order_meta_from_request, +) from pretix.presale.views.cart import generate_cart_id from pretix.storefrontapi.endpoints.event import ( CategorySerializer, ItemSerializer, @@ -140,13 +146,9 @@ class CartPositionSerializer(MinimalCartPositionSerializer): d = super().to_representation(instance) fields = get_position_fields(self.context["event"], instance) d["fields"] = FieldSerializer( - fields, - many=True, - context={**self.context, "position": instance} + fields, many=True, context={**self.context, "position": instance} ).data - d["fields_data"] = { - f.identifier: f.current_value(instance) for f in fields - } + d["fields_data"] = {f.identifier: f.current_value(instance) for f in fields} return d @@ -192,14 +194,28 @@ class CheckoutSessionSerializer(serializers.ModelSerializer): fields = get_checkout_fields(self.context["event"]) d["fields"] = FieldSerializer( - fields, - many=True, - context={**self.context, "checkout": checkout} + fields, many=True, context={**self.context, "checkout": checkout} ).data d["fields_data"] = { f.identifier: f.current_value(checkout.session_data) for f in fields } + payments = current_selected_payments( + self.context["event"], + total, + checkout.session_data, + total_includes_payment_fees=False, + fail=False, + ) + d["payments"] = [ + { + "identifier": p["pprov"].identifier, + "label": str(p["pprov"].public_name), + "payment_amount": str(p["payment_amount"]), + } + for p in payments + ] + steps = get_steps( self.context["event"], cartpos, @@ -307,7 +323,9 @@ class CheckoutViewSet(viewsets.ViewSet): elif request.method == "GET": data = [ { - "parent": MinimalCartPositionSerializer(grp["pos"], context=ctx).data, + "parent": MinimalCartPositionSerializer( + grp["pos"], context=ctx + ).data, "categories": [ { "category": CategorySerializer( @@ -340,14 +358,151 @@ class CheckoutViewSet(viewsets.ViewSet): status=200, ) + def _get_total(self, cs, payments): + cartpos = cs.get_cart_positions(prefetch_questions=True) + total = sum(p.price for p in cartpos) + + try: + # TODO: do we need a different get_fees for storefrontapi? + fees = get_fees( + self.request.event, + self.request, + total, + (cs.invoice_address if hasattr(cs, "invoice_address") else None), + payments=payments, + positions=cartpos, + ) + except TaxRule.SaleNotAllowed: + # ignore for now, will fail on order creation + fees = [] + + total += sum([f.value for f in fees]) + return total + + @action(detail=True, methods=["GET", "POST"]) + def payment(self, request, *args, **kwargs): + cs = get_object_or_404( + self.request.event.checkout_sessions, cart_id=kwargs["cart_id"] + ) + if request.method == "POST": + # TODO: allow explicit removal + + for provider in self.request.event.get_payment_providers().values(): + if provider.identifier == request.data.get("identifier", ""): + if not provider.multi_use_supported: + # Providers with multi_use_supported will call this themselves + simulated_payments = cs.session_data.get("payments", {}) + simulated_payments = [ + p + for p in simulated_payments + if p.get("multi_use_supported") + ] + simulated_payments.append( + { + "provider": provider.identifier, + "multi_use_supported": False, + "min_value": None, + "max_value": None, + "info_data": {}, + } + ) + total = self._get_total( + cs, + simulated_payments, + ) + else: + total = self._get_total( + cs, + [ + p + for p in cs.session_data.get("payments", []) + if p.get("multi_use_supported") + ], + ) + + resp = provider.storefrontapi_prepare( + cs.session_data, + total, + request.data.get("info"), + ) + if provider.multi_use_supported: + if resp is True: + # 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. + cs.session_data["payments"] = [ + p + for p in cs.session_data.get("payments", []) + if p.get("multi_use_supported") + ] + + if provider.identifier not in [ + p["provider"] + for p in cs.session_data.get("payments", []) + ]: + raise ImproperlyConfigured( + f"Payment provider {provider.identifier} set multi_use_supported " + f"and returned True from payment_prepare, but did not call " + f"add_payment_to_cart" + ) + else: + if resp is True or isinstance(resp, str): + # There can only be one payment method that does not have multi_use_supported, remove all + # previous ones. + cs.session_data["payments"] = [ + p + for p in cs.session_data.get("payments", []) + if p.get("multi_use_supported") + ] + add_payment_to_cart_session( + cs.session_data, provider, None, None, None + ) + cs.save(update_fields=["session_data"]) + return self._return_checkout_status(cs, 200) + elif request.method == "GET": + available_providers = [] + total = self._get_total( + cs, + [ + p + for p in cs.session_data.get("payments", []) + if p.get("multi_use_supported") + ], + ) + + for provider in sorted( + self.request.event.get_payment_providers().values(), + key=lambda p: (-p.priority, str(p.public_name).title()), + ): + # TODO: do we need a different is_allowed for storefrontapi? + if not provider.is_enabled or not provider.is_allowed( + self.request, total + ): + continue + fee = provider.calculate_fee(total) + available_providers.append( + { + "identifier": provider.identifier, + "label": provider.public_name, + "fee": str(fee), + "total": str(total + fee), + } + ) + + return Response( + data={ + "available_providers": available_providers, + }, + status=200, + ) + @action(detail=True, methods=["PATCH"]) def fields(self, request, *args, **kwargs): cs = get_object_or_404( self.request.event.checkout_sessions, cart_id=kwargs["cart_id"] ) - server_pos = { - p.pk: p for p in cs.get_cart_positions(prefetch_questions=True) - } + server_pos = {p.pk: p for p in cs.get_cart_positions(prefetch_questions=True)} for req_pos in request.data.get("cart_positions", []): pos = server_pos[req_pos["id"]] fields = get_position_fields(self.request.event, pos) @@ -398,6 +553,73 @@ class CheckoutViewSet(viewsets.ViewSet): time_machine_now(default=None), ) + @action(detail=True, methods=["POST"]) + def confirm(self, request, *args, **kwargs): + cs = get_object_or_404( + self.request.event.checkout_sessions, cart_id=kwargs["cart_id"] + ) + cartpos = cs.get_cart_positions(prefetch_questions=True) + total = sum(p.price for p in cartpos) + + try: + fees = get_fees( + self.request.event, + self.request, + total, + (cs.invoice_address if hasattr(cs, "invoice_address") else None), + payments=[], # todo + positions=cartpos, + ) + except TaxRule.SaleNotAllowed as e: + raise ValidationError(str(e)) # todo: need better message? + + total += sum([f.value for f in fees]) + steps = get_steps( + request.event, + cartpos, + getattr(cs, "invoice_address", None), + cs.session_data, + total, + ) + for step in steps: + applicable = step.is_applicable() + valid = not applicable or step.is_valid() + if not valid: + raise ValidationError(f"Step {step.identifier} is not valid") + + # todo: confirm messages, or integrate them as fields? + meta_info = { + "contact_form_data": cs.session_data.get("contact_form_data", {}), + } + api_meta = {} + for receiver, response in order_meta_from_request.send( + sender=request.event, request=request + ): + meta_info.update(response) + for receiver, response in order_api_meta_from_request.send( + sender=request.event, request=request + ): + api_meta.update(response) + + # todo: delete checkout session + # todo: give info about order + return self._do_async( + cs, + perform_order, + self.request.event.id, + payments=cs.session_data.get("payments", []), + positions=[p.id for p in cartpos], + email=cs.session_data.get("email"), + locale=translation.get_language(), + address=cs.invoice_address.pk if hasattr(cs, "invoice_address") else None, + meta_info=meta_info, + sales_channel=request.sales_channel.identifier, + shown_total=None, + customer=cs.customer, + override_now_dt=time_machine_now(default=None), + api_meta=api_meta, + ) + @action( detail=True, methods=["GET"], diff --git a/src/pretix/storefrontapi/endpoints/event.py b/src/pretix/storefrontapi/endpoints/event.py index b815ccf7ba..6460dbf319 100644 --- a/src/pretix/storefrontapi/endpoints/event.py +++ b/src/pretix/storefrontapi/endpoints/event.py @@ -89,8 +89,8 @@ class PricingField(serializers.Field): return None item = item_or_var if isinstance(item_or_var, Item) else item_or_var.item - suggested_price = item.suggested_price - display_price = item.display_price + suggested_price = item_or_var.suggested_price + display_price = item_or_var.display_price if self.context.get("price_included"): display_price = TaxedPrice( @@ -104,7 +104,7 @@ class PricingField(serializers.Field): if hasattr(item, "initial_price"): # Pre-select current price for add-ons - suggested_price = item.initial_price + suggested_price = item_or_var.initial_price return { "display_price": { diff --git a/src/pretix/storefrontapi/steps.py b/src/pretix/storefrontapi/steps.py index 4c5c362292..3fc62daf3a 100644 --- a/src/pretix/storefrontapi/steps.py +++ b/src/pretix/storefrontapi/steps.py @@ -1,10 +1,16 @@ +from collections import UserDict from decimal import Decimal +from django.test import RequestFactory + from pretix.base.storelogic import IncompleteError from pretix.base.storelogic.addons import ( addons_is_applicable, addons_is_completed, ) from pretix.base.storelogic.fields import ensure_fields_are_completed +from pretix.base.storelogic.payment import ( + ensure_payment_is_completed, payment_is_applicable, +) class CheckoutStep: @@ -58,14 +64,59 @@ class FieldsStep(CheckoutStep): return True +class PaymentStep(CheckoutStep): + identifier = "payment" + + @property + def request(self): + # TODO: find a better way to avoid this + rf = RequestFactory() + r = rf.get("/") + r.event = self.event + r.organizer = self.event.organizer + self.cart_session.setdefault("fake_request", {}) + cart_id = self.cart_positions[0].cart_id + r.session = UserDict( + { + f"current_cart_event_{self.event.pk}": cart_id, + "carts": {cart_id: self.cart_session}, + } + ) + r.session.session_key = cart_id + return r + + def is_applicable(self): + return payment_is_applicable( + self.event, + self.total, + self.cart_positions, + self.invoice_address, + self.cart_session, + self.request, + ) + + def is_valid(self): + try: + ensure_payment_is_completed( + self.event, + self.total, + self.cart_session, + self.request, + ) + except IncompleteError: + return False + else: + return True + + def get_steps(event, cart_positions, invoice_address, cart_session, total): return [ AddonStep(event, cart_positions, invoice_address, cart_session, total), FieldsStep(event, cart_positions, invoice_address, cart_session, total), + PaymentStep(event, cart_positions, invoice_address, cart_session, total), # todo: cross-selling # todo: customers # todo: memberships # todo: plugin signals - # todo: payment # todo: confirmations ]