Support for external gift cards (#2912)

This commit is contained in:
Raphael Michel
2022-11-23 14:52:56 +01:00
committed by GitHub
parent d3589696d7
commit 9624b1c505
24 changed files with 1521 additions and 523 deletions

View File

@@ -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)

View File

@@ -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).

View File

@@ -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 %}
</div>
</div>
{% if payment_provider %}
{% if payments %}
<div class="panel panel-primary">
<div class="panel-heading">
{% if payment_provider.identifier != "free" %}
@@ -52,9 +53,25 @@
{% trans "Payment" %}
</h3>
</div>
<div class="panel-body">
{{ payment }}
</div>
<ul class="list-group">
{% for payment, rendered_block in payments %}
<li class="list-group-item payment">
{% if payments|length > 1 %}
<div class="row">
<div class="col-sm-10 col-xs-12">
<h4>{{ payment.provider_name }}</h4>
{{ rendered_block }}
</div>
<div class="col-sm-2 col-xs-12 text-right">
<h4>{{ payment.payment_amount|money:request.event.currency }}</h4>
</div>
</div>
{% else %}
{{ rendered_block }}
{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% eventsignal event "pretix.presale.signals.checkout_confirm_page_content" request=request %}

View File

@@ -4,73 +4,120 @@
{% load bootstrap3 %}
{% load rich_text %}
{% block inner %}
<p>{% trans "Please select how you want to pay." %}</p>
{% if event.settings.payment_explanation %}
{{ event.settings.payment_explanation|rich_text }}
{% if current_payments %}
<p>{% trans "You already selected the following payment methods:" %}</p>
<form method="post">
{% csrf_token %}
<div class="list-group">
{% for p in current_payments %}
<div class="list-group-item">
<div class="row">
<div class="col-xs-9">
{{ p.provider_name }}
</div>
<div class="col-xs-2 text-right">
{{ p.payment_amount|money:request.event.currency }}
</div>
<div class="col-xs-1 text-right">
<button name="remove_payment" value="{{ p.id }}" title="{% trans "Remove payment" %}"
class="btn btn-link btn-xs">
<span class="fa fa-trash text-danger"></span>
</button>
</div>
</div>
</div>
{% endfor %}
{% if remaining %}
<div class="list-group-item">
<div class="row">
<div class="col-xs-9">
<strong>{% trans "Remaining balance" %}</strong><br>
<span class="text-muted">{% trans "Please select a payment method below." %}</span>
</div>
<div class="col-xs-2 text-right">
<strong>
{{ remaining|money:request.event.currency }}
</strong>
</div>
</div>
</div>
{% endif %}
</div>
</form>
{% if remaining %}
<p>{% trans "Please select how you want to pay the remaining balance:" %}</p>
{% endif %}
{% else %}
<p>{% trans "Please select how you want to pay." %}</p>
{% endif %}
<form method="post">
{% csrf_token %}
<div class="panel-group" id="payment_accordion">
{% for p in providers %}
<div class="panel panel-default" data-total="{{ p.total|floatformat:2 }}">
<div class="accordion-radio">
<div class="panel-heading">
<p class="panel-title">
{% if show_fees %}
<strong class="pull-right flip">{% if p.fee < 0 %}-{% else %}+{% endif %} {{ p.fee|money:event.currency|cut:"-" }}</strong>
{% endif %}
<input type="radio" name="payment" value="{{ p.provider.identifier }}"
title="{{ p.provider.public_name }}"
data-parent="#payment_accordion"
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
id="input_payment_{{ p.provider.identifier }}"
aria-describedby="payment_{{ p.provider.identifier }}"
data-toggle="radiocollapse" data-target="#payment_{{ p.provider.identifier }}"/>
<label for="input_payment_{{ p.provider.identifier }}"><strong>{{ p.provider.public_name }}</strong></label>
</p>
{% if not current_payments or remaining %}
{% if event.settings.payment_explanation %}
{{ event.settings.payment_explanation|rich_text }}
{% endif %}
<div class="panel-group" id="payment_accordion">
{% for p in providers %}
<div class="panel panel-default">
<div class="accordion-radio">
<div class="panel-heading">
<p class="panel-title">
{% if show_fees %}
<strong class="pull-right flip">{% if p.fee < 0 %}-{% else %}+{% endif %} {{ p.fee|money:event.currency|cut:"-" }}</strong>
{% endif %}
<input type="radio" name="payment" value="{{ p.provider.identifier }}"
title="{{ p.provider.public_name }}"
data-parent="#payment_accordion"
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
id="input_payment_{{ p.provider.identifier }}"
aria-describedby="payment_{{ p.provider.identifier }}"
data-toggle="radiocollapse" data-target="#payment_{{ p.provider.identifier }}"/>
<label for="input_payment_{{ p.provider.identifier }}"><strong>{{ p.provider.public_name }}</strong></label>
</p>
</div>
</div>
</div>
<div id="payment_{{ p.provider.identifier }}"
class="panel-collapse collapsed {% if selected == p.provider.identifier %}in{% endif %}">
<div class="panel-body form-horizontal">
{% if request.event.testmode %}
{% if p.provider.test_mode_message %}
<div class="alert alert-info">
<p>{{ p.provider.test_mode_message }}</p>
</div>
{% if not request.sales_channel.testmode_supported %}
<div class="alert alert-danger">
<div id="payment_{{ p.provider.identifier }}"
class="panel-collapse collapsed {% if selected == p.provider.identifier %}in{% endif %}">
<div class="panel-body form-horizontal">
{% if request.event.testmode %}
{% if p.provider.test_mode_message %}
<div class="alert alert-info">
<p>{{ p.provider.test_mode_message }}</p>
</div>
{% if not request.sales_channel.testmode_supported %}
<div class="alert alert-danger">
<p>
{% trans "This sales channel does not provide support for test mode." %}
<strong>
{% trans "If you continue, you might pay an actual order with non-existing money!" %}
</strong>
</p>
</div>
{% endif %}
{% else %}
<div class="alert alert-warning">
<p>
{% trans "This sales channel does not provide support for test mode." %}
{% trans "This payment provider does not provide support for test mode." %}
<strong>
{% trans "If you continue, you might pay an actual order with non-existing money!" %}
{% trans "If you continue, actual money might be transferred." %}
</strong>
</p>
</div>
{% endif %}
{% else %}
<div class="alert alert-warning">
<p>
{% trans "This payment provider does not provide support for test mode." %}
<strong>
{% trans "If you continue, actual money might be transferred." %}
</strong>
</p>
</div>
{% endif %}
{% endif %}
{{ p.form }}
{{ p.form }}
</div>
</div>
</div>
</div>
{% endfor %}
{% if not providers %}
<p><em>{% trans "There are no payment providers enabled." %}</em></p>
{% if not event.live %}
<p>{% trans "Please go to the payment settings and activate one or more payment providers." %}</p>
{% endfor %}
{% if not providers %}
<p><em>{% trans "There are no payment providers enabled." %}</em></p>
{% if not event.live %}
<p>{% trans "Please go to the payment settings and activate one or more payment providers." %}</p>
{% endif %}
{% endif %}
{% endif %}
</div>
</div>
{% endif %}
<div class="row checkout-button-row">
<div class="col-md-4 col-sm-6">
<a class="btn btn-block btn-default btn-lg"

View File

@@ -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 copy
from collections import defaultdict
from datetime import datetime, timedelta
from decimal import Decimal
@@ -39,9 +39,11 @@ from functools import wraps
from itertools import groupby
from django.conf import settings
from django.contrib import messages
from django.db.models import Exists, OuterRef, Prefetch, Sum
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 language
@@ -50,6 +52,7 @@ from pretix.base.models import (
QuestionAnswer, QuestionOption,
)
from pretix.base.services.cart import get_fees
from pretix.base.templatetags.money import money_filter
from pretix.helpers.cookies import set_cookie_without_samesite
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.signals import question_form_fields
@@ -103,7 +106,7 @@ class CartMixin:
def invoice_address(self):
return cached_invoice_address(self.request)
def get_cart(self, answers=False, queryset=None, order=None, downloads=False):
def get_cart(self, answers=False, queryset=None, order=None, downloads=False, payments=None):
if queryset is not None:
prefetch = []
if answers:
@@ -196,7 +199,8 @@ class CartMixin:
fees = order.fees.all()
elif positions:
fees = get_fees(
self.request.event, self.request, total, self.invoice_address, self.cart_session.get('payment'),
self.request.event, self.request, total, self.invoice_address,
payments if payments is not None else self.cart_session.get('payments', []),
cartpos
)
else:
@@ -233,6 +237,57 @@ class CartMixin:
'itemcount': sum(c.count for c in positions if not c.addon_to)
}
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
def _remove_payment(self, payment_id):
self.cart_session['payments'] = [p for p in self.cart_session['payments'] if p.get('id') != payment_id]
def cart_exists(request):
from pretix.presale.views.cart import get_or_create_cart_id
@@ -334,7 +389,7 @@ def get_cart_is_free(request):
pos = get_cart(request)
ia = get_cart_invoice_address(request)
total = get_cart_total(request)
fees = get_fees(request.event, request, total, ia, cs.get('payment'), pos)
fees = get_fees(request.event, request, total, ia, cs.get('payments', []), pos)
request._cart_free_cache = total + sum(f.value for f in fees) == Decimal('0.00')
return request._cart_free_cache

View File

@@ -208,6 +208,14 @@ class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TicketPageMixin,
self.kwargs = kwargs
if not self.order:
raise Http404(_('Unknown order code or not authorized to access this order.'))
if self.order.status == Order.STATUS_PENDING:
payment_to_complete = self.order.payments.filter(state=OrderPayment.PAYMENT_STATE_CREATED, process_initiated=False).first()
if payment_to_complete:
return redirect(eventreverse(self.request.event, 'presale:event.order.pay.complete', kwargs={
'order': self.order.code,
'secret': self.order.secret,
'payment': payment_to_complete.pk
}))
return super().get(request, *args, **kwargs)
@cached_property
@@ -472,6 +480,8 @@ class OrderPaymentConfirm(EventViewMixin, OrderDetailMixin, TemplateView):
'invoice': i.pk
})
messages.success(self.request, _('An invoice has been generated.'))
self.payment.process_initiated = True
self.payment.save(update_fields=['process_initiated'])
resp = self.payment.payment_provider.execute_payment(request, self.payment)
except PaymentException as e:
messages.error(request, str(e))
@@ -532,6 +542,8 @@ class OrderPaymentComplete(EventViewMixin, OrderDetailMixin, View):
def get(self, request, *args, **kwargs):
try:
self.payment.process_initiated = True
self.payment.save(update_fields=['process_initiated'])
resp = self.payment.payment_provider.execute_payment(request, self.payment)
except PaymentException as e:
messages.error(request, str(e))