mirror of
https://github.com/pretix/pretix.git
synced 2026-05-05 15:14:04 +00:00
* Vendor vue.js * Refactor item_group_by_category to support vouchers * Widget: Show product list * Widget: free prices * Widget: pictures and loading indicator * Widget: First iframe steps * Widget: Do not rerender iframe * Widget: Error handling * Improve widget * Widget: localization tech * Fix invoice style * Voucher attribute and waiting list * Add some iframe chrome * First step to namespaced carts * More isolation steps * More cart isolation things * More cart isolation things * Mobile stuff * Show cart on checkout pages * PayPal and Stripe support * Enable downloads * Locale handling * change text "save URL to this exact page" * Widget: voucher redemption * Widget: CSS * CSS: Responsive * Widget: CSS improvements * Widget: Add embedding code generator * Widget: Error messages and SSL check * First tests * Widget: tests * Don't use IDs in widgets * Widget: static files caching
367 lines
15 KiB
Python
367 lines
15 KiB
Python
import json
|
|
import logging
|
|
import urllib.parse
|
|
from collections import OrderedDict
|
|
|
|
import paypalrestsdk
|
|
from django import forms
|
|
from django.contrib import messages
|
|
from django.core import signing
|
|
from django.template.loader import get_template
|
|
from django.utils.translation import ugettext as __, ugettext_lazy as _
|
|
|
|
from pretix.base.models import Order, Quota, RequiredAction
|
|
from pretix.base.payment import BasePaymentProvider, PaymentException
|
|
from pretix.base.services.mail import SendMailException
|
|
from pretix.base.services.orders import mark_order_paid, mark_order_refunded
|
|
from pretix.helpers.urls import build_absolute_uri as build_global_uri
|
|
from pretix.multidomain.urlreverse import build_absolute_uri
|
|
from pretix.plugins.paypal.models import ReferencedPayPalObject
|
|
|
|
logger = logging.getLogger('pretix.plugins.paypal')
|
|
|
|
|
|
class RefundForm(forms.Form):
|
|
auto_refund = forms.ChoiceField(
|
|
initial='auto',
|
|
label=_('Refund automatically?'),
|
|
choices=(
|
|
('auto', _('Automatically refund charge with PayPal')),
|
|
('manual', _('Do not send refund instruction to PayPal, only mark as refunded in pretix'))
|
|
),
|
|
widget=forms.RadioSelect,
|
|
)
|
|
|
|
|
|
class Paypal(BasePaymentProvider):
|
|
identifier = 'paypal'
|
|
verbose_name = _('PayPal')
|
|
payment_form_fields = OrderedDict([
|
|
])
|
|
|
|
@property
|
|
def settings_form_fields(self):
|
|
return OrderedDict(
|
|
list(super().settings_form_fields.items()) + [
|
|
('endpoint',
|
|
forms.ChoiceField(
|
|
label=_('Endpoint'),
|
|
initial='live',
|
|
choices=(
|
|
('live', 'Live'),
|
|
('sandbox', 'Sandbox'),
|
|
),
|
|
)),
|
|
('client_id',
|
|
forms.CharField(
|
|
label=_('Client ID'),
|
|
help_text=_('<a target="_blank" href="{docs_url}">{text}</a>').format(
|
|
text=_('Click here for a tutorial on how to obtain the required keys'),
|
|
docs_url='https://docs.pretix.eu/en/latest/user/payments/paypal.html'
|
|
)
|
|
)),
|
|
('secret',
|
|
forms.CharField(
|
|
label=_('Secret'),
|
|
))
|
|
]
|
|
)
|
|
|
|
def settings_content_render(self, request):
|
|
return "<div class='alert alert-info'>%s<br /><code>%s</code></div>" % (
|
|
_('Please configure a PayPal Webhook to the following endpoint in order to automatically cancel orders '
|
|
'when payments are refunded externally.'),
|
|
build_global_uri('plugins:paypal:webhook')
|
|
)
|
|
|
|
def init_api(self):
|
|
paypalrestsdk.set_config(
|
|
mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live',
|
|
client_id=self.settings.get('client_id'),
|
|
client_secret=self.settings.get('secret'))
|
|
|
|
def payment_is_valid_session(self, request):
|
|
return (request.session.get('payment_paypal_id', '') != ''
|
|
and request.session.get('payment_paypal_payer', '') != '')
|
|
|
|
def payment_form_render(self, request) -> str:
|
|
template = get_template('pretixplugins/paypal/checkout_payment_form.html')
|
|
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
|
|
return template.render(ctx)
|
|
|
|
def checkout_prepare(self, request, cart):
|
|
self.init_api()
|
|
kwargs = {}
|
|
if request.resolver_match and 'cart_namespace' in request.resolver_match.kwargs:
|
|
kwargs['cart_namespace'] = request.resolver_match.kwargs['cart_namespace']
|
|
|
|
payment = paypalrestsdk.Payment({
|
|
'intent': 'sale',
|
|
'payer': {
|
|
"payment_method": "paypal",
|
|
},
|
|
"redirect_urls": {
|
|
"return_url": build_absolute_uri(request.event, 'plugins:paypal:return', kwargs=kwargs),
|
|
"cancel_url": build_absolute_uri(request.event, 'plugins:paypal:abort', kwargs=kwargs),
|
|
},
|
|
"transactions": [
|
|
{
|
|
"item_list": {
|
|
"items": [
|
|
{
|
|
"name": __('Order for %s') % str(request.event),
|
|
"quantity": 1,
|
|
"price": str(cart['total']),
|
|
"currency": request.event.currency
|
|
}
|
|
]
|
|
},
|
|
"amount": {
|
|
"currency": request.event.currency,
|
|
"total": str(cart['total'])
|
|
},
|
|
"description": __('Event tickets for {event}').format(event=request.event.name)
|
|
}
|
|
]
|
|
})
|
|
request.session['payment_paypal_order'] = None
|
|
return self._create_payment(request, payment)
|
|
|
|
def _create_payment(self, request, payment):
|
|
try:
|
|
if payment.create():
|
|
if payment.state not in ('created', 'approved', 'pending'):
|
|
messages.error(request, _('We had trouble communicating with PayPal'))
|
|
logger.error('Invalid payment state: ' + str(payment))
|
|
return
|
|
request.session['payment_paypal_id'] = payment.id
|
|
for link in payment.links:
|
|
if link.method == "REDIRECT" and link.rel == "approval_url":
|
|
if request.session.get('iframe_session', False):
|
|
signer = signing.Signer(salt='safe-redirect')
|
|
return (
|
|
build_absolute_uri(request.event, 'plugins:paypal:redirect') + '?url=' +
|
|
urllib.parse.quote(signer.sign(link.href))
|
|
)
|
|
else:
|
|
return str(link.href)
|
|
else:
|
|
messages.error(request, _('We had trouble communicating with PayPal'))
|
|
logger.error('Error on creating payment: ' + str(payment.error))
|
|
except Exception as e:
|
|
messages.error(request, _('We had trouble communicating with PayPal'))
|
|
logger.error('Error on creating payment: ' + str(e))
|
|
|
|
def checkout_confirm_render(self, request) -> str:
|
|
"""
|
|
Returns the HTML that should be displayed when the user selected this provider
|
|
on the 'confirm order' page.
|
|
"""
|
|
template = get_template('pretixplugins/paypal/checkout_payment_confirm.html')
|
|
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
|
|
return template.render(ctx)
|
|
|
|
def payment_perform(self, request, order) -> str:
|
|
"""
|
|
Will be called if the user submitted his order successfully to initiate the
|
|
payment process.
|
|
|
|
It should return a custom redirct URL, if you need special behaviour, or None to
|
|
continue with default behaviour.
|
|
|
|
On errors, it should use Django's message framework to display an error message
|
|
to the user (or the normal form validation error messages).
|
|
|
|
:param order: The order object
|
|
"""
|
|
if (request.session.get('payment_paypal_id', '') == ''
|
|
or request.session.get('payment_paypal_payer', '') == ''):
|
|
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
|
'proceed.'))
|
|
|
|
self.init_api()
|
|
payment = paypalrestsdk.Payment.find(request.session.get('payment_paypal_id'))
|
|
ReferencedPayPalObject.objects.get_or_create(order=order, reference=payment.id)
|
|
if str(payment.transactions[0].amount.total) != str(order.total) or payment.transactions[0].amount.currency != \
|
|
self.event.currency:
|
|
logger.error('Value mismatch: Order %s vs payment %s' % (order.id, str(payment)))
|
|
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
|
'proceed.'))
|
|
|
|
return self._execute_payment(payment, request, order)
|
|
|
|
def _execute_payment(self, payment, request, order):
|
|
if payment.state == 'created':
|
|
payment.replace([
|
|
{
|
|
"op": "replace",
|
|
"path": "/transactions/0/item_list",
|
|
"value": {
|
|
"items": [
|
|
{
|
|
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
|
|
"quantity": 1,
|
|
"price": str(order.total),
|
|
"currency": order.event.currency
|
|
}
|
|
]
|
|
}
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/transactions/0/description",
|
|
"value": __('Order {order} for {event}').format(
|
|
event=request.event.name,
|
|
order=order.code
|
|
)
|
|
}
|
|
])
|
|
payment.execute({"payer_id": request.session.get('payment_paypal_payer')})
|
|
|
|
order.refresh_from_db()
|
|
if payment.state == 'pending':
|
|
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as soon as the '
|
|
'payment completed.'))
|
|
order.payment_info = json.dumps(payment.to_dict())
|
|
order.save()
|
|
return
|
|
|
|
if payment.state != 'approved':
|
|
logger.error('Invalid state: %s' % str(payment))
|
|
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
|
'proceed.'))
|
|
return
|
|
|
|
if order.status == Order.STATUS_PAID:
|
|
logger.warning('PayPal success event even though order is already marked as paid')
|
|
return
|
|
|
|
try:
|
|
mark_order_paid(order, 'paypal', json.dumps(payment.to_dict()))
|
|
except Quota.QuotaExceededException as e:
|
|
RequiredAction.objects.create(
|
|
event=request.event, action_type='pretix.plugins.paypal.overpaid', data=json.dumps({
|
|
'order': order.code,
|
|
'payment': payment.id
|
|
})
|
|
)
|
|
raise PaymentException(str(e))
|
|
|
|
except SendMailException:
|
|
messages.warning(request, _('There was an error sending the confirmation mail.'))
|
|
return None
|
|
|
|
def order_pending_render(self, request, order) -> str:
|
|
retry = True
|
|
try:
|
|
if order.payment_info and json.loads(order.payment_info)['state'] == 'pending':
|
|
retry = False
|
|
except KeyError:
|
|
pass
|
|
template = get_template('pretixplugins/paypal/pending.html')
|
|
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
|
|
'retry': retry, 'order': order}
|
|
return template.render(ctx)
|
|
|
|
def order_control_render(self, request, order) -> str:
|
|
if order.payment_info:
|
|
payment_info = json.loads(order.payment_info)
|
|
else:
|
|
payment_info = None
|
|
template = get_template('pretixplugins/paypal/control.html')
|
|
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
|
|
'payment_info': payment_info, 'order': order}
|
|
return template.render(ctx)
|
|
|
|
def _refund_form(self, request):
|
|
return RefundForm(data=request.POST if request.method == "POST" else None)
|
|
|
|
def order_control_refund_render(self, order, request) -> str:
|
|
template = get_template('pretixplugins/paypal/control_refund.html')
|
|
ctx = {
|
|
'request': request,
|
|
'form': self._refund_form(request),
|
|
}
|
|
return template.render(ctx)
|
|
|
|
def order_control_refund_perform(self, request, order) -> "bool|str":
|
|
f = self._refund_form(request)
|
|
if not f.is_valid():
|
|
messages.error(request, _('Your input was invalid, please try again.'))
|
|
return
|
|
elif f.cleaned_data.get('auto_refund') == 'manual':
|
|
order = mark_order_refunded(order, user=request.user)
|
|
order.payment_manual = True
|
|
order.save()
|
|
return
|
|
|
|
self.init_api()
|
|
|
|
if order.payment_info:
|
|
payment_info = json.loads(order.payment_info)
|
|
else:
|
|
payment_info = None
|
|
|
|
if not payment_info:
|
|
mark_order_refunded(order, user=request.user)
|
|
messages.warning(request, _('We were unable to transfer the money back automatically. '
|
|
'Please get in touch with the customer and transfer it back manually.'))
|
|
return
|
|
|
|
for res in payment_info['transactions'][0]['related_resources']:
|
|
for k, v in res.items():
|
|
if k == 'sale':
|
|
sale = paypalrestsdk.Sale.find(v['id'])
|
|
break
|
|
|
|
refund = sale.refund({})
|
|
if not refund.success():
|
|
mark_order_refunded(order, user=request.user)
|
|
messages.warning(request, _('We were unable to transfer the money back automatically. '
|
|
'Please get in touch with the customer and transfer it back manually.'))
|
|
else:
|
|
sale = paypalrestsdk.Payment.find(payment_info['id'])
|
|
order = mark_order_refunded(order, user=request.user)
|
|
order.payment_info = json.dumps(sale.to_dict())
|
|
order.save()
|
|
|
|
def order_can_retry(self, order):
|
|
return self._is_still_available(order=order)
|
|
|
|
def order_prepare(self, request, order):
|
|
self.init_api()
|
|
payment = paypalrestsdk.Payment({
|
|
'intent': 'sale',
|
|
'payer': {
|
|
"payment_method": "paypal",
|
|
},
|
|
"redirect_urls": {
|
|
"return_url": build_absolute_uri(request.event, 'plugins:paypal:return'),
|
|
"cancel_url": build_absolute_uri(request.event, 'plugins:paypal:abort'),
|
|
},
|
|
"transactions": [
|
|
{
|
|
"item_list": {
|
|
"items": [
|
|
{
|
|
"name": __('Order {slug}-{code}').format(slug=self.event.slug.upper(), code=order.code),
|
|
"quantity": 1,
|
|
"price": str(order.total),
|
|
"currency": order.event.currency
|
|
}
|
|
]
|
|
},
|
|
"amount": {
|
|
"currency": request.event.currency,
|
|
"total": str(order.total)
|
|
},
|
|
"description": __('Order {order} for {event}').format(
|
|
event=request.event.name,
|
|
order=order.code
|
|
)
|
|
}
|
|
]
|
|
})
|
|
request.session['payment_paypal_order'] = order.pk
|
|
return self._create_payment(request, payment)
|