mirror of
https://github.com/pretix/pretix.git
synced 2026-05-04 15:04:03 +00:00
Co-authored-by: Raphael Michel <michel@rami.io> Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
This commit is contained in:
@@ -58,7 +58,10 @@ from django_countries import countries
|
||||
from pretix import __version__
|
||||
from pretix.base.decimal import round_decimal
|
||||
from pretix.base.forms import SecretKeySettingsField
|
||||
from pretix.base.models import Event, OrderPayment, OrderRefund, Quota
|
||||
from pretix.base.forms.questions import guess_country
|
||||
from pretix.base.models import (
|
||||
Event, InvoiceAddress, Order, OrderPayment, OrderRefund, Quota,
|
||||
)
|
||||
from pretix.base.payment import (
|
||||
BasePaymentProvider, PaymentException, WalletQueries,
|
||||
)
|
||||
@@ -66,6 +69,8 @@ from pretix.base.plugins import get_all_plugins
|
||||
from pretix.base.services.mail import SendMailException
|
||||
from pretix.base.settings import SettingsSandbox
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.helpers.countries import CachedCountries
|
||||
from pretix.helpers.http import get_client_ip
|
||||
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
||||
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
|
||||
from pretix.plugins.stripe.forms import StripeKeyValidator
|
||||
@@ -75,9 +80,73 @@ from pretix.plugins.stripe.models import (
|
||||
from pretix.plugins.stripe.tasks import (
|
||||
get_stripe_account_key, stripe_verify_domain,
|
||||
)
|
||||
from pretix.presale.views.cart import cart_session
|
||||
|
||||
logger = logging.getLogger('pretix.plugins.stripe')
|
||||
|
||||
# State of the payment methods
|
||||
#
|
||||
# Source: https://stripe.com/docs/payments/payment-methods/overview
|
||||
# Last Update: 2023-04-24
|
||||
#
|
||||
# Cards
|
||||
# - Credit and Debit Cards: ✓
|
||||
# - Apple, Google Pay: ✓
|
||||
#
|
||||
# Bank debits
|
||||
# - ACH Debit: ✗
|
||||
# - Canadian PADs: ✗
|
||||
# - BACS Direct Debit: ✗
|
||||
# - SEPA Direct Debit: ✓
|
||||
# - BECS Direct Debit: ✗
|
||||
#
|
||||
# Bank redirects
|
||||
# - Bancontact: ✓
|
||||
# - BLIK: ✗
|
||||
# - EPS: ✓
|
||||
# - giropay: ✓
|
||||
# - iDEAL: ✓
|
||||
# - P24: ✓
|
||||
# - Sofort: ✓
|
||||
# - FPX: ✗
|
||||
# - PayNow: ✗
|
||||
# - UPI: ✗
|
||||
# - Netbanking: ✗
|
||||
#
|
||||
# Bank transfers
|
||||
# - ACH Bank Transfer: ✗
|
||||
# - SEPA Bank Transfer: ✗
|
||||
# - UK Bank Transfer: ✗
|
||||
# - Multibanco: ✗
|
||||
# - Furikomi (Japan): ✗
|
||||
# - Mexico Bank Transfer: ✗
|
||||
#
|
||||
# Buy now, pay later
|
||||
# - Affirm: ✗
|
||||
# - Afterpay/Clearpay: ✗
|
||||
# - Klarna: ✗
|
||||
#
|
||||
# Real-time payments
|
||||
# - PayNow: ✗
|
||||
# - PromptPay: ✗
|
||||
# - Pix: ✗
|
||||
#
|
||||
# Vouchers
|
||||
# - Konbini: ✗
|
||||
# - OXXO: ✗
|
||||
# - Boleto: ✗
|
||||
#
|
||||
# Wallets
|
||||
# - Apple Pay: ✓ (Cards)
|
||||
# - Google Pay: ✓ (Cards)
|
||||
# - Secure Remote Commerce: ✗
|
||||
# - Link: ✓ (PaymentRequestButton)
|
||||
# - Cash App Pay: ✗
|
||||
# - MobilePay: ✗
|
||||
# - Alipay: ✓
|
||||
# - WeChat Pay: ✓
|
||||
# - GrabPay: ✓
|
||||
|
||||
|
||||
class StripeSettingsHolder(BasePaymentProvider):
|
||||
identifier = 'stripe_settings'
|
||||
@@ -250,7 +319,7 @@ class StripeSettingsHolder(BasePaymentProvider):
|
||||
|
||||
d = OrderedDict(
|
||||
fields + [
|
||||
('method_cc',
|
||||
('method_card',
|
||||
forms.BooleanField(
|
||||
label=_('Credit card payments'),
|
||||
required=False,
|
||||
@@ -283,6 +352,32 @@ class StripeSettingsHolder(BasePaymentProvider):
|
||||
help_text=_('Needs to be enabled in your Stripe account first.'),
|
||||
required=False,
|
||||
)),
|
||||
('method_sepa_debit',
|
||||
forms.BooleanField(
|
||||
label=_('SEPA Direct Debit'),
|
||||
disabled=self.event.currency != 'EUR',
|
||||
help_text=(
|
||||
_('Needs to be enabled in your Stripe account first.') +
|
||||
'<div class="alert alert-warning">%s</div>' % _(
|
||||
'SEPA Direct Debit payments via Stripe are <strong>not</strong> processed '
|
||||
'instantly but might take up to <strong>14 days</strong> to be confirmed in some cases. '
|
||||
'Please only activate this payment method if your payment term allows for this lag.'
|
||||
)),
|
||||
required=False,
|
||||
)),
|
||||
('sepa_creditor_name',
|
||||
forms.CharField(
|
||||
label=_('SEPA Creditor Mandate Name'),
|
||||
disabled=self.event.currency != 'EUR',
|
||||
help_text=_('Please provide your SEPA Creditor Mandate Name, that will be displayed to the user.'),
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'data-display-dependency': '#id_payment_stripe_method_sepa_debit',
|
||||
'data-required-if': '#id_payment_stripe_method_sepa_debit'
|
||||
}
|
||||
),
|
||||
)),
|
||||
('method_sofort',
|
||||
forms.BooleanField(
|
||||
label=_('SOFORT'),
|
||||
@@ -757,44 +852,17 @@ class StripeMethod(BasePaymentProvider):
|
||||
le.save(update_fields=['data', 'shredded'])
|
||||
|
||||
|
||||
class StripeCC(StripeMethod):
|
||||
identifier = 'stripe'
|
||||
verbose_name = _('Credit card via Stripe')
|
||||
public_name = _('Credit card')
|
||||
method = 'cc'
|
||||
|
||||
@property
|
||||
def walletqueries(self):
|
||||
# ToDo: Check against Stripe API, if ApplePay and GooglePay are even activated/available
|
||||
# This is probably only really feasable once the Payment Methods Configuration API is out of beta
|
||||
# https://stripe.com/docs/connect/payment-method-configurations
|
||||
if self.settings.get("walletdetection", True, as_type=bool):
|
||||
return [WalletQueries.APPLEPAY, WalletQueries.GOOGLEPAY]
|
||||
return []
|
||||
|
||||
def payment_form_render(self, request, total) -> str:
|
||||
account = get_stripe_account_key(self)
|
||||
if not RegisteredApplePayDomain.objects.filter(account=account, domain=request.host).exists():
|
||||
stripe_verify_domain.apply_async(args=(self.event.pk, request.host))
|
||||
|
||||
template = get_template('pretixplugins/stripe/checkout_payment_form_cc.html')
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'total': self._decimal_to_int(total),
|
||||
'settings': self.settings,
|
||||
'is_moto': self.is_moto(request)
|
||||
}
|
||||
return template.render(ctx)
|
||||
class StripePaymentIntentMethod(StripeMethod):
|
||||
identifier = ''
|
||||
method = ''
|
||||
|
||||
def payment_is_valid_session(self, request):
|
||||
return request.session.get('payment_stripe_payment_method_id', '') != ''
|
||||
return request.session.get('payment_stripe_{}_payment_method_id'.format(self.method), '') != ''
|
||||
|
||||
def checkout_prepare(self, request, cart):
|
||||
payment_method_id = request.POST.get('stripe_payment_method_id', '')
|
||||
request.session['payment_stripe_payment_method_id'] = payment_method_id
|
||||
request.session['payment_stripe_brand'] = request.POST.get('stripe_card_brand', '')
|
||||
request.session['payment_stripe_last4'] = request.POST.get('stripe_card_last4', '')
|
||||
payment_method_id = request.POST.get('stripe_{}_payment_method_id'.format(self.method), '')
|
||||
request.session['payment_stripe_{}_payment_method_id'.format(self.method)] = payment_method_id
|
||||
|
||||
if payment_method_id == '':
|
||||
messages.warning(request, _('You may need to enable JavaScript for Stripe payments.'))
|
||||
return False
|
||||
@@ -804,21 +872,13 @@ class StripeCC(StripeMethod):
|
||||
try:
|
||||
return self._handle_payment_intent(request, payment)
|
||||
finally:
|
||||
del request.session['payment_stripe_payment_method_id']
|
||||
del request.session['payment_stripe_{}_payment_method_id'.format(self.method)]
|
||||
|
||||
def is_moto(self, request, payment=None) -> bool:
|
||||
# We don't have a payment yet when checking if we should display the MOTO-flag
|
||||
# However, before we execute the payment, we absolutely have to check if the request-SalesChannel as well as the
|
||||
# order are tagged as a reseller-transaction. Else, a user with a valid reseller-session might be able to place
|
||||
# a MOTO transaction trough the WebShop.
|
||||
return False
|
||||
|
||||
moto = self.settings.get('reseller_moto', False, as_type=bool) and \
|
||||
request.sales_channel.identifier == 'resellers'
|
||||
|
||||
if payment:
|
||||
return moto and payment.order.sales_channel == 'resellers'
|
||||
|
||||
return moto
|
||||
def _payment_intent_kwargs(self, request, payment):
|
||||
return {}
|
||||
|
||||
def _handle_payment_intent(self, request, payment, intent=None):
|
||||
self._init_api()
|
||||
@@ -828,6 +888,7 @@ class StripeCC(StripeMethod):
|
||||
params = {}
|
||||
params.update(self._connect_kwargs(payment))
|
||||
params.update(self.api_kwargs)
|
||||
params.update(self._payment_intent_kwargs(request, payment))
|
||||
|
||||
if self.is_moto(request, payment):
|
||||
params.update({
|
||||
@@ -841,7 +902,8 @@ class StripeCC(StripeMethod):
|
||||
intent = stripe.PaymentIntent.create(
|
||||
amount=self._get_amount(payment),
|
||||
currency=self.event.currency.lower(),
|
||||
payment_method=request.session['payment_stripe_payment_method_id'],
|
||||
payment_method=request.session['payment_stripe_{}_payment_method_id'.format(self.method)],
|
||||
payment_method_types=[self.method],
|
||||
confirmation_method='manual',
|
||||
confirm=True,
|
||||
description='{event}-{code}'.format(
|
||||
@@ -855,7 +917,7 @@ class StripeCC(StripeMethod):
|
||||
'code': payment.order.code
|
||||
},
|
||||
# TODO: Is this sufficient?
|
||||
idempotency_key=str(self.event.id) + payment.order.code + request.session['payment_stripe_payment_method_id'],
|
||||
idempotency_key=str(self.event.id) + payment.order.code + request.session['payment_stripe_{}_payment_method_id'.format(self.method)],
|
||||
return_url=build_absolute_uri(self.event, 'plugins:stripe:sca.return', kwargs={
|
||||
'order': payment.order.code,
|
||||
'payment': payment.pk,
|
||||
@@ -1002,6 +1064,78 @@ class StripeCC(StripeMethod):
|
||||
raise PaymentException(_('We had trouble communicating with Stripe. Please try again and get in touch '
|
||||
'with us if this problem persists.'))
|
||||
|
||||
|
||||
class StripeCC(StripePaymentIntentMethod):
|
||||
identifier = 'stripe'
|
||||
verbose_name = _('Credit card via Stripe')
|
||||
public_name = _('Credit card')
|
||||
method = 'card'
|
||||
|
||||
@property
|
||||
def walletqueries(self):
|
||||
# ToDo: Check against Stripe API, if ApplePay and GooglePay are even activated/available
|
||||
# This is probably only really feasable once the Payment Methods Configuration API is out of beta
|
||||
# https://stripe.com/docs/connect/payment-method-configurations
|
||||
if self.settings.get("walletdetection", True, as_type=bool):
|
||||
return [WalletQueries.APPLEPAY, WalletQueries.GOOGLEPAY]
|
||||
return []
|
||||
|
||||
def payment_form_render(self, request, total, order=None) -> str:
|
||||
account = get_stripe_account_key(self)
|
||||
if not RegisteredApplePayDomain.objects.filter(account=account, domain=request.host).exists():
|
||||
stripe_verify_domain.apply_async(args=(self.event.pk, request.host))
|
||||
|
||||
template = get_template('pretixplugins/stripe/checkout_payment_form_card.html')
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'total': self._decimal_to_int(total),
|
||||
'settings': self.settings,
|
||||
'is_moto': self.is_moto(request)
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
def _migrate_session(self, request):
|
||||
# todo: remove after pretix 2023.8 was released
|
||||
keymap = {
|
||||
'payment_stripe_payment_method_id': 'payment_stripe_card_payment_method_id',
|
||||
'payment_stripe_brand': 'payment_stripe_card_brand',
|
||||
'payment_stripe_last4': 'payment_stripe_card_last4',
|
||||
}
|
||||
for old, new in keymap.items():
|
||||
if old in request.session:
|
||||
request.session[new] = request.session[old]
|
||||
del request.session[old]
|
||||
|
||||
def checkout_prepare(self, request, cart):
|
||||
self._migrate_session(request)
|
||||
request.session['payment_stripe_card_brand'] = request.POST.get('stripe_card_brand', '')
|
||||
request.session['payment_stripe_card_last4'] = request.POST.get('stripe_card_last4', '')
|
||||
|
||||
return super().checkout_prepare(request, cart)
|
||||
|
||||
def payment_is_valid_session(self, request):
|
||||
self._migrate_session(request)
|
||||
return super().payment_is_valid_session(request)
|
||||
|
||||
def _handle_payment_intent(self, request, payment, intent=None):
|
||||
self._migrate_session(request)
|
||||
return super()._handle_payment_intent(request, payment, intent)
|
||||
|
||||
def is_moto(self, request, payment=None) -> bool:
|
||||
# We don't have a payment yet when checking if we should display the MOTO-flag
|
||||
# However, before we execute the payment, we absolutely have to check if the request-SalesChannel as well as the
|
||||
# order are tagged as a reseller-transaction. Else, a user with a valid reseller-session might be able to place
|
||||
# a MOTO transaction trough the WebShop.
|
||||
|
||||
moto = self.settings.get('reseller_moto', False, as_type=bool) and \
|
||||
request.sales_channel.identifier == 'resellers'
|
||||
|
||||
if payment:
|
||||
return moto and payment.order.sales_channel == 'resellers'
|
||||
|
||||
return moto
|
||||
|
||||
def payment_presale_render(self, payment: OrderPayment) -> str:
|
||||
pi = payment.info_data or {}
|
||||
try:
|
||||
@@ -1018,6 +1152,131 @@ class StripeCC(StripeMethod):
|
||||
f'{_("expires {month}/{year}").format(month=card.get("exp_month"), year=card.get("exp_year"))}'
|
||||
|
||||
|
||||
class StripeSEPADirectDebit(StripePaymentIntentMethod):
|
||||
identifier = 'stripe_sepa_debit'
|
||||
verbose_name = _('SEPA Debit via Stripe')
|
||||
public_name = _('SEPA Debit')
|
||||
method = 'sepa_debit'
|
||||
ia = InvoiceAddress()
|
||||
|
||||
def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order=None) -> str:
|
||||
def get_invoice_address():
|
||||
if order and getattr(order, 'invoice_address', None):
|
||||
request._checkout_flow_invoice_address = order.invoice_address
|
||||
if not hasattr(request, '_checkout_flow_invoice_address'):
|
||||
cs = cart_session(request)
|
||||
iapk = cs.get('invoice_address')
|
||||
if not iapk:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
else:
|
||||
try:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
|
||||
except InvoiceAddress.DoesNotExist:
|
||||
request._checkout_flow_invoice_address = InvoiceAddress()
|
||||
return request._checkout_flow_invoice_address
|
||||
|
||||
cs = cart_session(request)
|
||||
self.ia = get_invoice_address()
|
||||
|
||||
template = get_template('pretixplugins/stripe/checkout_payment_form_sepadirectdebit.html')
|
||||
ctx = {
|
||||
'request': request,
|
||||
'event': self.event,
|
||||
'settings': self.settings,
|
||||
'form': self.payment_form(request),
|
||||
'email': order.email if order else cs.get('email', '')
|
||||
}
|
||||
return template.render(ctx)
|
||||
|
||||
@property
|
||||
def payment_form_fields(self):
|
||||
return OrderedDict(
|
||||
[
|
||||
('accountname',
|
||||
forms.CharField(
|
||||
label=_('Account Holder Name'),
|
||||
initial=self.ia.name,
|
||||
)),
|
||||
('line1',
|
||||
forms.CharField(
|
||||
label=_('Account Holder Street'),
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'data-display-dependency': '#stripe_sepa_debit_country',
|
||||
'data-required-if': '#stripe_sepa_debit_country'
|
||||
}
|
||||
),
|
||||
initial=self.ia.street,
|
||||
)),
|
||||
('postal_code',
|
||||
forms.CharField(
|
||||
label=_('Account Holder Postal Code'),
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'data-display-dependency': '#stripe_sepa_debit_country',
|
||||
'data-required-if': '#stripe_sepa_debit_country'
|
||||
}
|
||||
),
|
||||
initial=self.ia.zipcode,
|
||||
)),
|
||||
('city',
|
||||
forms.CharField(
|
||||
label=_('Account Holder City'),
|
||||
required=False,
|
||||
widget=forms.TextInput(
|
||||
attrs={
|
||||
'data-display-dependency': '#stripe_sepa_debit_country',
|
||||
'data-required-if': '#stripe_sepa_debit_country'
|
||||
}
|
||||
),
|
||||
initial=self.ia.city,
|
||||
)),
|
||||
('country',
|
||||
forms.ChoiceField(
|
||||
label=_('Account Holder Country'),
|
||||
required=False,
|
||||
choices=CachedCountries(),
|
||||
widget=forms.Select(
|
||||
attrs={
|
||||
'data-display-dependency': '#stripe_sepa_debit_country',
|
||||
'data-required-if': '#stripe_sepa_debit_country'
|
||||
}
|
||||
),
|
||||
initial=self.ia.country or guess_country(self.event),
|
||||
)),
|
||||
])
|
||||
|
||||
def _payment_intent_kwargs(self, request, payment):
|
||||
return {
|
||||
'mandate_data': {
|
||||
'customer_acceptance': {
|
||||
'type': 'online',
|
||||
'online': {
|
||||
'ip_address': get_client_ip(request),
|
||||
'user_agent': request.META['HTTP_USER_AGENT'],
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
def checkout_prepare(self, request, cart):
|
||||
request.session['payment_stripe_sepa_debit_last4'] = request.POST.get('stripe_sepa_debit_last4', '')
|
||||
request.session['payment_stripe_sepa_debit_bank'] = request.POST.get('stripe_sepa_debit_bank', '')
|
||||
|
||||
return super().checkout_prepare(request, cart)
|
||||
|
||||
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
|
||||
try:
|
||||
super().execute_payment(request, payment)
|
||||
finally:
|
||||
fields = ['accountname', 'line1', 'postal_code', 'city', 'country']
|
||||
for field in fields:
|
||||
if 'payment_stripe_sepa_debit_{}'.format(field) in request.session:
|
||||
del request.session['payment_stripe_sepa_debit_{}'.format(field)]
|
||||
|
||||
|
||||
class StripeGiropay(StripeMethod):
|
||||
identifier = 'stripe_giropay'
|
||||
verbose_name = _('giropay via Stripe')
|
||||
|
||||
Reference in New Issue
Block a user