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

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

View File

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

View File

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

View File

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

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

View File

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

View File

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