Compare commits

..

16 Commits

Author SHA1 Message Date
Richard Schreiber
873de52ff6 remove async-await 2022-06-01 15:05:01 +02:00
Martin Gross
ea6c698b3a PPv2: Call get_fees without explicit payment provider. 2022-06-01 12:20:28 +02:00
Martin Gross
d2d6a30623 PPv2: minor XHR/get_fees cleanup 2022-06-01 12:12:49 +02:00
Martin Gross
68097291ca PPv2: Include other fees than payment fees into the XHR-calculation 2022-06-01 12:10:58 +02:00
Martin Gross
a8286f77d8 PPv2: Fix fee calculation if no payment fee is present 2022-06-01 10:58:02 +02:00
Martin Gross
d8e96c16bb Add t.paypal.com to img-src CSP 2022-06-01 10:07:55 +02:00
Martin Gross
e20c2c56f0 PPv2: Surface error-messages if XHR-call fails 2022-05-31 19:23:57 +02:00
Martin Gross
823de60e8c PPv2: Make XHR view a proper view and not a TemplateView 2022-05-31 19:02:55 +02:00
Raphael Michel
25fb5fb741 Fix inconsistent translation 2022-05-31 16:48:56 +02:00
Martin Gross
017638cc29 PPv2: Only transmit the user's main language without any possible "-informal"-tags 2022-05-31 16:15:29 +02:00
Martin Gross
4e37acf8d4 PPv2: Do not run capture if PPOrder has not been approved by user. 2022-05-31 12:01:30 +02:00
Martin Gross
40d273e145 PayPal v2: Control-view: Show Capture ID instead of Order ID 2022-05-30 17:07:52 +02:00
Raphael Michel
88f4ee0f95 Event timeline: Always show effective end of sale 2022-05-30 16:47:25 +02:00
Raphael Michel
925b8334a9 PayPal: Migrate to Order v2 API and ISU authentication (#2493) (#2614)
Co-authored-by: Raphael Michel <michel@rami.io>
Co-authored-by: Martin Gross <gross@rami.io>
2022-05-30 15:44:22 +02:00
Raphael Michel
2e0be8c801 Allow to filter subevents by sales channel 2022-05-27 18:17:56 +02:00
Raphael Michel
6306b8e97d Bump to 4.11.0.dev0 2022-05-27 17:16:40 +02:00
49 changed files with 3516 additions and 258 deletions

View File

@@ -611,12 +611,8 @@ Order position endpoints
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
accepts a number of optional requests in the body.
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter. In this case, you should
always set ``untrusted_input=true`` as a query parameter to avoid security issues.
**Tip:** Instead of an ID, you can also use the ``secret`` field as the lookup parameter.
:query boolean untrusted_input: If set to true, the lookup parameter is **always** interpreted as a ``secret``, never
as an ``id``. This should be always set if you are passing through untrusted, scanned
data to avoid guessing of ticket IDs.
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults

View File

@@ -474,6 +474,7 @@ Endpoints
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:query sales_channel: If set to a sales channel identifier, the response will only contain subevents from events available on this sales channel.
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.10.1"
__version__ = "4.11.0.dev0"

View File

@@ -409,11 +409,6 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce')
untrusted_input = (
self.request.GET.get('untrusted_input', '') not in ('0', 'false', 'False', '')
or (isinstance(self.request.auth, Device) and 'pretixscan' in (self.request.auth.software_brand or '').lower())
)
if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
else:
@@ -434,7 +429,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
if self.kwargs['pk'].isnumeric() and not untrusted_input:
if self.kwargs['pk'].isnumeric():
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.

View File

@@ -321,6 +321,7 @@ with scopes_disabled():
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
sales_channel = django_filters.rest_framework.CharFilter(method='sales_channel_qs')
class Meta:
model = SubEvent
@@ -353,6 +354,9 @@ with scopes_disabled():
else:
return queryset.exclude(expr)
def sales_channel_qs(self, queryset, name, value):
return queryset.filter(event__sales_channels__contains=value)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = SubEventSerializer

View File

@@ -1226,7 +1226,7 @@ class Event(EventMixin, LoggedModel):
self.set_active_plugins(plugins_active)
plugins_available = self.get_available_plugins()
if hasattr(plugins_available[module].app, 'uninstalled'):
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self)
regenerate_css.apply_async(args=(self.pk,))

View File

@@ -84,13 +84,17 @@ def timeline_for_event(event, subevent=None):
edit_url=ev_edit_url
))
if ev.presale_end:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=ev.presale_end,
description=pgettext_lazy('timeline', 'End of ticket sales'),
edit_url=ev_edit_url
))
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=(
ev.presale_end or ev.date_to or ev.date_from.astimezone(ev.timezone).replace(hour=23, minute=59, second=59)
),
description='{}{}'.format(
pgettext_lazy('timeline', 'End of ticket sales'),
f" ({pgettext_lazy('timeline', 'automatically because the event is over and no end of presale has been configured')})" if not ev.presale_end else ""
),
edit_url=ev_edit_url
))
rd = event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
if rd:

View File

@@ -25768,7 +25768,7 @@ msgstr "minimale Bestellmenge: %(num)s"
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html:62
msgctxt "price"
msgid "free"
msgstr "kostenlos"
msgstr "gratis"
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:76
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html:51

View File

@@ -25718,7 +25718,7 @@ msgstr "Minimale Bestellmenge: %(num)s"
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html:62
msgctxt "price"
msgid "free"
msgstr "kostenlos"
msgstr "gratis"
#: pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html:76
#: pretix/presale/templates/pretixpresale/event/fragment_product_list.html:51

View File

@@ -56,6 +56,9 @@ class PaypalApp(AppConfig):
def ready(self):
from . import signals # NOQA
def is_available(self, event):
return 'pretix.plugins.paypal' in event.plugins.split(',')
@cached_property
def compatibility_errors(self):
errs = []

View File

@@ -0,0 +1,52 @@
# Generated by Django 3.2.13 on 2022-05-25 08:39
from django.db import migrations
from django.db.models import Q
def migrate_to_v2(app, schema_editor):
GlobalSettingsObject_SettingsStore = app.get_model('pretixbase', 'GlobalSettingsObject_SettingsStore')
EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
Event = app.get_model('pretixbase', 'Event')
# If there are no system-wide OAuth keys set, per-event API keys are used, and we can migrate all events
if not GlobalSettingsObject_SettingsStore.objects.filter(
Q(key__in=['payment_paypal_connect_client_id', 'payment_paypal_connect_secret_key'])
& (Q(value__isnull=True) | ~Q(value=""))
).exists():
for ev in Event.objects.filter(plugins__contains='pretix.plugins.paypal'):
switch_paypal_version(ev)
# There are system-wide OAuth keys set - so we need to check each event individually
else:
# Only look at events that have the PayPal plugin enabled
for ev in Event.objects.filter(plugins__contains='pretix.plugins.paypal'):
# If the payment method is enabled, we don't do anything
if EventSettingsStore.objects.filter(object_id=ev.pk, key='payment_paypal__enabled', value="True").exists():
pass
# In all other cases, the payment method is either disabled or hasn't been set up - in this case we'll
# migrate the event to v2
else:
switch_paypal_version(ev)
def switch_paypal_version(event):
plugins_active = event.plugins.split(',')
if 'pretix.plugins.paypal' in plugins_active:
plugins_active.remove('pretix.plugins.paypal')
plugins_active.append('pretix.plugins.paypal2')
event.plugins = ','.join(plugins_active)
event.save(update_fields=['plugins'])
class Migration(migrations.Migration):
dependencies = [
('paypal', '0002_referencedpaypalobject_payment'),
]
operations = [
migrations.RunPython(migrate_to_v2, migrations.RunPython.noop)
]

View File

@@ -57,7 +57,6 @@ from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.services.mail import SendMailException
from pretix.base.settings import SettingsSandbox
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
@@ -171,16 +170,10 @@ class Paypal(BasePaymentProvider):
if self.settings.connect_client_id and not self.settings.secret:
# Use PayPal connect
if not self.settings.connect_user_id:
settings_content = (
"<p>{}</p>"
"<a href='{}' class='btn btn-primary btn-lg'>{}</a>"
).format(
_('To accept payments via PayPal, you will need an account at PayPal. By clicking on the '
'following button, you can either create a new PayPal account connect pretix to an existing '
'one.'),
self.get_connect_url(request),
_('Connect with {icon} PayPal').format(icon='<i class="fa fa-paypal"></i>')
)
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
else:
settings_content = (
"<button formaction='{}' class='btn btn-danger'>{}</button>"
@@ -192,29 +185,10 @@ class Paypal(BasePaymentProvider):
_('Disconnect from PayPal')
)
else:
settings_content = "<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')
)
if self.event.currency not in SUPPORTED_CURRENCIES:
settings_content += (
'<br><br><div class="alert alert-warning">%s '
'<a href="https://developer.paypal.com/docs/api/reference/currency-codes/">%s</a>'
'</div>'
) % (
_("PayPal does not process payments in your event's currency."),
_("Please check this PayPal page for a complete list of supported currencies.")
)
if self.event.currency in LOCAL_ONLY_CURRENCIES:
settings_content += '<br><br><div class="alert alert-warning">%s''</div>' % (
_("Your event's currency is supported by PayPal as a payment and balance currency for in-country "
"accounts only. This means, that the receiving as well as the sending PayPal account must have been "
"created in the same country and use the same currency. Out of country accounts will not be able to "
"send any payments.")
)
# Migrate User to PayPal v2
self.event.disable_plugin("pretix.plugins.paypal")
self.event.enable_plugin("pretix.plugins.paypal2")
self.event.save()
return settings_content
@@ -228,8 +202,8 @@ class Paypal(BasePaymentProvider):
client_id=self.settings.connect_client_id,
client_secret=self.settings.connect_secret_key,
openid_client_id=self.settings.connect_client_id,
openid_client_secret=self.settings.connect_secret_key,
openid_redirect_uri=urllib.parse.quote(build_global_uri('plugins:paypal:oauth.return')))
openid_client_secret=self.settings.connect_secret_key
)
else:
paypalrestsdk.set_config(
mode="sandbox" if "sandbox" in self.settings.get('endpoint') else 'live',

View File

@@ -19,17 +19,10 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
from collections import OrderedDict
from django import forms
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from pretix.base.forms import SecretKeySettingsField
from pretix.base.signals import (
logentry_display, register_global_settings, register_payment_providers,
)
from pretix.base.signals import logentry_display, register_payment_providers
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
@@ -40,46 +33,6 @@ def register_payment_provider(sender, **kwargs):
@receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display")
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
if logentry.action_type != 'pretix.plugins.paypal.event':
return
from pretix.plugins.paypal2.signals import pretixcontrol_logentry_display
data = json.loads(logentry.data)
event_type = data.get('event_type')
text = None
plains = {
'PAYMENT.SALE.COMPLETED': _('Payment completed.'),
'PAYMENT.SALE.DENIED': _('Payment denied.'),
'PAYMENT.SALE.REFUNDED': _('Payment refunded.'),
'PAYMENT.SALE.REVERSED': _('Payment reversed.'),
'PAYMENT.SALE.PENDING': _('Payment pending.'),
}
if event_type in plains:
text = plains[event_type]
else:
text = event_type
if text:
return _('PayPal reported an event: {}').format(text)
@receiver(register_global_settings, dispatch_uid='paypal_global_settings')
def register_global_settings(sender, **kwargs):
return OrderedDict([
('payment_paypal_connect_client_id', forms.CharField(
label=_('PayPal Connect: Client ID'),
required=False,
)),
('payment_paypal_connect_secret_key', SecretKeySettingsField(
label=_('PayPal Connect: Secret key'),
required=False,
)),
('payment_paypal_connect_endpoint', forms.ChoiceField(
label=_('PayPal Connect Endpoint'),
initial='live',
choices=(
('live', 'Live'),
('sandbox', 'Sandbox'),
),
)),
])
return pretixcontrol_logentry_display(sender, logentry, **kwargs)

View File

@@ -21,11 +21,7 @@
#
from django.conf.urls import include, re_path
from pretix.multidomain import event_url
from .views import (
abort, oauth_disconnect, oauth_return, redirect_view, success, webhook,
)
from .views import abort, oauth_disconnect, redirect_view, success
event_patterns = [
re_path(r'^paypal/', include([
@@ -35,14 +31,10 @@ event_patterns = [
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/abort/', abort, name='abort'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/return/', success, name='return'),
event_url(r'^webhook/$', webhook, name='webhook', require_live=False),
])),
]
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal/disconnect/',
oauth_disconnect, name='oauth.disconnect'),
re_path(r'^_paypal/webhook/$', webhook, name='webhook'),
re_path(r'^_paypal/oauth_return/$', oauth_return, name='oauth.return'),
]

View File

@@ -42,16 +42,15 @@ from django.contrib import messages
from django.core import signing
from django.db.models import Sum
from django.http import HttpResponse, HttpResponseBadRequest
from django.shortcuts import get_object_or_404, redirect, render
from django.shortcuts import redirect, render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django_scopes import scopes_disabled
from paypalrestsdk.openid_connect import Tokeninfo
from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.models import Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import PaymentException
from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
@@ -76,38 +75,6 @@ def redirect_view(request, *args, **kwargs):
return r
@scopes_disabled()
def oauth_return(request, *args, **kwargs):
if 'payment_paypal_oauth_event' not in request.session:
messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
return redirect(reverse('control:index'))
event = get_object_or_404(Event, pk=request.session['payment_paypal_oauth_event'])
prov = Paypal(event)
prov.init_api()
try:
tokeninfo = Tokeninfo.create(request.GET.get('code'))
userinfo = Tokeninfo.create_with_refresh_token(tokeninfo['refresh_token']).userinfo()
except paypalrestsdk.exceptions.ConnectionError:
logger.exception('Failed to obtain OAuth token')
messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
else:
messages.success(request,
_('Your PayPal account is now connected to pretix. You can change the settings in '
'detail below.'))
event.settings.payment_paypal_connect_refresh_token = tokeninfo['refresh_token']
event.settings.payment_paypal_connect_user_id = userinfo.email
return redirect(reverse('control:event.settings.payment.provider', kwargs={
'organizer': event.organizer.slug,
'event': event.slug,
'provider': 'paypal'
}))
def success(request, *args, **kwargs):
pid = request.GET.get('paymentId')
token = request.GET.get('token')
@@ -286,8 +253,14 @@ def oauth_disconnect(request, **kwargs):
request.event.settings.payment_paypal__enabled = False
messages.success(request, _('Your PayPal account has been disconnected.'))
# Migrate User to PayPal v2
event = request.event
event.disable_plugin("pretix.plugins.paypal")
event.enable_plugin("pretix.plugins.paypal2")
event.save()
return redirect(reverse('control:event.settings.payment.provider', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'provider': 'paypal'
'provider': 'paypal_settings'
}))

View File

@@ -0,0 +1,21 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#

View File

@@ -0,0 +1,49 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class Paypal2App(AppConfig):
name = 'pretix.plugins.paypal2'
verbose_name = "PayPal"
class PretixPluginMeta:
name = "PayPal"
author = _("the pretix team")
version = version
category = 'PAYMENT'
featured = True
picture = 'pretixplugins/paypal2/paypal_logo.svg'
description = _("Accept payments with your PayPal account. In addition to regular PayPal payments, you can now "
"also offer payments in a variety of local payment methods such as giropay, SOFORT, iDEAL and "
"many more to your customers - they don't even need a PayPal account. PayPal is one of the "
"most popular payment methods world-wide.")
def ready(self):
from . import signals # NOQA
def is_available(self, event):
return 'pretix.plugins.paypal' not in event.plugins.split(',')

View File

@@ -0,0 +1,66 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import jwt
from paypalcheckoutsdk.core import PayPalEnvironment as VendorPayPalEnvironment
class PayPalEnvironment(VendorPayPalEnvironment):
def __init__(self, client_id, client_secret, api_url, web_url, merchant_id, partner_id):
super(PayPalEnvironment, self).__init__(client_id, client_secret, api_url, web_url)
self.merchant_id = merchant_id
self.partner_id = partner_id
def authorization_assertation(self):
if self.merchant_id:
return jwt.encode(
payload={
'iss': self.client_id,
'payer_id': self.merchant_id
},
key=None,
algorithm=None,
)
return ""
class SandboxEnvironment(PayPalEnvironment):
def __init__(self, client_id, client_secret, merchant_id=None, partner_id=None):
super(SandboxEnvironment, self).__init__(
client_id,
client_secret,
PayPalEnvironment.SANDBOX_API_URL,
PayPalEnvironment.SANDBOX_WEB_URL,
merchant_id,
partner_id
)
class LiveEnvironment(PayPalEnvironment):
def __init__(self, client_id, client_secret, merchant_id, partner_id):
super(LiveEnvironment, self).__init__(
client_id,
client_secret,
PayPalEnvironment.LIVE_API_URL,
PayPalEnvironment.LIVE_WEB_URL,
merchant_id,
partner_id
)

View File

@@ -0,0 +1,70 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import hashlib
from django.core.cache import cache
from paypalcheckoutsdk.core import (
AccessToken, PayPalHttpClient as VendorPayPalHttpClient,
)
class PayPalHttpClient(VendorPayPalHttpClient):
def __call__(self, request):
# First we get all the items that make up the current credentials and create a hash to detect changes
checksum = hashlib.sha256(''.join([
self.environment.base_url, self.environment.client_id, self.environment.client_secret
]).encode()).hexdigest()
cache_key_hash = f'pretix_paypal_token_hash_{checksum}'
token_hash = cache.get(cache_key_hash)
if token_hash:
# First we set an optional access token
self._access_token = AccessToken(
access_token=token_hash['access_token'],
expires_in=token_hash['expires_in'],
token_type=token_hash['token_type'],
)
# This is not part of the constructor - so we need to set it after the fact.
self._access_token.created_at = token_hash['created_at']
# Only then we'll call the original __call__() method, as it will verify the validity of the tokens
# and request new ones if required.
super().__call__(request)
# At this point - if there were any changes in access-token, we should have them and can cache them again
if self._access_token and (not token_hash or token_hash['access_token'] != self._access_token.access_token):
expiration = self._access_token.expires_in - 60 # For good measure, we expire 60 seconds earlier
cache.set(cache_key_hash, {
'access_token': self._access_token.access_token,
'expires_in': self._access_token.expires_in,
'token_type': self._access_token.token_type,
'created_at': self._access_token.created_at
}, expiration)
# And now for some housekeeping.
if self.environment.merchant_id:
request.headers["PayPal-Auth-Assertion"] = self.environment.authorization_assertation()
if self.environment.partner_id:
request.headers["PayPal-Partner-Attribution-Id"] = self.environment.partner_id

View File

@@ -0,0 +1,38 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
class PartnerReferralCreateRequest:
"""
Creates a Partner Referral.
"""
def __init__(self):
self.verb = "POST"
self.path = "/v2/customer/partner-referrals?"
self.headers = {}
self.headers["Content-Type"] = "application/json"
self.body = None
def prefer(self, prefer):
self.headers["Prefer"] = str(prefer)
def request_body(self, order):
self.body = order
return self

View File

@@ -0,0 +1,43 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
try:
from urllib import quote # Python 2.X
except ImportError:
from urllib.parse import quote # Python 3+
class PartnersMerchantIntegrationsGetRequest:
"""
Retrieves the Merchant Account Status of a Partner Merchant Integration.
"""
def __init__(self, partner_merchant_id, seller_merchant_id):
self.verb = "GET"
self.path = "/v1/customer/partners/{partner_merchant_id}/merchant-integrations/{seller_merchant_id}".format(
partner_merchant_id=quote(str(partner_merchant_id)),
seller_merchant_id=quote(str(seller_merchant_id))
)
self.headers = {}
self.headers["Content-Type"] = "application/json"
self.body = None
def prefer(self, prefer):
self.headers["Prefer"] = str(prefer)

View File

@@ -0,0 +1,978 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import hashlib
import json
import logging
import urllib.parse
from collections import OrderedDict
from decimal import Decimal
from django import forms
from django.contrib import messages
from django.http import HttpRequest
from django.template.loader import get_template
from django.templatetags.static import static
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.safestring import mark_safe
from django.utils.timezone import now
from django.utils.translation import gettext as __, gettext_lazy as _
from django_countries import countries
from i18nfield.strings import LazyI18nString
from paypalcheckoutsdk.orders import (
OrdersCaptureRequest, OrdersCreateRequest, OrdersGetRequest,
OrdersPatchRequest,
)
from paypalcheckoutsdk.payments import CapturesRefundRequest, RefundsGetRequest
from pretix import settings
from pretix.base.decimal import round_decimal
from pretix.base.forms.questions import guess_country
from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import BasePaymentProvider, PaymentException
from pretix.base.services.mail import SendMailException
from pretix.base.settings import SettingsSandbox
from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri, eventreverse
from pretix.plugins.paypal2.client.core.environment import (
LiveEnvironment, SandboxEnvironment,
)
from pretix.plugins.paypal2.client.core.paypal_http_client import (
PayPalHttpClient,
)
from pretix.plugins.paypal2.client.customer.partner_referral_create_request import (
PartnerReferralCreateRequest,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
logger = logging.getLogger('pretix.plugins.paypal2')
SUPPORTED_CURRENCIES = ['AUD', 'BRL', 'CAD', 'CZK', 'DKK', 'EUR', 'HKD', 'HUF', 'INR', 'ILS', 'JPY', 'MYR', 'MXN',
'TWD', 'NZD', 'NOK', 'PHP', 'PLN', 'GBP', 'RUB', 'SGD', 'SEK', 'CHF', 'THB', 'USD']
LOCAL_ONLY_CURRENCIES = ['INR']
class PaypalSettingsHolder(BasePaymentProvider):
identifier = 'paypal_settings'
verbose_name = _('PayPal')
is_enabled = False
is_meta = True
payment_form_fields = OrderedDict([])
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox('payment', 'paypal', event)
@property
def settings_form_fields(self):
# ISU
if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret:
if self.settings.isu_merchant_id:
fields = [
('isu_merchant_id',
forms.CharField(
label=_('PayPal Merchant ID'),
disabled=True
)),
]
else:
return {}
# Manual API integration
else:
fields = [
('client_id',
forms.CharField(
label=_('Client ID'),
max_length=80,
min_length=80,
help_text=_('<a target="_blank" rel="noopener" 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'),
max_length=80,
min_length=80,
)),
('endpoint',
forms.ChoiceField(
label=_('Endpoint'),
initial='live',
choices=(
('live', 'Live'),
('sandbox', 'Sandbox'),
),
)),
]
methods = [
('method_wallet',
forms.BooleanField(
label=_('PayPal'),
required=False,
help_text=_(
'Even if a customer chooses an Alternative Payment Method, they will always have the option to '
'revert back to paying with their PayPal account. For this reason, this payment method is always '
'active.'
),
disabled=True,
)),
('method_apm',
forms.BooleanField(
label=_('Alternative Payment Methods'),
help_text=_(
'In addition to payments through a PayPal account, you can also offer your customers the option '
'to pay with credit cards and other, local payment methods such as SOFORT, giropay, iDEAL, and '
'many more - even when they do not have a PayPal account. Eligible payment methods will be '
'determined based on the shoppers location. For German merchants, this is the direct successor '
'of PayPal Plus.'
),
required=False,
widget=forms.CheckboxInput(
attrs={
'data-checkbox-dependency': '#id_payment_paypal_method_wallet',
}
)
)),
('disable_method_sepa',
forms.BooleanField(
label=_('Disable SEPA Direct Debit'),
help_text=_(
'While most payment methods cannot be recalled by a customer without outlining their exact grief '
'with the merchants, SEPA Direct Debit can be recalled with the press of a button. For that '
'reason - and depending on the nature of your event - you might want to disabled the option of '
'SEPA Direct Debit payments in order to reduce the risk of costly chargebacks.'
),
required=False,
widget=forms.CheckboxInput(
attrs={
'data-checkbox-dependency': '#id_payment_paypal_method_apm',
}
)
)),
('enable_method_paylater',
forms.BooleanField(
label=_('Enable Buy Now Pay Later'),
help_text=_(
'Offer your customers the possibility to buy now (up to a certain limit) and pay in multiple installments '
'or within 30 days. You, as the merchant, are getting your money right away.'
),
required=False,
widget=forms.CheckboxInput(
attrs={
'data-checkbox-dependency': '#id_payment_paypal_method_apm',
}
)
)),
]
extra_fields = [
('prefix',
forms.CharField(
label=_('Reference prefix'),
help_text=_('Any value entered here will be added in front of the regular booking reference '
'containing the order number.'),
required=False,
)),
('postfix',
forms.CharField(
label=_('Reference postfix'),
help_text=_('Any value entered here will be added behind the regular booking reference '
'containing the order number.'),
required=False,
)),
]
if settings.DEBUG:
allcountries = list(countries)
allcountries.insert(0, ('', _('-- Automatic --')))
extra_fields.append(
('debug_buyer_country',
forms.ChoiceField(
choices=allcountries,
label=mark_safe('<span class="label label-primary">DEBUG</span> {}'.format(_('Buyer country'))),
initial=guess_country(self.event),
)),
)
d = OrderedDict(
fields + methods + extra_fields + list(super().settings_form_fields.items())
)
d.move_to_end('prefix')
d.move_to_end('postfix')
d.move_to_end('_enabled', False)
return d
def settings_content_render(self, request):
settings_content = ""
if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret:
# Use ISU
if not self.settings.isu_merchant_id:
isu_referral_url = self.get_isu_referral_url(request)
settings_content = (
"<p>{}</p>"
"<a href='{}' class='btn btn-primary btn-lg {}'>{}</a>"
).format(
_('To accept payments via PayPal, you will need an account at PayPal. By clicking on the '
'following button, you can either create a new PayPal account or connect pretix to an existing '
'one.'),
isu_referral_url,
'disabled' if not isu_referral_url else '',
_('Connect with {icon} PayPal').format(icon='<i class="fa fa-paypal"></i>')
)
else:
settings_content = (
"<button formaction='{}' class='btn btn-danger'>{}</button>"
).format(
reverse('plugins:paypal2:isu.disconnect', kwargs={
'organizer': self.event.organizer.slug,
'event': self.event.slug,
}),
_('Disconnect from PayPal')
)
else:
settings_content = "<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:paypal2:webhook')
)
if self.event.currency not in SUPPORTED_CURRENCIES:
settings_content += (
'<br><br><div class="alert alert-warning">%s '
'<a href="https://developer.paypal.com/docs/api/reference/currency-codes/">%s</a>'
'</div>'
) % (
_("PayPal does not process payments in your event's currency."),
_("Please check this PayPal page for a complete list of supported currencies.")
)
if self.event.currency in LOCAL_ONLY_CURRENCIES:
settings_content += '<br><br><div class="alert alert-warning">%s''</div>' % (
_("Your event's currency is supported by PayPal as a payment and balance currency for in-country "
"accounts only. This means, that the receiving as well as the sending PayPal account must have been "
"created in the same country and use the same currency. Out of country accounts will not be able to "
"send any payments.")
)
return settings_content
def get_isu_referral_url(self, request):
pprov = PaypalMethod(request.event)
pprov.init_api()
request.session['payment_paypal_isu_event'] = request.event.pk
request.session['payment_paypal_isu_tracking_id'] = get_random_string(length=127)
try:
req = PartnerReferralCreateRequest()
req.request_body({
"operations": [
{
"operation": "API_INTEGRATION",
"api_integration_preference": {
"rest_api_integration": {
"integration_method": "PAYPAL",
"integration_type": "THIRD_PARTY",
"third_party_details": {
"features": [
"PAYMENT",
"REFUND",
"ACCESS_MERCHANT_INFORMATION"
],
}
}
}
}
],
"products": [
"EXPRESS_CHECKOUT"
],
"partner_config_override": {
"partner_logo_url": urllib.parse.urljoin(settings.SITE_URL, static('pretixbase/img/pretix-logo.svg')),
"return_url": build_global_uri('plugins:paypal2:isu.return', kwargs={
'organizer': self.event.organizer.slug,
'event': self.event.slug,
})
},
"tracking_id": request.session['payment_paypal_isu_tracking_id'],
"preferred_language_code": request.user.locale.split('-')[0]
})
response = pprov.client.execute(req)
except IOError as e:
messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
logger.exception('PayPal PartnerReferralCreateRequest: {}'.format(str(e)))
else:
return self.get_link(response.result.links, 'action_url').href
def get_link(self, links, rel):
for link in links:
if link.rel == rel:
return link
return None
class PaypalMethod(BasePaymentProvider):
identifier = ''
method = ''
BN = 'ramiioGmbH_Cart_PPCP'
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox('payment', 'paypal', event)
@property
def settings_form_fields(self):
return {}
@property
def is_enabled(self) -> bool:
return self.settings.get('_enabled', as_type=bool) and self.settings.get('method_{}'.format(self.method),
as_type=bool)
@property
def test_mode_message(self):
if self.settings.connect_client_id and not self.settings.secret:
# in OAuth mode, sandbox mode needs to be set global
is_sandbox = self.settings.connect_endpoint == 'sandbox'
else:
is_sandbox = self.settings.get('endpoint') == 'sandbox'
if is_sandbox:
return _('The PayPal sandbox is being used, you can test without actually sending money but you will need a '
'PayPal sandbox user to log in.')
return None
def is_allowed(self, request: HttpRequest, total: Decimal = None) -> bool:
return super().is_allowed(request, total) and self.event.currency in SUPPORTED_CURRENCIES
def init_api(self):
# ISU
if self.settings.connect_client_id and not self.settings.secret:
if 'sandbox' in self.settings.connect_endpoint:
env = SandboxEnvironment(
client_id=self.settings.connect_client_id,
client_secret=self.settings.connect_secret_key,
merchant_id=self.settings.get('isu_merchant_id', None),
partner_id=self.BN
)
else:
env = LiveEnvironment(
client_id=self.settings.connect_client_id,
client_secret=self.settings.connect_secret_key,
merchant_id=self.settings.get('isu_merchant_id', None),
partner_id=self.BN
)
# Manual API integration
else:
if 'sandbox' in self.settings.get('endpoint'):
env = SandboxEnvironment(
client_id=self.settings.get('client_id'),
client_secret=self.settings.get('secret'),
merchant_id=None,
partner_id=self.BN
)
else:
env = LiveEnvironment(
client_id=self.settings.get('client_id'),
client_secret=self.settings.get('secret'),
merchant_id=None,
partner_id=self.BN
)
self.client = PayPalHttpClient(env)
def payment_is_valid_session(self, request):
return request.session.get('payment_paypal_oid', '') != ''
def payment_form_render(self, request) -> str:
def build_kwargs():
keys = ['organizer', 'event', 'order', 'secret', 'cart_namespace']
kwargs = {}
for key in keys:
if key in request.resolver_match.kwargs:
kwargs[key] = request.resolver_match.kwargs[key]
return kwargs
template = get_template('pretixplugins/paypal2/checkout_payment_form.html')
ctx = {
'request': request,
'event': self.event,
'settings': self.settings,
'method': self.method,
'xhr': eventreverse(self.event, 'plugins:paypal2:xhr', kwargs=build_kwargs())
}
return template.render(ctx)
def checkout_prepare(self, request, cart):
paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), None)
# PayPal OID has been previously generated through XHR and onApprove() has fired
if paypal_order_id and paypal_order_id == request.session.get('payment_paypal_oid', None):
self.init_api()
try:
req = OrdersGetRequest(paypal_order_id)
response = self.client.execute(req)
except IOError as e:
messages.warning(request, _('We had trouble communicating with PayPal'))
logger.exception('PayPal OrdersGetRequest: {}'.format(str(e)))
return False
else:
if response.result.status == 'APPROVED':
return True
messages.warning(request, _('Something went wrong when requesting the payment status. Please try again.'))
return False
# onApprove has fired, but we don't have a matching OID in the session - manipulation/something went wrong.
elif paypal_order_id:
messages.warning(request, _('We had trouble communicating with PayPal'))
return False
else:
# We don't have an XHR-generated OID, nor a onApprove-fired OID.
# Probably no active JavaScript; this won't work
messages.warning(request, _('You may need to enable JavaScript for PayPal payments.'))
return False
def format_price(self, value):
return str(round_decimal(value, self.event.currency, {
# PayPal behaves differently than Stripe in deciding what currencies have decimal places
# Source https://developer.paypal.com/docs/classic/api/currency_codes/
'HUF': 0,
'JPY': 0,
'MYR': 0,
'TWD': 0,
# However, CLPs are not listed there while PayPal requires us not to send decimal places there. WTF.
'CLP': 0,
# Let's just guess that the ones listed here are 0-based as well
# https://developers.braintreepayments.com/reference/general/currencies
'BIF': 0,
'DJF': 0,
'GNF': 0,
'KMF': 0,
'KRW': 0,
'LAK': 0,
'PYG': 0,
'RWF': 0,
'UGX': 0,
'VND': 0,
'VUV': 0,
'XAF': 0,
'XOF': 0,
'XPF': 0,
}))
@property
def abort_pending_allowed(self):
return False
def _create_paypal_order(self, request, payment=None, cart=None):
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']
# ISU
if request.event.settings.payment_paypal_isu_merchant_id:
payee = {
"merchant_id": request.event.settings.payment_paypal_isu_merchant_id,
}
# Manual API integration
else:
payee = {}
if payment and not cart:
value = self.format_price(payment.amount)
currency = payment.order.event.currency
description = '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
)
custom_id = '{prefix}{slug}-{code}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=self.event.slug.upper(),
code=payment.order.code,
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
)
request.session['payment_paypal_payment'] = payment.pk
elif cart and not payment:
value = self.format_price(cart['cart_total'] + cart['cart_fees'] + cart['payment_fee'])
currency = request.event.currency
description = __('Event tickets for {event}').format(event=request.event.name)
custom_id = '{prefix}{slug}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
slug=request.event.slug.upper(),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
)
request.session['payment_paypal_payment'] = None
else:
pass
try:
paymentreq = OrdersCreateRequest()
paymentreq.request_body({
'intent': 'CAPTURE',
# 'payer': {}, # We could transmit PII (email, name, address, etc.)
'purchase_units': [{
'amount': {
'currency_code': currency,
'value': value,
},
'payee': payee,
'description': description,
'custom_id': custom_id,
# 'shipping': {}, # Include Shipping information?
}],
'application_context': {
'locale': request.LANGUAGE_CODE.split('-')[0],
'shipping_preference': 'NO_SHIPPING', # 'SET_PROVIDED_ADDRESS', # Do not set on non-ship order?
'user_action': 'CONTINUE',
'return_url': build_absolute_uri(request.event, 'plugins:paypal2:return', kwargs=kwargs),
'cancel_url': build_absolute_uri(request.event, 'plugins:paypal2:abort', kwargs=kwargs),
},
})
response = self.client.execute(paymentreq)
except IOError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('PayPal OrdersCreateRequest: {}'.format(str(e)))
else:
if response.result.status not in ('CREATED', 'PAYER_ACTION_REQUIRED'):
messages.error(request, _('We had trouble communicating with PayPal'))
logger.error('Invalid payment state: ' + str(paymentreq))
return
request.session['payment_paypal_oid'] = response.result.id
return response.result
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/paypal2/checkout_payment_confirm.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings}
return template.render(ctx)
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
try:
if request.session.get('payment_paypal_oid', '') == '':
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
self.init_api()
try:
req = OrdersGetRequest(request.session.get('payment_paypal_oid'))
response = self.client.execute(req)
except IOError as e:
logger.exception('PayPal OrdersGetRequest: {}'.format(str(e)))
raise PaymentException(_('We had trouble communicating with PayPal'))
else:
pp_captured_order = response.result
try:
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=pp_captured_order.id)
except ReferencedPayPalObject.MultipleObjectsReturned:
pass
if str(pp_captured_order.purchase_units[0].amount.value) != str(payment.amount) or \
pp_captured_order.purchase_units[0].amount.currency_code != self.event.currency:
logger.error('Value mismatch: Payment %s vs paypal trans %s' % (payment.id, str(pp_captured_order)))
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
'proceed.'))
if pp_captured_order.status == 'APPROVED':
try:
patchreq = OrdersPatchRequest(pp_captured_order.id)
patchreq.request_body([
{
"op": "replace",
"path": "/purchase_units/@reference_id=='default'/custom_id",
"value": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {slug}-{code}').format(
slug=self.event.slug.upper(),
code=payment.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
},
{
"op": "replace",
"path": "/purchase_units/@reference_id=='default'/description",
"value": '{prefix}{orderstring}{postfix}'.format(
prefix='{} '.format(self.settings.prefix) if self.settings.prefix else '',
orderstring=__('Order {order} for {event}').format(
event=request.event.name,
order=payment.order.code
),
postfix=' {}'.format(self.settings.postfix) if self.settings.postfix else ''
),
}
])
self.client.execute(patchreq)
except IOError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('PayPal OrdersPatchRequest: {}'.format(str(e)))
return
try:
capturereq = OrdersCaptureRequest(pp_captured_order.id)
response = self.client.execute(capturereq)
except IOError as e:
messages.error(request, _('We had trouble communicating with PayPal'))
logger.exception('PayPal OrdersCaptureRequest: {}'.format(str(e)))
return
else:
pp_captured_order = response.result
for purchaseunit in pp_captured_order.purchase_units:
for capture in purchaseunit.payments.captures:
try:
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=capture.id)
except ReferencedPayPalObject.MultipleObjectsReturned:
pass
if capture.status == 'PENDING':
messages.warning(request, _('PayPal has not yet approved the payment. We will inform you as '
'soon as the payment completed.'))
payment.info = json.dumps(pp_captured_order.dict())
payment.state = OrderPayment.PAYMENT_STATE_PENDING
payment.save()
return
payment.refresh_from_db()
if pp_captured_order.status != 'COMPLETED':
payment.fail(info=pp_captured_order.dict())
logger.error('Invalid state: %s' % str(pp_captured_order))
raise PaymentException(
_('We were unable to process your payment. See below for details on how to proceed.')
)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
logger.warning('PayPal success event even though order is already marked as paid')
return
try:
payment.info = json.dumps(pp_captured_order.dict())
payment.save(update_fields=['info'])
payment.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
except SendMailException:
messages.warning(request, _('There was an error sending the confirmation mail.'))
finally:
del request.session['payment_paypal_oid']
def payment_pending_render(self, request, payment) -> str:
retry = True
try:
if payment.info and payment.info_data['state'] == 'pending':
retry = False
except KeyError:
pass
template = get_template('pretixplugins/paypal2/pending.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'retry': retry, 'order': payment.order}
return template.render(ctx)
def matching_id(self, payment: OrderPayment):
sale_id = None
# Legacy PayPal info-data
if 'purchase_units' not in payment.info_data:
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
else:
for trans in payment.info_data.get('purchase_units', []):
for res in trans.get('payments', {}).get('captures', []):
sale_id = res['id']
return sale_id or payment.info_data.get('id', None)
def api_payment_details(self, payment: OrderPayment):
sale_id = None
# Legacy PayPal info-data
if 'purchase_units' not in payment.info_data:
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
return {
"payer_email": payment.info_data.get('payer', {}).get('payer_info', {}).get('email'),
"payer_id": payment.info_data.get('payer', {}).get('payer_info', {}).get('payer_id'),
"cart_id": payment.info_data.get('cart', None),
"payment_id": payment.info_data.get('id', None),
"sale_id": sale_id,
}
else:
for trans in payment.info_data.get('purchase_units', []):
for res in trans.get('payments', {}).get('captures', []):
sale_id = res['id']
return {
"payer_email": payment.info_data.get('payer', {}).get('email_address'),
"payer_id": payment.info_data.get('payer', {}).get('payer_id'),
"cart_id": payment.info_data.get('id', None),
"payment_id": sale_id,
"sale_id": sale_id,
}
def payment_control_render(self, request: HttpRequest, payment: OrderPayment):
# Legacy PayPal info-data
if 'purchase_units' not in payment.info_data:
template = get_template('pretixplugins/paypal2/control_legacy.html')
sale_id = None
for trans in payment.info_data.get('transactions', []):
for res in trans.get('related_resources', []):
if 'sale' in res and 'id' in res['sale']:
sale_id = res['sale']['id']
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'payment_info': payment.info_data, 'order': payment.order, 'sale_id': sale_id}
else:
template = get_template('pretixplugins/paypal2/control.html')
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
'payment_info': payment.info_data, 'order': payment.order}
return template.render(ctx)
def payment_control_render_short(self, payment: OrderPayment) -> str:
# Legacy PayPal info-data
if 'purchase_units' not in payment.info_data:
return payment.info_data.get('payer', {}).get('payer_info', {}).get('email', '')
else:
return '{} / {}'.format(
payment.info_data.get('id', ''),
payment.info_data.get('payer', {}).get('email_address', '')
)
def payment_partial_refund_supported(self, payment: OrderPayment):
# Paypal refunds are possible for 180 days after purchase:
# https://www.paypal.com/lc/smarthelp/article/how-do-i-issue-a-refund-faq780#:~:text=Refund%20after%20180%20days%20of,PayPal%20balance%20of%20the%20recipient.
return (now() - payment.payment_date).days <= 180
def payment_refund_supported(self, payment: OrderPayment):
self.payment_partial_refund_supported(payment)
def execute_refund(self, refund: OrderRefund):
self.init_api()
try:
pp_payment = None
payment_info_data = None
# Legacy PayPal - get up to date info data first
if "purchase_units" not in refund.payment.info_data:
req = OrdersGetRequest(refund.payment.info_data['cart'])
response = self.client.execute(req)
payment_info_data = response.result.dict()
else:
payment_info_data = refund.payment.info_data
for res in payment_info_data['purchase_units'][0]['payments']['captures']:
if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']:
pp_payment = res['id']
break
if not pp_payment:
req = OrdersGetRequest(payment_info_data['id'])
response = self.client.execute(req)
for res in response.result.purchase_units[0].payments.captures:
if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED']:
pp_payment = res.id
break
req = CapturesRefundRequest(pp_payment)
req.request_body({
"amount": {
"value": self.format_price(refund.amount),
"currency_code": refund.order.event.currency
}
})
response = self.client.execute(req)
except IOError as e:
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(e)
})
logger.error('execute_refund: {}'.format(str(e)))
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(str(e)))
refund.info = json.dumps(response.result.dict())
refund.save(update_fields=['info'])
req = RefundsGetRequest(response.result.id)
response = self.client.execute(req)
refund.info = json.dumps(response.result.dict())
refund.save(update_fields=['info'])
if response.result.status == 'COMPLETED':
refund.done()
elif response.result.status == 'PENDING':
refund.state = OrderRefund.REFUND_STATE_TRANSIT
refund.save(update_fields=['state'])
else:
refund.order.log_action('pretix.event.order.refund.failed', {
'local_id': refund.local_id,
'provider': refund.provider,
'error': str(response.result.status_details.reason)
})
raise PaymentException(_('Refunding the amount via PayPal failed: {}').format(response.result.status_details.reason))
def payment_prepare(self, request, payment):
paypal_order_id = request.POST.get('payment_paypal_{}_oid'.format(self.method), None)
# PayPal OID has been previously generated through XHR and onApprove() has fired
if paypal_order_id and paypal_order_id == request.session.get('payment_paypal_oid', None):
self.init_api()
try:
req = OrdersGetRequest(paypal_order_id)
response = self.client.execute(req)
except IOError as e:
messages.warning(request, _('We had trouble communicating with PayPal'))
logger.exception('PayPal OrdersGetRequest: {}'.format(str(e)))
return False
else:
if response.result.status == 'APPROVED':
return True
messages.warning(request, _('Something went wrong when requesting the payment status. Please try again.'))
return False
# onApprove has fired, but we don't have a matching OID in the session - manipulation/something went wrong.
elif paypal_order_id:
messages.warning(request, _('We had trouble communicating with PayPal'))
return False
else:
# We don't have an XHR-generated OID, nor a onApprove-fired OID.
# Probably no active JavaScript; this won't work
messages.warning(request, _('You may need to enable JavaScript for PayPal payments.'))
return False
def shred_payment_info(self, obj):
if obj.info:
d = json.loads(obj.info)
new = {
'id': d.get('id'),
'payer': {
'payer_info': {
'email': ''
}
},
'update_time': d.get('update_time'),
'transactions': [
{
'amount': t.get('amount')
} for t in d.get('transactions', [])
],
'_shredded': True
}
obj.info = json.dumps(new)
obj.save(update_fields=['info'])
for le in obj.order.all_logentries().filter(action_type="pretix.plugins.paypal.event").exclude(data=""):
d = le.parsed_data
if 'resource' in d:
d['resource'] = {
'id': d['resource'].get('id'),
'sale_id': d['resource'].get('sale_id'),
'parent_payment': d['resource'].get('parent_payment'),
}
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
def render_invoice_text(self, order: Order, payment: OrderPayment) -> str:
if order.status == Order.STATUS_PAID:
if payment.info_data.get('id', None):
try:
return '{}\r\n{}: {}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id'],
_('PayPal sale ID'),
payment.info_data['transactions'][0]['related_resources'][0]['sale']['id']
)
except (KeyError, IndexError):
return '{}\r\n{}: {}'.format(
_('The payment for this invoice has already been received.'),
_('PayPal payment ID'),
payment.info_data['id']
)
else:
return super().render_invoice_text(order, payment)
return self.settings.get('_invoice_text', as_type=LazyI18nString, default='')
class PaypalWallet(PaypalMethod):
identifier = 'paypal'
verbose_name = _('PayPal')
public_name = _('PayPal')
method = 'wallet'
class PaypalAPM(PaypalMethod):
identifier = 'paypal_apm'
verbose_name = _('PayPal APM')
public_name = _('PayPal Alternative Payment Methods')
method = 'apm'
def payment_is_valid_session(self, request):
# Since APMs request the OID by XHR at a later point, no need to check anything here
return True
def checkout_prepare(self, request, cart):
return True
def payment_prepare(self, request, payment):
return True
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
# This is a workaround to not have APMs be written to the database with identifier paypal_apm.
# Since all transactions - APM or not - look the same and are handled the same, we want to keep all PayPal
# transactions under the "paypal"-identifier - no matter what the customer might have selected.
payment.provider = "paypal"
payment.save(update_fields=["provider"])
paypal_order = self._create_paypal_order(request, payment, None)
payment.info = json.dumps(paypal_order.dict())
payment.save(update_fields=['info'])
return eventreverse(self.event, 'plugins:paypal2:pay', kwargs={
'order': payment.order.code,
'payment': payment.pk,
'hash': hashlib.sha1(payment.order.secret.lower().encode()).hexdigest(),
})

View File

@@ -0,0 +1,171 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
from collections import OrderedDict
from django import forms
from django.dispatch import receiver
from django.http import HttpRequest, HttpResponse
from django.template.loader import get_template
from django.urls import resolve
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from pretix import settings
from pretix.base.forms import SecretKeySettingsField
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.settings import settings_hierarkey
from pretix.base.signals import (
logentry_display, register_global_settings, register_payment_providers,
)
from pretix.plugins.paypal2.payment import PaypalMethod
from pretix.presale.signals import html_head, process_response
@receiver(register_payment_providers, dispatch_uid="payment_paypal2")
def register_payment_provider(sender, **kwargs):
from .payment import PaypalAPM, PaypalSettingsHolder, PaypalWallet
return [PaypalSettingsHolder, PaypalWallet, PaypalAPM]
@receiver(signal=logentry_display, dispatch_uid="paypal2_logentry_display")
def pretixcontrol_logentry_display(sender, logentry, **kwargs):
if logentry.action_type != 'pretix.plugins.paypal.event':
return
data = json.loads(logentry.data)
event_type = data.get('event_type')
text = None
plains = {
'PAYMENT.SALE.COMPLETED': _('Payment completed.'),
'PAYMENT.SALE.DENIED': _('Payment denied.'),
'PAYMENT.SALE.REFUNDED': _('Payment refunded.'),
'PAYMENT.SALE.REVERSED': _('Payment reversed.'),
'PAYMENT.SALE.PENDING': _('Payment pending.'),
'CHECKOUT.ORDER.APPROVED': _('Order approved.'),
}
if event_type in plains:
text = plains[event_type]
else:
text = event_type
if text:
return _('PayPal reported an event: {}').format(text)
@receiver(register_global_settings, dispatch_uid='paypal2_global_settings')
def register_global_settings(sender, **kwargs):
return OrderedDict([
('payment_paypal_connect_client_id', forms.CharField(
label=_('PayPal ISU/Connect: Client ID'),
required=False,
)),
('payment_paypal_connect_secret_key', SecretKeySettingsField(
label=_('PayPal ISU/Connect: Secret key'),
required=False,
)),
('payment_paypal_connect_partner_merchant_id', forms.CharField(
label=_('PayPal ISU/Connect: Partner Merchant ID'),
help_text=_('This is not the BN-code, but rather the ID of the merchant account which holds branding information for ISU.'),
required=False,
)),
('payment_paypal_connect_endpoint', forms.ChoiceField(
label=_('PayPal ISU/Connect Endpoint'),
initial='live',
choices=(
('live', 'Live'),
('sandbox', 'Sandbox'),
),
)),
])
@receiver(html_head, dispatch_uid="payment_paypal2_html_head")
def html_head_presale(sender, request=None, **kwargs):
provider = PaypalMethod(sender)
url = resolve(request.path_info)
if provider.settings.get('_enabled', as_type=bool) and (
url.url_name == "event.order.pay.change" or
(url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or
(url.namespace == "plugins:paypal2" and url.url_name == "pay")
):
provider.init_api()
template = get_template('pretixplugins/paypal2/presale_head.html')
ctx = {
'client_id': provider.client.environment.client_id,
'merchant_id': provider.client.environment.merchant_id or '',
'csp_nonce': _nonce(request),
'debug': settings.DEBUG,
'settings': provider.settings,
# If we ever have more APMs that can be disabled, we should iterate over the
# disable_method_*/enable_method*-keys
'disable_funding': 'sepa' if provider.settings.get('disable_method_sepa', as_type=bool) else '',
'enable_funding': 'paylater' if provider.settings.get('enable_method_paylater', as_type=bool) else ''
}
return template.render(ctx)
else:
return ""
@receiver(signal=process_response, dispatch_uid="payment_paypal2_middleware_resp")
def signal_process_response(sender, request: HttpRequest, response: HttpResponse, **kwargs):
provider = PaypalMethod(sender)
url = resolve(request.path_info)
if provider.settings.get('_enabled', as_type=bool) and (
url.url_name == "event.order.pay.change" or
(url.url_name == "event.checkout" and url.kwargs['step'] == "payment") or
(url.namespace == "plugins:paypal2" and url.url_name == "pay")
):
if 'Content-Security-Policy' in response:
h = _parse_csp(response['Content-Security-Policy'])
else:
h = {}
csps = {
'script-src': ['https://www.paypal.com', "'nonce-{}'".format(_nonce(request))],
'frame-src': ['https://www.paypal.com', 'https://www.sandbox.paypal.com', "'nonce-{}'".format(_nonce(request))],
'connect-src': ['https://www.paypal.com', 'https://www.sandbox.paypal.com'], # Or not - seems to only affect PayPal logging...
'img-src': ['https://t.paypal.com'],
'style-src': ["'nonce-{}'".format(_nonce(request))]
}
_merge_csp(h, csps)
if h:
response['Content-Security-Policy'] = _render_csp(h)
return response
settings_hierarkey.add_default('payment_paypal_debug_buyer_country', '', str)
settings_hierarkey.add_default('payment_paypal_method_wallet', True, bool)
def _nonce(request):
if not hasattr(request, "_paypal_nonce"):
request._paypal_nonce = get_random_string(32)
return request._paypal_nonce

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generator: Adobe Illustrator 16.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="124px" height="33px" viewBox="0 0 124 33" enable-background="new 0 0 124 33" xml:space="preserve">
<path fill="#253B80" d="M46.211,6.749h-6.839c-0.468,0-0.866,0.34-0.939,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.564,0.658 h3.265c0.468,0,0.866-0.34,0.939-0.803l0.746-4.73c0.072-0.463,0.471-0.803,0.938-0.803h2.165c4.505,0,7.105-2.18,7.784-6.5 c0.306-1.89,0.013-3.375-0.872-4.415C50.224,7.353,48.5,6.749,46.211,6.749z M47,13.154c-0.374,2.454-2.249,2.454-4.062,2.454 h-1.032l0.724-4.583c0.043-0.277,0.283-0.481,0.563-0.481h0.473c1.235,0,2.4,0,3.002,0.704C47.027,11.668,47.137,12.292,47,13.154z"/>
<path fill="#253B80" d="M66.654,13.075h-3.275c-0.279,0-0.52,0.204-0.563,0.481l-0.145,0.916l-0.229-0.332 c-0.709-1.029-2.29-1.373-3.868-1.373c-3.619,0-6.71,2.741-7.312,6.586c-0.313,1.918,0.132,3.752,1.22,5.031 c0.998,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.562,0.66h2.95 c0.469,0,0.865-0.34,0.939-0.803l1.77-11.209C67.271,13.388,67.004,13.075,66.654,13.075z M62.089,19.449 c-0.316,1.871-1.801,3.127-3.695,3.127c-0.951,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.668-1.391-0.514-2.301 c0.295-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C62.034,17.721,62.232,18.543,62.089,19.449z"/>
<path fill="#253B80" d="M84.096,13.075h-3.291c-0.314,0-0.609,0.156-0.787,0.417l-4.539,6.686l-1.924-6.425 c-0.121-0.402-0.492-0.678-0.912-0.678h-3.234c-0.393,0-0.666,0.384-0.541,0.754l3.625,10.638l-3.408,4.811 c-0.268,0.379,0.002,0.9,0.465,0.9h3.287c0.312,0,0.604-0.152,0.781-0.408L84.564,13.97C84.826,13.592,84.557,13.075,84.096,13.075z "/>
<path fill="#179BD7" d="M94.992,6.749h-6.84c-0.467,0-0.865,0.34-0.938,0.802l-2.766,17.537c-0.055,0.346,0.213,0.658,0.562,0.658 h3.51c0.326,0,0.605-0.238,0.656-0.562l0.785-4.971c0.072-0.463,0.471-0.803,0.938-0.803h2.164c4.506,0,7.105-2.18,7.785-6.5 c0.307-1.89,0.012-3.375-0.873-4.415C99.004,7.353,97.281,6.749,94.992,6.749z M95.781,13.154c-0.373,2.454-2.248,2.454-4.062,2.454 h-1.031l0.725-4.583c0.043-0.277,0.281-0.481,0.562-0.481h0.473c1.234,0,2.4,0,3.002,0.704 C95.809,11.668,95.918,12.292,95.781,13.154z"/>
<path fill="#179BD7" d="M115.434,13.075h-3.273c-0.281,0-0.52,0.204-0.562,0.481l-0.145,0.916l-0.23-0.332 c-0.709-1.029-2.289-1.373-3.867-1.373c-3.619,0-6.709,2.741-7.311,6.586c-0.312,1.918,0.131,3.752,1.219,5.031 c1,1.176,2.426,1.666,4.125,1.666c2.916,0,4.533-1.875,4.533-1.875l-0.146,0.91c-0.055,0.348,0.213,0.66,0.564,0.66h2.949 c0.467,0,0.865-0.34,0.938-0.803l1.771-11.209C116.053,13.388,115.785,13.075,115.434,13.075z M110.869,19.449 c-0.314,1.871-1.801,3.127-3.695,3.127c-0.949,0-1.711-0.305-2.199-0.883c-0.484-0.574-0.666-1.391-0.514-2.301 c0.297-1.855,1.805-3.152,3.67-3.152c0.93,0,1.686,0.309,2.184,0.892C110.816,17.721,111.014,18.543,110.869,19.449z"/>
<path fill="#179BD7" d="M119.295,7.23l-2.807,17.858c-0.055,0.346,0.213,0.658,0.562,0.658h2.822c0.469,0,0.867-0.34,0.939-0.803 l2.768-17.536c0.055-0.346-0.213-0.659-0.562-0.659h-3.16C119.578,6.749,119.338,6.953,119.295,7.23z"/>
<path fill="#253B80" d="M7.266,29.154l0.523-3.322l-1.165-0.027H1.061L4.927,1.292C4.939,1.218,4.978,1.149,5.035,1.1 c0.057-0.049,0.13-0.076,0.206-0.076h9.38c3.114,0,5.263,0.648,6.385,1.927c0.526,0.6,0.861,1.227,1.023,1.917 c0.17,0.724,0.173,1.589,0.007,2.644l-0.012,0.077v0.676l0.526,0.298c0.443,0.235,0.795,0.504,1.065,0.812 c0.45,0.513,0.741,1.165,0.864,1.938c0.127,0.795,0.085,1.741-0.123,2.812c-0.24,1.232-0.628,2.305-1.152,3.183 c-0.482,0.809-1.096,1.48-1.825,2c-0.696,0.494-1.523,0.869-2.458,1.109c-0.906,0.236-1.939,0.355-3.072,0.355h-0.73 c-0.522,0-1.029,0.188-1.427,0.525c-0.399,0.344-0.663,0.814-0.744,1.328l-0.055,0.299l-0.924,5.855l-0.042,0.215 c-0.011,0.068-0.03,0.102-0.058,0.125c-0.025,0.021-0.061,0.035-0.096,0.035H7.266z"/>
<path fill="#179BD7" d="M23.048,7.667L23.048,7.667L23.048,7.667c-0.028,0.179-0.06,0.362-0.096,0.55 c-1.237,6.351-5.469,8.545-10.874,8.545H9.326c-0.661,0-1.218,0.48-1.321,1.132l0,0l0,0L6.596,26.83l-0.399,2.533 c-0.067,0.428,0.263,0.814,0.695,0.814h4.881c0.578,0,1.069-0.42,1.16-0.99l0.048-0.248l0.919-5.832l0.059-0.32 c0.09-0.572,0.582-0.992,1.16-0.992h0.73c4.729,0,8.431-1.92,9.513-7.476c0.452-2.321,0.218-4.259-0.978-5.622 C24.022,8.286,23.573,7.945,23.048,7.667z"/>
<path fill="#222D65" d="M21.754,7.151c-0.189-0.055-0.384-0.105-0.584-0.15c-0.201-0.044-0.407-0.083-0.619-0.117 c-0.742-0.12-1.555-0.177-2.426-0.177h-7.352c-0.181,0-0.353,0.041-0.507,0.115C9.927,6.985,9.675,7.306,9.614,7.699L8.05,17.605 l-0.045,0.289c0.103-0.652,0.66-1.132,1.321-1.132h2.752c5.405,0,9.637-2.195,10.874-8.545c0.037-0.188,0.068-0.371,0.096-0.55 c-0.313-0.166-0.652-0.308-1.017-0.429C21.941,7.208,21.848,7.179,21.754,7.151z"/>
<path fill="#253B80" d="M9.614,7.699c0.061-0.393,0.313-0.714,0.652-0.876c0.155-0.074,0.326-0.115,0.507-0.115h7.352 c0.871,0,1.684,0.057,2.426,0.177c0.212,0.034,0.418,0.073,0.619,0.117c0.2,0.045,0.395,0.095,0.584,0.15 c0.094,0.028,0.187,0.057,0.278,0.086c0.365,0.121,0.704,0.264,1.017,0.429c0.368-2.347-0.003-3.945-1.272-5.392 C20.378,0.682,17.853,0,14.622,0h-9.38c-0.66,0-1.223,0.48-1.325,1.133L0.01,25.898c-0.077,0.49,0.301,0.932,0.795,0.932h5.791 l1.454-9.225L9.614,7.699z"/>
</svg>

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@@ -0,0 +1,282 @@
/*global $, paypal_client_id, paypal_loadingmessage, gettext */
'use strict';
var pretixpaypal = {
paypal: null,
client_id: null,
order_id: null,
payer_id: null,
merchant_id: null,
currency: null,
method: null,
additional_disabled_funding: null,
additional_enabled_funding: null,
debug_buyer_country: null,
continue_button: null,
paypage: false,
method_map: {
wallet: {
method: 'wallet',
funding_source: 'paypal',
//disable_funding: null,
//enable_funding: 'paylater',
early_auth: true,
},
apm: {
method: 'apm',
funding_source: null,
//disable_funding: null,
//enable_funding: null,
early_auth: false,
}
},
apm_map: {
paypal: gettext('PayPal'),
venmo: gettext('Venmo'),
applepay: gettext('Apple Pay'),
itau: gettext('Itaú'),
credit: gettext('PayPal Credit'),
card: gettext('Credit Card'),
paylater: gettext('PayPal Pay Later'),
ideal: gettext('iDEAL'),
sepa: gettext('SEPA Direct Debit'),
bancontact: gettext('Bancontact'),
giropay: gettext('giropay'),
sofort: gettext('SOFORT'),
eps: gettext('eps'),
mybank: gettext('MyBank'),
p24: gettext('Przelewy24'),
verkkopankki: gettext('Verkkopankki'),
payu: gettext('PayU'),
blik: gettext('BLIK'),
trustly: gettext('Trustly'),
zimpler: gettext('Zimpler'),
maxima: gettext('Maxima'),
oxxo: gettext('OXXO'),
boleto: gettext('Boleto'),
wechatpay: gettext('WeChat Pay'),
mercadopago: gettext('Mercado Pago')
},
load: function () {
if (pretixpaypal.paypal === null) {
pretixpaypal.client_id = $.trim($("#paypal_client_id").html());
pretixpaypal.merchant_id = $.trim($("#paypal_merchant_id").html());
pretixpaypal.debug_buyer_country = $.trim($("#paypal_buyer_country").html());
pretixpaypal.continue_button = $('.checkout-button-row').closest("form").find(".checkout-button-row .btn-primary");
pretixpaypal.continue_button.closest('div').append('<div id="paypal-button-container"></div>');
pretixpaypal.additional_disabled_funding = $.trim($("#paypal_disable_funding").html());
pretixpaypal.additional_enabled_funding = $.trim($("#paypal_enable_funding").html());
pretixpaypal.paypage = Boolean($('#paypal-button-container').data('paypage'));
pretixpaypal.order_id = $.trim($("#paypal_oid").html());
pretixpaypal.currency = $("body").attr("data-currency");
}
pretixpaypal.continue_button.prop("disabled", true);
// We are setting the cogwheel already here, as the renderAPM() method might take some time to get loaded.
let apmtextselector = $("label[for=input_payment_paypal_apm]");
apmtextselector.prepend('<span class="fa fa-cog fa-spin"></span> ');
let sdk_url = 'https://www.paypal.com/sdk/js' +
'?client-id=' + pretixpaypal.client_id +
'&components=buttons,funding-eligibility' +
'&currency=' + pretixpaypal.currency;
if (pretixpaypal.merchant_id) {
sdk_url += '&merchant-id=' + pretixpaypal.merchant_id;
}
if (pretixpaypal.additional_disabled_funding) {
sdk_url += '&disable-funding=' + [pretixpaypal.additional_disabled_funding].filter(Boolean).join(',');
}
if (pretixpaypal.additional_enabled_funding) {
sdk_url += '&enable-funding=' + [pretixpaypal.additional_enabled_funding].filter(Boolean).join(',');
}
if (pretixpaypal.debug_buyer_country) {
sdk_url += '&buyer-country=' + pretixpaypal.debug_buyer_country;
}
let ppscript = document.createElement('script');
let ready = false;
let head = document.getElementsByTagName("head")[0];
ppscript.setAttribute('src', sdk_url);
ppscript.setAttribute('data-csp-nonce', $.trim($("#csp_nonce").html()));
ppscript.setAttribute('data-page-type', 'checkout');
ppscript.setAttribute('data-partner-attribution-id', 'ramiioGmbH_Cart_PPCP');
document.head.appendChild(ppscript);
ppscript.onload = ppscript.onreadystatechange = function () {
if (!ready && (!this.readyState || this.readyState === "loaded" || this.readyState === "complete")) {
ready = true;
pretixpaypal.paypal = paypal;
// Handle memory leak in IE
ppscript.onload = ppscript.onreadystatechange = null;
if (head && ppscript.parentNode) {
head.removeChild(ppscript);
}
pretixpaypal.ready();
}
};
},
ready: function () {
if ($("input[name=payment][value=paypal_apm]").length > 0) {
pretixpaypal.renderAPMs();
}
$("input[name=payment][value^='paypal']").change(function () {
pretixpaypal.renderButton($(this).val());
});
$("input[name=payment]").not("[value^='paypal']").change(function () {
pretixpaypal.restore();
});
if ($("input[name=payment][value^='paypal']").is(':checked') || $(".payment-redo-form").length) {
pretixpaypal.renderButton($("input[name=payment][value^='paypal']:checked").val());
}
if ($('#paypal-button-container').data('paypage')) {
pretixpaypal.renderButton('paypal_apm');
}
},
restore: function () {
// if PayPal has not been initialized, there shouldn't be anything to cleanup
if (pretixpaypal.paypal !== null) {
$('#paypal-button-container').empty()
pretixpaypal.continue_button.text(gettext('Continue'));
pretixpaypal.continue_button.show();
pretixpaypal.continue_button.prop("disabled", false);
}
},
renderButton: function (method) {
if (method === 'paypal') {
method = "wallet"
} else {
method = method.split('paypal_').at(-1)
}
pretixpaypal.method = pretixpaypal.method_map[method];
if (pretixpaypal.method.method === 'apm' && !pretixpaypal.paypage) {
pretixpaypal.restore();
return;
}
$('#paypal-button-container').empty()
$('#paypal-card-container').empty()
let button = pretixpaypal.paypal.Buttons({
fundingSource: pretixpaypal.method.funding_source,
style: {
layout: pretixpaypal.method.early_auth ? 'horizontal' : 'vertical',
//color: 'white',
shape: 'rect',
label: 'pay',
tagline: false
},
createOrder: function (data, actions) {
if (pretixpaypal.order_id) {
return pretixpaypal.order_id;
}
// On the paypal:pay view, we already pregenerated the OID.
// Since this view is also only used for APMs, we only need the XHR-calls for the Smart Payment Buttons.
if (pretixpaypal.paypage) {
return $("#payment_paypal_" + pretixpaypal.method.method + "_oid");
} else {
var xhrurl = $("#payment_paypal_" + pretixpaypal.method.method + "_xhr").val();
}
return fetch(xhrurl, {
method: 'POST'
}).then(function (res) {
return res.json();
}).then(function (data) {
if ('id' in data) {
return data.id;
} else {
// Refreshing the page to surface the request-error message
location.reload();
}
});
},
onApprove: function (data, actions) {
waitingDialog.show(gettext("Confirming your payment …"));
pretixpaypal.order_id = data.orderID;
pretixpaypal.payer_id = data.payerID;
let method = pretixpaypal.paypage ? "wallet" : pretixpaypal.method.method;
let selectorstub = "#payment_paypal_" + method;
var $form = $(selectorstub + "_oid").closest("form");
// Insert the tokens into the form so it gets submitted to the server
$(selectorstub + "_oid").val(pretixpaypal.order_id);
$(selectorstub + "_payer").val(pretixpaypal.payer_id);
// and submit
$form.get(0).submit();
// billingToken: null
// facilitatorAccessToken: "A21AAL_fEu0gDD-sIXyOy65a6MjgSJJrhmxuPcxxUGnL5gW2DzTxiiAksfoC4x8hD-BjeY1LsFVKl7ceuO7UR1a9pQr8Q_AVw"
// orderID: "7RF70259NY7589848"
// payerID: "8M3BU92Z97VXA"
// paymentID: null
},
});
if (button.isEligible()) {
button.render('#paypal-button-container');
pretixpaypal.continue_button.hide();
} else {
pretixpaypal.continue_button.text(gettext('Payment method unavailable'));
pretixpaypal.continue_button.show();
}
},
renderAPMs: function () {
pretixpaypal.restore();
let inputselector = $("input[name=payment][value=paypal_apm]");
// The first selector is used on the regular payment-step of the checkout flow
// The second selector is used for the payment method change view.
// In the long run, the layout of both pages should be adjusted to be one.
let textselector = $("label[for=input_payment_paypal_apm]");
let textselector2 = inputselector.next("strong");
let eligibles = [];
pretixpaypal.paypal.getFundingSources().forEach(function (fundingSource) {
// Let's always skip PayPal, since it's always a dedicated funding source
if (fundingSource === 'paypal') {
return;
}
// This could also be paypal.Marks() - but they only expose images instead of cleartext...
let button = pretixpaypal.paypal.Buttons({
fundingSource: fundingSource
});
if (button.isEligible()) {
eligibles.push(gettext(pretixpaypal.apm_map[fundingSource] || fundingSource));
}
});
inputselector.attr('title', eligibles.join(', '));
textselector.fadeOut(300, function () {
textselector.text(eligibles.join(', '));
textselector.fadeIn(300);
});
textselector2.fadeOut(300, function () {
textselector2[0].textContent = eligibles.join(', ');
textselector2.fadeIn(300);
});
}
};
$(function () {
pretixpaypal.load();
});

View File

@@ -0,0 +1,14 @@
{% load i18n %}
<p>
{% if method == "wallet" %}
{% blocktrans trimmed %}
The total amount listed above will be withdrawn from your PayPal account after the
confirmation of your purchase.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
After placing your order, you will be able to select your desired payment method, including PayPal.
{% endblocktrans %}
{% endif %}
</p>

View File

@@ -0,0 +1,18 @@
{% load i18n %}
<p>
{% if method == "wallet" %}
{% blocktrans trimmed %}
Please click the "Pay with PayPal" button below to start your payment.
{% endblocktrans %}
{% else %}
{% blocktrans trimmed %}
After you clicked continue, we will redirect you to PayPal to fill in your payment
details. You will then be redirected back here to review and confirm your order.
{% endblocktrans %}
{% endif %}
</p>
<input type="hidden" name="payment_paypal_{{ method }}_oid" value="" id="payment_paypal_{{ method }}_oid" />
<input type="hidden" name="payment_paypal_{{ method }}_payer" value="" id="payment_paypal_{{ method }}_payer" />
<input type="hidden" name="payment_paypal_{{ method }}_xhr" value="{{ xhr }}" id="payment_paypal_{{ method }}_xhr" />

View File

@@ -0,0 +1,18 @@
{% load i18n %}
{% if payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Sale ID" %}</dt>
<dd>{{ payment_info.purchase_units.0.payments.captures.0.id }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>{{ payment_info.status }}</dd>
<dt>{% trans "Payer" %}</dt>
<dd>{{ payment_info.payer.email_address }}</dd>
<dt>{% trans "Last update" %}</dt>
<dd>{{ payment_info.purchase_units.0.payments.captures.0.update_time }}</dd>
<dt>{% trans "Total value" %}</dt>
<dd>{{ payment_info.purchase_units.0.payments.captures.0.amount.value }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ payment_info.purchase_units.0.payments.captures.0.amount.currency_code }}</dd>
</dl>
{% endif %}

View File

@@ -0,0 +1,18 @@
{% load i18n %}
{% if payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Payment ID" %}</dt>
<dd>{{ payment_info.id }}</dd>
<dt>{% trans "Sale ID" %}</dt>
<dd>{{ sale_id|default_if_none:"?" }}</dd>
<dt>{% trans "Payer" %}</dt>
<dd>{{ payment_info.payer.payer_info.email }}</dd>
<dt>{% trans "Last update" %}</dt>
<dd>{{ payment_info.update_time }}</dd>
<dt>{% trans "Total value" %}</dt>
<dd>{{ payment_info.transactions.0.amount.total }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ payment_info.transactions.0.amount.currency }}</dd>
</dl>
{% endif %}

View File

@@ -0,0 +1,45 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load static %}
{% block title %}{% trans "Pay order" %}{% endblock %}
{% block custom_header %}
{{ block.super }}
{% if oid %}
<script type="text/plain" id="paypal_oid">{{ oid }}</script>
{% endif %}
{% endblock %}
{% block content %}
<div class="panel panel-primary">
<div class="panel-heading">
<h3 class="panel-title">
{% blocktrans trimmed with code=order.code %}
Pay order: {{ code }}
{% endblocktrans %}
</h3>
</div>
<div class="panel-body" id="paymentcontainer">
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
<noscript>
<div class="alert alert-warning">
{% trans "Please turn on JavaScript." %}
</div>
</noscript>
<p>{% trans "Please use the button/form below to complete your payment." %}</p>
<div id="paypal-button-container" data-paypage="paypal_apm" class="text-center"></div>
<input type="hidden" name="payment_paypal_{{ method }}_oid" value="{{ oid }}" id="payment_paypal_{{ method }}_oid" />
<input type="hidden" name="payment_paypal_{{ method }}_payer" value="" id="payment_paypal_{{ method }}_payer" />
</form>
</div>
</div>
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{% eventurl request.event "presale:event.order" secret=order.secret order=order.code %}">
{% trans "Cancel" %}
</a>
</div>
<div class="clearfix"></div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% load i18n %}
{% if retry %}
<p>{% blocktrans trimmed %}
Our attempt to execute your Payment via PayPal has failed. Please try again or contact us.
{% endblocktrans %}</p>
{% else %}
<p>{% blocktrans trimmed %}
We're waiting for an answer from PayPal regarding your payment. Please contact us, if this
takes more than a few hours.
{% endblocktrans %}</p>
{% endif %}

View File

@@ -0,0 +1,20 @@
{% load static %}
{% load compress %}
{% load i18n %}
{% compress js %}
<script type="text/javascript" src="{% static "pretixplugins/paypal2/pretix-paypal.js" %}"></script>
{% endcompress %}
<script type="text/plain" id="csp_nonce">{{ csp_nonce }}</script>
<script type="text/plain" id="paypal_client_id">{{ client_id }}</script>
<script type="text/plain" id="paypal_merchant_id">{{ merchant_id }}</script>
{% if disable_funding %}
<script type="text/plain" id="paypal_disable_funding">{{ disable_funding }}</script>
{% endif %}
{% if enable_funding %}
<script type="text/plain" id="paypal_enable_funding">{{ enable_funding }}</script>
{% endif %}
{% if debug %}
<script type="text/plain" id="paypal_buyer_country">{{ settings.debug_buyer_country }}</script>
{% endif %}

View File

@@ -0,0 +1,32 @@
{% load compress %}
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html>
<head>
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/x-scss" href="{% static "pretixbase/scss/cachedfiles.scss" %}"/>
{% endcompress %}
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
{% endcompress %}
</head>
<body>
<div class="container">
<h1>{% trans "The payment process has started in a new window." %}</h1>
<p>
{% trans "The window to enter your payment data was not opened or was closed?" %}
</p>
<p>
<a href="{{ url }}" target="_blank">
{% trans "Click here in order to open the window." %}
</a>
</p>
<script>
window.open('{{ url|escapejs }}');
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,49 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.conf.urls import include, re_path
from .views import (
PayView, XHRView, abort, isu_disconnect, isu_return, redirect_view,
success, webhook,
)
event_patterns = [
re_path(r'^paypal2/', include([
re_path(r'^abort/$', abort, name='abort'),
re_path(r'^return/$', success, name='return'),
re_path(r'^redirect/$', redirect_view, name='redirect'),
re_path(r'^xhr/$', XHRView.as_view(), name='xhr'),
re_path(r'^pay/(?P<order>[^/]+)/(?P<hash>[^/]+)/(?P<payment>[^/]+)/$', PayView.as_view(), name='pay'),
re_path(r'^(?P<order>[^/][^w]+)/(?P<secret>[A-Za-z0-9]+)/xhr/$', XHRView.as_view(), name='xhr'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/abort/', abort, name='abort'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/return/', success, name='return'),
re_path(r'w/(?P<cart_namespace>[a-zA-Z0-9]{16})/xhr/', XHRView.as_view(), name='xhr'),
])),
]
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal2/disconnect/', isu_disconnect,
name='isu.disconnect'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal/return/$', isu_return, name='isu.return'),
re_path(r'^_paypal/webhook/$', webhook, name='webhook'),
]

View File

@@ -0,0 +1,471 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Flavia Bastos
#
# 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 hashlib
import json
import logging
from decimal import Decimal
from django.contrib import messages
from django.core import signing
from django.db.models import Sum
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
)
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.views import View
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic import TemplateView
from django_scopes import scopes_disabled
from paypalcheckoutsdk import orders as pp_orders, payments as pp_payments
from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota
from pretix.base.payment import PaymentException
from pretix.base.services.cart import get_fees
from pretix.base.settings import GlobalSettingsObject
from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal2.client.customer.partners_merchantintegrations_get_request import (
PartnersMerchantIntegrationsGetRequest,
)
from pretix.plugins.paypal2.payment import PaypalMethod, PaypalMethod as Paypal
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.presale.views import get_cart, get_cart_total
logger = logging.getLogger('pretix.plugins.paypal2')
class PaypalOrderView:
def dispatch(self, request, *args, **kwargs):
try:
self.order = request.event.orders.get(code=kwargs['order'])
if hashlib.sha1(self.order.secret.lower().encode()).hexdigest() != kwargs['hash'].lower():
raise Http404('Unknown order')
except Order.DoesNotExist:
# Do a hash comparison as well to harden timing attacks
if 'abcdefghijklmnopq'.lower() == hashlib.sha1('abcdefghijklmnopq'.encode()).hexdigest():
raise Http404('Unknown order')
else:
raise Http404('Unknown order')
return super().dispatch(request, *args, **kwargs)
@cached_property
def payment(self):
return get_object_or_404(
self.order.payments,
pk=self.kwargs['payment'],
provider__istartswith='paypal',
)
def _redirect_to_order(self):
return redirect(eventreverse(self.request.event, 'presale:event.order', kwargs={
'order': self.order.code,
'secret': self.order.secret
}) + ('?paid=yes' if self.order.status == Order.STATUS_PAID else ''))
@xframe_options_exempt
def redirect_view(request, *args, **kwargs):
signer = signing.Signer(salt='safe-redirect')
try:
url = signer.unsign(request.GET.get('url', ''))
except signing.BadSignature:
return HttpResponseBadRequest('Invalid parameter')
r = render(request, 'pretixplugins/paypal2/redirect.html', {
'url': url,
})
r._csp_ignore = True
return r
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(xframe_options_exempt, 'dispatch')
class XHRView(View):
def post(self, request, *args, **kwargs):
if 'order' in self.kwargs:
order = self.request.event.orders.filter(code=self.kwargs['order']).select_related('event').first()
if order:
if order.secret.lower() == self.kwargs['secret'].lower():
pass
else:
order = None
else:
order = None
prov = PaypalMethod(request.event)
if order:
lp = order.payments.last()
if lp and lp.fee and lp.state not in (
OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED
):
fee = lp.fee.value - prov.calculate_fee(order.pending_sum - lp.fee.value)
else:
fee = prov.calculate_fee(order.pending_sum)
cart = {
'positions': order.positions,
'cart_total': order.pending_sum,
'cart_fees': Decimal('0.00'),
'payment_fee': fee,
}
else:
cart_total = get_cart_total(request)
cart_fees = Decimal('0.00')
for fee in get_fees(request.event, request, cart_total, None, None, get_cart(request)):
cart_fees += fee.value
cart = {
'positions': get_cart(request),
'cart_total': cart_total,
'cart_fees': cart_fees,
'payment_fee': prov.calculate_fee(cart_total + cart_fees),
}
paypal_order = prov._create_paypal_order(request, None, cart)
r = JsonResponse(paypal_order.dict() if paypal_order else {})
r._csp_ignore = True
return r
@method_decorator(xframe_options_exempt, 'dispatch')
class PayView(PaypalOrderView, TemplateView):
template_name = ''
def get(self, request, *args, **kwargs):
if self.payment.state != OrderPayment.PAYMENT_STATE_CREATED:
return self._redirect_to_order()
else:
r = render(request, 'pretixplugins/paypal2/pay.html', self.get_context_data())
return r
def post(self, request, *args, **kwargs):
self.payment.payment_provider.execute_payment(request, self.payment)
return self._redirect_to_order()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['order'] = self.order
ctx['oid'] = self.payment.info_data['id']
ctx['method'] = self.payment.payment_provider.method
return ctx
@scopes_disabled()
@event_permission_required('can_change_event_settings')
def isu_return(request, *args, **kwargs):
getparams = ['merchantId', 'merchantIdInPayPal', 'permissionsGranted', 'accountStatus', 'consentStatus', 'productIntentID', 'isEmailConfirmed']
sessionparams = ['payment_paypal_isu_event', 'payment_paypal_isu_tracking_id']
if not any(k in request.GET for k in getparams) or not any(k in request.session for k in sessionparams):
messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
return redirect(reverse('control:index'))
event = get_object_or_404(Event, pk=request.session['payment_paypal_isu_event'])
gs = GlobalSettingsObject()
prov = Paypal(event)
prov.init_api()
try:
req = PartnersMerchantIntegrationsGetRequest(
gs.settings.get('payment_paypal_connect_partner_merchant_id'),
request.GET.get('merchantIdInPayPal')
)
response = prov.client.execute(req)
except IOError as e:
messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
logger.exception('PayPal PartnersMerchantIntegrationsGetRequest: {}'.format(str(e)))
else:
params = ['merchant_id', 'tracking_id', 'payments_receivable', 'primary_email_confirmed']
if not any(k in response.result for k in params):
if 'message' in response.result:
messages.error(request, response.result.message)
else:
messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
else:
if response.result.tracking_id != request.session['payment_paypal_isu_tracking_id']:
messages.error(request, _('An error occurred during connecting with PayPal, please try again.'))
else:
if request.GET.get("isEmailConfirmed") == "false": # Yes - literal!
messages.warning(
request,
_('The e-mail address on your PayPal account has not yet been confirmed. You will need to do '
'this before you can start accepting payments.')
)
messages.success(
request,
_('Your PayPal account is now connected to pretix. You can change the settings in detail below.')
)
event.settings.payment_paypal_isu_merchant_id = response.result.merchant_id
# Just for good measure: Let's keep a copy of the granted scopes
for integration in response.result.oauth_integrations:
if integration.integration_type == 'OAUTH_THIRD_PARTY':
for third_party in integration.oauth_third_party:
if third_party.partner_client_id == prov.client.environment.client_id:
event.settings.payment_paypal_isu_scopes = third_party.scopes
return redirect(reverse('control:event.settings.payment.provider', kwargs={
'organizer': event.organizer.slug,
'event': event.slug,
'provider': 'paypal_settings'
}))
def success(request, *args, **kwargs):
token = request.GET.get('token')
payer = request.GET.get('PayerID')
request.session['payment_paypal_token'] = token
request.session['payment_paypal_payer'] = payer
urlkwargs = {}
if 'cart_namespace' in kwargs:
urlkwargs['cart_namespace'] = kwargs['cart_namespace']
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if request.session.get('payment_paypal_id', None):
if payment:
prov = Paypal(request.event)
try:
resp = prov.execute_payment(request, payment)
except PaymentException as e:
messages.error(request, str(e))
urlkwargs['step'] = 'payment'
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if resp:
return resp
else:
messages.error(request, _('Invalid response from PayPal received.'))
logger.error('Session did not contain payment_paypal_id')
urlkwargs['step'] = 'payment'
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
if payment:
return redirect(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
urlkwargs['step'] = 'confirm'
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs=urlkwargs))
def abort(request, *args, **kwargs):
messages.error(request, _('It looks like you canceled the PayPal payment'))
if request.session.get('payment_paypal_payment'):
payment = OrderPayment.objects.get(pk=request.session.get('payment_paypal_payment'))
else:
payment = None
if payment:
return redirect(eventreverse(request.event, 'presale:event.order', kwargs={
'order': payment.order.code,
'secret': payment.order.secret
}) + ('?paid=yes' if payment.order.status == Order.STATUS_PAID else ''))
else:
return redirect(eventreverse(request.event, 'presale:event.checkout', kwargs={'step': 'payment'}))
@csrf_exempt
@require_POST
@scopes_disabled()
def webhook(request, *args, **kwargs):
event_body = request.body.decode('utf-8').strip()
event_json = json.loads(event_body)
# V1/V2 Sorting -- Start
if 'event_type' not in event_json:
return HttpResponse("Invalid body, no event_type given", status=400)
if event_json['event_type'].startswith('PAYMENT.SALE.'):
from pretix.plugins.paypal.views import webhook
return webhook(request, *args, **kwargs)
# V1/V2 Sorting -- End
# We do not check the signature, we just use it as a trigger to look the charge up.
if 'resource_type' not in event_json:
return HttpResponse("Invalid body, no resource_type given", status=400)
if event_json['resource_type'] not in ["checkout-order", "refund", "capture"]:
return HttpResponse("Not interested in this resource type", status=200)
# Retrieve the Charge ID of the refunded payment
if event_json['resource_type'] == 'refund':
payloadid = get_link(event_json['resource']['links'], 'up')['href'].split('/')[-1]
else:
payloadid = event_json['resource']['id']
refs = [payloadid]
if event_json['resource'].get('supplementary_data', {}).get('related_ids', {}).get('order_id'):
refs.append(event_json['resource'].get('supplementary_data').get('related_ids').get('order_id'))
rso = ReferencedPayPalObject.objects.select_related('order', 'order__event').filter(
reference__in=refs
).first()
if rso:
event = rso.order.event
else:
rso = None
if hasattr(request, 'event'):
event = request.event
else:
return HttpResponse("Unable to detect event", status=200)
prov = Paypal(event)
prov.init_api()
try:
if rso:
payloadid = rso.payment.info_data['id']
sale = prov.client.execute(pp_orders.OrdersGetRequest(payloadid)).result
except IOError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Sale not found', status=500)
if rso and rso.payment:
payment = rso.payment
else:
payments = OrderPayment.objects.filter(order__event=event, provider='paypal',
info__icontains=sale['id'])
payment = None
for p in payments:
# Legacy PayPal info-data
if "purchase_units" not in p.info_data:
try:
req = pp_orders.OrdersGetRequest(p.info_data['cart'])
response = prov.client.execute(req)
p.info = json.dumps(response.result.dict())
p.save(update_fields=['info'])
p.refresh_from_db()
except IOError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Could not retrieve Order Data', status=500)
for res in p.info_data['purchase_units'][0]['payments']['captures']:
if res['status'] in ['COMPLETED', 'PARTIALLY_REFUNDED'] and res['id'] == sale['id']:
payment = p
break
if not payment:
return HttpResponse('Payment not found', status=200)
payment.order.log_action('pretix.plugins.paypal.event', data=event_json)
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'):
if event_json['resource_type'] == 'refund':
try:
req = pp_payments.RefundsGetRequest(event_json['resource']['id'])
refund = prov.client.execute(req).result
except IOError:
logger.exception('PayPal error on webhook. Event data: %s' % str(event_json))
return HttpResponse('Refund not found', status=500)
known_refunds = {r.info_data.get('id'): r for r in payment.refunds.all()}
if refund['id'] not in known_refunds:
payment.create_external_refund(
amount=abs(Decimal(refund['amount']['value'])),
info=json.dumps(refund.dict() if not isinstance(refund, dict) else refund)
)
elif known_refunds.get(refund['id']).state in (
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['status'] == 'COMPLETED':
known_refunds.get(refund['id']).done()
if 'seller_payable_breakdown' in refund and 'total_refunded_amount' in refund['seller_payable_breakdown']:
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
total_refunded_amount = Decimal(refund['seller_payable_breakdown']['total_refunded_amount']['value'])
if known_sum < total_refunded_amount:
payment.create_external_refund(
amount=total_refunded_amount - known_sum
)
elif sale['status'] == 'REFUNDED':
known_sum = payment.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_SOURCE_EXTERNAL)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if known_sum < payment.amount:
payment.create_external_refund(
amount=payment.amount - known_sum
)
elif payment.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED,
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) \
and sale['status'] == 'COMPLETED':
try:
payment.confirm()
except Quota.QuotaExceededException:
pass
return HttpResponse(status=200)
@event_permission_required('can_change_event_settings')
@require_POST
def isu_disconnect(request, **kwargs):
del request.event.settings.payment_paypal_connect_refresh_token
del request.event.settings.payment_paypal_connect_user_id
del request.event.settings.payment_paypal_isu_merchant_id
del request.event.settings.payment_paypal_isu_scopes
request.event.settings.payment_paypal__enabled = False
messages.success(request, _('Your PayPal account has been disconnected.BB'))
return redirect(reverse('control:event.settings.payment.provider', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'provider': 'paypal_settings'
}))
def get_link(links, rel):
for link in links:
if link['rel'] == rel:
return link
return None

View File

@@ -128,7 +128,7 @@
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, false, false, true)">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, false, false)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showUnpaidModal = false">
@@ -188,7 +188,7 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, true, true)">
<button type="button" class="btn btn-primary pull-right" @click="check(checkResult.position.secret, true, true)">
{{ $root.strings['modal.continue'] }}
</button>
<button type="button" class="btn btn-default" @click="showQuestionsModal = false">
@@ -296,7 +296,7 @@ export default {
},
methods: {
selectResult(res) {
this.check(res.id, false, false, false, false)
this.check(res.id, false, false, false)
},
answerSetM(qid, opid, checked) {
let arr = this.answers[qid] ? this.answers[qid].split(',') : [];
@@ -320,7 +320,7 @@ export default {
this.showQuestionsModal = false
this.answers = {}
},
check(id, ignoreUnpaid, keepAnswers, fallbackToSearch, untrusted) {
check(id, ignoreUnpaid, keepAnswers, fallbackToSearch) {
if (!keepAnswers) {
this.answers = {}
} else if (this.showQuestionsModal) {
@@ -339,11 +339,7 @@ export default {
this.$refs.input.blur()
})
let url = this.$root.api.lists + this.checkinlist.id + '/positions/' + encodeURIComponent(id) + '/redeem/?expand=item&expand=subevent&expand=variation'
if (untrusted) {
url += '&untrusted_input=true'
}
fetch(url, {
fetch(this.$root.api.lists + this.checkinlist.id + '/positions/' + encodeURIComponent(id) + '/redeem/?expand=item&expand=subevent&expand=variation', {
method: 'POST',
headers: {
'X-CSRFToken': document.querySelector("input[name=csrfmiddlewaretoken]").value,
@@ -443,7 +439,7 @@ export default {
startSearch(fallbackToScan) {
if (this.query.length >= 32 && fallbackToScan) {
// likely a secret, not a search result
this.check(this.query, false, false, true, true)
this.check(this.query, false, false, true)
return
}

View File

@@ -144,7 +144,7 @@
</div>
<p>
{% elif not var.display_price.gross %}
{% trans "FREE" context "price" %}
<span class="text-uppercase">{% trans "free" context "price" %}</span>
{% elif event.settings.display_net_prices %}
{{ var.display_price.net|money:event.currency }}
{% else %}

View File

@@ -356,6 +356,7 @@ INSTALLED_APPS = [
'pretix.plugins.banktransfer',
'pretix.plugins.stripe',
'pretix.plugins.paypal',
'pretix.plugins.paypal2',
'pretix.plugins.ticketoutputpdf',
'pretix.plugins.sendmail',
'pretix.plugins.statistics',

View File

@@ -203,6 +203,8 @@ setup(
'openpyxl==3.0.*',
'packaging',
'paypalrestsdk==1.13.*',
'paypal-checkout-serversdk==1.0.*',
'PyJWT==2.0.*',
'phonenumberslite==8.12.*',
'Pillow==9.1.*',
'protobuf==3.19.*',

View File

@@ -1199,6 +1199,7 @@ def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clis
), {
'force': True
}, format='json')
print(resp.data)
assert resp.status_code == 400
assert resp.data["status"] == "error"
assert resp.data["reason"] == "already_redeemed"
@@ -1218,43 +1219,3 @@ def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clis
assert resp.data["reason"] == "invalid"
with scopes_disabled():
assert not Checkin.objects.last()
@pytest.mark.django_db
def test_redeem_by_id_not_allowed_if_pretixscan(device, device_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
device.software_brand = "pretixSCAN"
device.software_version = "1.14.2"
device.save()
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, p.pk
), {
'force': True
}, format='json')
print(resp.data)
assert resp.status_code == 404
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, p.secret
), {
'force': True
}, format='json')
assert resp.status_code == 201
@pytest.mark.django_db
def test_redeem_by_id_not_allowed_if_untrusted(device, device_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/?untrusted_input=true'.format(
organizer.slug, event.slug, clist.pk, p.pk
), {
'force': True
}, format='json')
assert resp.status_code == 404
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/?untrusted_input=true'.format(
organizer.slug, event.slug, clist.pk, p.secret
), {
'force': True
}, format='json')
assert resp.status_code == 201

View File

@@ -299,16 +299,16 @@ class EventsTest(SoupTest):
def test_plugins(self):
doc = self.get_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug))
self.assertIn("PayPal", doc.select(".form-plugins")[0].text)
self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.paypal\"]")[0].text)
self.assertIn("Stripe", doc.select(".form-plugins")[0].text)
self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.stripe\"]")[0].text)
doc = self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug),
{'plugin:pretix.plugins.paypal': 'enable'})
self.assertIn("Disable", doc.select("[name=\"plugin:pretix.plugins.paypal\"]")[0].text)
{'plugin:pretix.plugins.stripe': 'enable'})
self.assertIn("Disable", doc.select("[name=\"plugin:pretix.plugins.stripe\"]")[0].text)
doc = self.post_doc('/control/event/%s/%s/settings/plugins' % (self.orga1.slug, self.event1.slug),
{'plugin:pretix.plugins.paypal': 'disable'})
self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.paypal\"]")[0].text)
{'plugin:pretix.plugins.stripe': 'disable'})
self.assertIn("Enable", doc.select("[name=\"plugin:pretix.plugins.stripe\"]")[0].text)
def test_testmode_enable(self):
self.event1.testmode = False

View File

@@ -57,12 +57,8 @@ def test_require_live(event, client):
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
assert r.status_code == 302
r = client.get('/mrmcd/2015/paypal/webhook/', follow=False)
assert r.status_code == 405
event.live = False
event.save()
r = client.get('/mrmcd/2015/paypal/abort/', follow=False)
assert r.status_code == 403
r = client.get('/mrmcd/2015/paypal/webhook/', follow=False)
assert r.status_code == 405

View File

@@ -0,0 +1,33 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# 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.

View File

@@ -241,7 +241,7 @@ def test_webhook_all_good(env, client, monkeypatch):
@pytest.mark.django_db
def test_webhook_global(env, client, monkeypatch):
def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
@@ -282,49 +282,9 @@ def test_webhook_global(env, client, monkeypatch):
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
with scopes_disabled():
order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING)
charge = get_test_charge(env[1])
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
client.post('/dummy/dummy/paypal/webhook/', json.dumps(
{
"id": "WH-2WR32451HC0233532-67976317FL4543714",
"create_time": "2014-10-23T17:23:52Z",
"resource_type": "sale",
"event_type": "PAYMENT.SALE.COMPLETED",
"summary": "A successful sale payment was made for $ 0.48 USD",
"resource": {
"amount": {
"total": "-0.01",
"currency": "USD"
},
"id": "36C38912MN9658832",
"parent_payment": "PAY-5YK922393D847794YKER7MUI",
"update_time": "2014-10-31T15:41:51Z",
"state": "completed",
"create_time": "2014-10-31T15:41:51Z",
"links": [],
"sale_id": "9T0916710M1105906"
},
"links": [],
"event_version": "1.0"
}
), content_type='application_json')
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_refund1(env, client, monkeypatch):
order = env[1]
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
@@ -332,8 +292,9 @@ def test_webhook_refund1(env, client, monkeypatch):
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/dummy/dummy/paypal/webhook/', json.dumps(
client.post('/_paypal/webhook/', json.dumps(
{
# Sample obtained in a sandbox webhook
"id": "WH-9K829080KA1622327-31011919VC6498738",
@@ -380,6 +341,7 @@ def test_webhook_refund1(env, client, monkeypatch):
@pytest.mark.django_db
def test_webhook_refund2(env, client, monkeypatch):
order = env[1]
charge = get_test_charge(env[1])
charge['state'] = 'refunded'
refund = get_test_refund(env[1])
@@ -387,8 +349,9 @@ def test_webhook_refund2(env, client, monkeypatch):
monkeypatch.setattr("paypalrestsdk.Sale.find", lambda *args: charge)
monkeypatch.setattr("paypalrestsdk.Refund.find", lambda *args: refund)
monkeypatch.setattr("pretix.plugins.paypal.payment.Paypal.init_api", lambda *args: None)
ReferencedPayPalObject.objects.create(order=order, reference="PAY-5YK922393D847794YKER7MUI")
client.post('/dummy/dummy/paypal/webhook/', json.dumps(
client.post('/_paypal/webhook/', json.dumps(
{
# Sample obtained in the webhook simulator
"id": "WH-2N242548W9943490U-1JU23391CS4765624",

View File

@@ -0,0 +1,33 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# 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.

View File

@@ -0,0 +1,141 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# 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 datetime
import pytest
from django.utils.timezone import now
from pretix.base.models import (
CartPosition, Event, Item, ItemCategory, Organizer, Quota,
)
from pretix.testutils.sessions import add_cart_session, get_cart_session_key
@pytest.fixture
def env(client):
orga = Organizer.objects.create(name='CCC', slug='ccc')
event = Event.objects.create(
organizer=orga, name='30C3', slug='30c3',
date_from=datetime.datetime(now().year + 1, 12, 26, tzinfo=datetime.timezone.utc),
plugins='pretix.plugins.paypal2',
live=True
)
category = ItemCategory.objects.create(event=event, name="Everything", position=0)
quota_tickets = Quota.objects.create(event=event, name='Tickets', size=5)
ticket = Item.objects.create(event=event, name='Early-bird ticket',
category=category, default_price=23, admission=True)
quota_tickets.items.add(ticket)
event.settings.set('attendee_names_asked', False)
event.settings.set('payment_paypal__enabled', True)
event.settings.set('payment_paypal__fee_abs', 3)
event.settings.set('payment_paypal_endpoint', 'sandbox')
event.settings.set('payment_paypal_client_id', '12345')
event.settings.set('payment_paypal_secret', '12345')
add_cart_session(client, event, {'email': 'admin@localhost'})
return client, ticket
class Object():
pass
def get_test_order():
return {'id': '04F89033701558004',
'intent': 'CAPTURE',
'status': 'APPROVED',
'purchase_units': [{'reference_id': 'default',
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'payee': {'merchant_id': 'G6R2B9YXADKWW'},
'description': 'Event tickets for PayPal v2',
'custom_id': 'PAYPALV2',
'soft_descriptor': 'MARTINFACIL'}],
'payer': {'name': {'given_name': 'test', 'surname': 'buyer'},
'email_address': 'dummy@dummy.dummy',
'payer_id': 'Q739JNKWH67HE',
'address': {'country_code': 'DE'}},
'create_time': '2022-04-28T13:10:58Z',
'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/04F89033701558004',
'rel': 'self',
'method': 'GET'},
{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/04F89033701558004',
'rel': 'update',
'method': 'PATCH'},
{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/04F89033701558004/capture',
'rel': 'capture',
'method': 'POST'}]}
@pytest.mark.django_db
def test_payment(env, monkeypatch):
def init_api(self):
class Client():
environment = Object()
environment.client_id = '12345'
environment.merchant_id = 'G6R2B9YXADKWW'
def execute(self, request):
response = Object()
response.result = Object()
response.result.status = 'APPROVED'
return response
self.client = Client()
order = get_test_order()
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: order)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
client, ticket = env
session_key = get_cart_session_key(client, ticket.event)
CartPosition.objects.create(
event=ticket.event, cart_id=session_key, item=ticket,
price=23, expires=now() + datetime.timedelta(minutes=10)
)
client.get('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), follow=True)
client.post('/%s/%s/checkout/questions/' % (ticket.event.organizer.slug, ticket.event.slug), {
'email': 'admin@localhost'
}, follow=True)
session = client.session
session['payment_paypal_oid'] = '04F89033701558004'
session.save()
response = client.post('/%s/%s/checkout/payment/' % (ticket.event.organizer.slug, ticket.event.slug), {
'payment': 'paypal',
'payment_paypal_wallet_oid': '04F89033701558004',
'payment_paypal_wallet_payer': 'Q739JNKWH67HE',
})
print(response.content.decode())
assert response['Location'] == '/ccc/30c3/checkout/confirm/'

View File

@@ -0,0 +1,67 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
#
# This file may have since been changed and any changes are released under the terms of AGPLv3 as described above. A
# full history of changes and contributors is available at <https://github.com/pretix/pretix>.
#
# This file contains Apache-licensed contributions copyrighted by: Tobias Kunze
#
# 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 datetime
import pytest
from pretix.base.models import Event, Organizer, Team, User
@pytest.fixture
def env(client):
orga = Organizer.objects.create(name='CCC', slug='ccc')
event = Event.objects.create(
organizer=orga, name='30C3', slug='30c3',
date_from=datetime.datetime(2013, 12, 26, tzinfo=datetime.timezone.utc),
plugins='pretix.plugins.paypal2',
live=True
)
event.settings.set('attendee_names_asked', False)
event.settings.set('payment_paypal__enabled', True)
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
t = Team.objects.create(organizer=event.organizer, can_change_event_settings=True)
t.members.add(user)
t.limit_events.add(event)
client.force_login(user)
return client, event
@pytest.mark.django_db
def test_settings(env):
client, event = env
response = client.get('/control/event/%s/%s/settings/payment/paypal_settings' % (event.organizer.slug, event.slug),
follow=True)
assert response.status_code == 200
assert 'paypal__enabled' in response.rendered_content

View File

@@ -0,0 +1,688 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import json
from datetime import timedelta
from decimal import Decimal
import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import (
Event, Order, OrderPayment, OrderRefund, Organizer, Team, User,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
@pytest.fixture
def env():
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy', plugins='pretix.plugins.paypal2',
date_from=now(), live=True
)
t = Team.objects.create(organizer=event.organizer, can_view_orders=True, can_change_orders=True)
t.members.add(user)
t.limit_events.add(event)
o1 = Order.objects.create(
code='FOOBAR', event=event, email='dummy@dummy.test',
status=Order.STATUS_PAID,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal('43.59'),
)
o1.payments.create(
amount=o1.total,
provider='paypal',
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
info=json.dumps({
"id": "806440346Y391300T",
"status": "COMPLETED",
"purchase_units": [
{
"reference_id": "default",
"shipping": {
"name": {
"full_name": "test buyer"
}
},
"payments": {
"captures": [
{
"id": "22A4162004478570J",
"status": "COMPLETED",
"amount": {
"currency_code": "EUR",
"value": "43.59"
},
"final_capture": True,
"disbursement_mode": "INSTANT",
"seller_protection": {
"status": "ELIGIBLE",
"dispute_categories": [
"ITEM_NOT_RECEIVED",
"UNAUTHORIZED_TRANSACTION"
]
},
"seller_receivable_breakdown": {
"gross_amount": {
"currency_code": "EUR",
"value": "43.59"
},
"paypal_fee": {
"currency_code": "EUR",
"value": "1.18"
},
"net_amount": {
"currency_code": "EUR",
"value": "42.41"
}
},
"custom_id": "Order PAYPALV2-JWJGC",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund",
"rel": "refund",
"method": "POST"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
"rel": "up",
"method": "GET"
}
],
"create_time": "2022-04-28T12:00:22Z",
"update_time": "2022-04-28T12:00:22Z"
}
]
}
}
],
"payer": {
"name": {
"given_name": "test",
"surname": "buyer"
},
"email_address": "dummy@dummy.dummy",
"payer_id": "Q739JNKWH67HE",
"address": {
"country_code": "DE"
}
},
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
"rel": "self",
"method": "GET"
}
]
})
)
return event, o1
def get_test_order():
return {'id': '806440346Y391300T',
'intent': 'CAPTURE',
'status': 'COMPLETED',
'purchase_units': [{'reference_id': 'default',
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'payee': {'email_address': 'dummy-facilitator@dummy.dummy',
'merchant_id': 'G6R2B9YXADKWW'},
'description': 'Order JWJGC for PayPal v2',
'custom_id': 'Order PAYPALV2-JWJGC',
'soft_descriptor': 'MARTINFACIL',
'payments': {'captures': [{'id': '22A4162004478570J',
'status': 'COMPLETED',
'amount': {'currency_code': 'EUR', 'value': '43.59'},
'final_capture': True,
'disbursement_mode': 'INSTANT',
'seller_protection': {'status': 'ELIGIBLE',
'dispute_categories': [
'ITEM_NOT_RECEIVED',
'UNAUTHORIZED_TRANSACTION']},
'seller_receivable_breakdown': {
'gross_amount': {'currency_code': 'EUR',
'value': '43.59'},
'paypal_fee': {'currency_code': 'EUR', 'value': '1.18'},
'net_amount': {'currency_code': 'EUR',
'value': '42.41'}},
'custom_id': 'Order PAYPALV2-JWJGC',
'links': [{
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J',
'rel': 'self',
'method': 'GET'},
{
'href': 'https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund',
'rel': 'refund',
'method': 'POST'},
{
'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
'rel': 'up',
'method': 'GET'}],
'create_time': '2022-04-28T12:00:22Z',
'update_time': '2022-04-28T12:00:22Z'}]}}],
'payer': {'name': {'given_name': 'test', 'surname': 'buyer'},
'email_address': 'dummy@dummy.dummy',
'payer_id': 'Q739JNKWH67HE',
'address': {'country_code': 'DE'}},
'create_time': '2022-04-28T11:59:59Z',
'update_time': '2022-04-28T12:00:22Z',
'links': [{'href': 'https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T',
'rel': 'self',
'method': 'GET'}]}
def get_test_refund():
return {
"id": "1YK122615V244890X",
"amount": {
"currency_code": "EUR",
"value": "43.59"
},
"seller_payable_breakdown": {
"gross_amount": {
"currency_code": "EUR",
"value": "43.59"
},
"paypal_fee": {
"currency_code": "EUR",
"value": "1.18"
},
"net_amount": {
"currency_code": "EUR",
"value": "42.41"
},
"total_refunded_amount": {
"currency_code": "EUR",
"value": "43.59"
}
},
"custom_id": "Order PAYPALV2-JWJGC",
"status": "COMPLETED",
"create_time": "2022-04-28T07:50:56-07:00",
"update_time": "2022-04-28T07:50:56-07:00",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/refunds/1YK122615V244890X",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J",
"rel": "up",
"method": "GET"
}
]
}
class Object():
pass
def init_api(self):
class Client():
environment = Object()
environment.client_id = '12345'
environment.merchant_id = 'G6R2B9YXADKWW'
def execute(self, request):
response = Object()
response.result = request
return response
self.client = Client()
@pytest.mark.django_db
def test_webhook_all_good(env, client, monkeypatch):
order = env[1]
pp_order = get_test_order()
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="806440346Y391300T")
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-4T867178D0574904F-7TT11736YU643990P",
"create_time": "2022-04-28T12:00:37.077Z",
"resource_type": "checkout-order",
"event_type": "CHECKOUT.ORDER.COMPLETED",
"summary": "Checkout Order Completed",
"resource": {
"update_time": "2022-04-28T12:00:22Z",
"create_time": "2022-04-28T11:59:59Z",
"purchase_units": [
{
"reference_id": "default",
"amount": {
"currency_code": "EUR",
"value": "43.59"
},
"payee": {
"email_address": "dummy-facilitator@dummy.dummy",
"merchant_id": "G6R2B9YXADKWW"
},
"description": "Order JWJGC for PayPal v2",
"custom_id": "Order PAYPALV2-JWJGC",
"soft_descriptor": "MARTINFACIL",
"payments": {
"captures": [
{
"id": "22A4162004478570J",
"status": "COMPLETED",
"amount": {
"currency_code": "EUR",
"value": "43.59"
},
"final_capture": True,
"disbursement_mode": "INSTANT",
"seller_protection": {
"status": "ELIGIBLE",
"dispute_categories": [
"ITEM_NOT_RECEIVED",
"UNAUTHORIZED_TRANSACTION"
]
},
"seller_receivable_breakdown": {
"gross_amount": {
"currency_code": "EUR",
"value": "43.59"
},
"paypal_fee": {
"currency_code": "EUR",
"value": "1.18"
},
"net_amount": {
"currency_code": "EUR",
"value": "42.41"
}
},
"custom_id": "Order PAYPALV2-JWJGC",
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J",
"rel": "self",
"method": "GET"
},
{
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund",
"rel": "refund",
"method": "POST"
},
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
"rel": "up",
"method": "GET"
}
],
"create_time": "2022-04-28T12:00:22Z",
"update_time": "2022-04-28T12:00:22Z"
}
]
}
}
],
"links": [
{
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T",
"rel": "self",
"method": "GET"
}
],
"id": "806440346Y391300T",
"intent": "CAPTURE",
"payer": {
"name": {
"given_name": "test",
"surname": "buyer"
},
"email_address": "dummy@dummy.dummy",
"payer_id": "Q739JNKWH67HE",
"address": {
"country_code": "DE"
}
},
"status": "COMPLETED"
},
"status": "SUCCESS",
"links": [
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4T867178D0574904F-7TT11736YU643990P",
"rel": "self",
"method": "GET",
"encType": "application/json"
},
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-4T867178D0574904F-7TT11736YU643990P/resend",
"rel": "resend",
"method": "POST",
"encType": "application/json"
}
],
"event_version": "1.0",
"resource_version": "2.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_mark_paid(env, client, monkeypatch):
order = env[1]
order.status = Order.STATUS_PENDING
order.save()
with scopes_disabled():
order.payments.update(state=OrderPayment.PAYMENT_STATE_PENDING)
pp_order = get_test_order()
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="806440346Y391300T")
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-88L014580L300952M-4BX97184625330932",
"create_time": "2022-04-28T12:00:26.840Z",
"resource_type": "capture",
"event_type": "PAYMENT.CAPTURE.COMPLETED",
"summary": "Payment completed for EUR 43.59 EUR",
"resource": {
"disbursement_mode": "INSTANT",
"amount": {
"value": "43.59",
"currency_code": "EUR"
},
"seller_protection": {
"dispute_categories": [
"ITEM_NOT_RECEIVED",
"UNAUTHORIZED_TRANSACTION"
],
"status": "ELIGIBLE"
},
"supplementary_data": {
"related_ids": {
"order_id": "806440346Y391300T"
}
},
"update_time": "2022-04-28T12:00:22Z",
"create_time": "2022-04-28T12:00:22Z",
"final_capture": True,
"seller_receivable_breakdown": {
"paypal_fee": {
"value": "1.18",
"currency_code": "EUR"
},
"gross_amount": {
"value": "43.59",
"currency_code": "EUR"
},
"net_amount": {
"value": "42.41",
"currency_code": "EUR"
}
},
"custom_id": "Order PAYPALV2-JWJGC",
"links": [
{
"method": "GET",
"rel": "self",
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J"
},
{
"method": "POST",
"rel": "refund",
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J/refund"
},
{
"method": "GET",
"rel": "up",
"href": "https://api.sandbox.paypal.com/v2/checkout/orders/806440346Y391300T"
}
],
"id": "22A4162004478570J",
"status": "COMPLETED"
},
"status": "SUCCESS",
"links": [
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-88L014580L300952M-4BX97184625330932",
"rel": "self",
"method": "GET",
"encType": "application/json"
},
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-88L014580L300952M-4BX97184625330932/resend",
"rel": "resend",
"method": "POST",
"encType": "application/json"
}
],
"event_version": "1.0",
"resource_version": "2.0"
}
), content_type='application_json')
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
@pytest.mark.django_db
def test_webhook_refund1(env, client, monkeypatch):
order = env[1]
pp_order = get_test_order()
pp_refund = get_test_refund()
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order)
monkeypatch.setattr("paypalcheckoutsdk.payments.RefundsGetRequest", lambda *args: pp_refund)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="22A4162004478570J")
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-5LJ60612747357339-66248625WA926672S",
"create_time": "2022-04-28T14:51:00.318Z",
"resource_type": "refund",
"event_type": "PAYMENT.CAPTURE.REFUNDED",
"summary": "A EUR 43.59 EUR capture payment was refunded",
"resource": {
"seller_payable_breakdown": {
"total_refunded_amount": {
"value": "43.59",
"currency_code": "EUR"
},
"paypal_fee": {
"value": "1.18",
"currency_code": "EUR"
},
"gross_amount": {
"value": "42.41",
"currency_code": "EUR"
},
"net_amount": {
"value": "43.59",
"currency_code": "EUR"
}
},
"amount": {
"value": "43.59",
"currency_code": "EUR"
},
"update_time": "2022-04-28T07:50:56-07:00",
"create_time": "2022-04-28T07:50:56-07:00",
"custom_id": "Order PAYPALV2-JWJGC",
"links": [
{
"method": "GET",
"rel": "self",
"href": "https://api.sandbox.paypal.com/v2/payments/refunds/1YK122615V244890X"
},
{
"method": "GET",
"rel": "up",
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J"
}
],
"id": "1YK122615V244890X",
"status": "COMPLETED"
},
"status": "SUCCESS",
"links": [
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-5LJ60612747357339-66248625WA926672S",
"rel": "self",
"method": "GET",
"encType": "application/json"
},
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-5LJ60612747357339-66248625WA926672S/resend",
"rel": "resend",
"method": "POST",
"encType": "application/json"
}
],
"event_version": "1.0",
"resource_version": "2.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
with scopes_disabled():
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL
@pytest.mark.django_db
def test_webhook_refund2(env, client, monkeypatch):
order = env[1]
pp_order = get_test_order()
pp_refund = get_test_refund()
monkeypatch.setattr("paypalcheckoutsdk.orders.OrdersGetRequest", lambda *args: pp_order)
monkeypatch.setattr("paypalcheckoutsdk.payments.RefundsGetRequest", lambda *args: pp_refund)
monkeypatch.setattr("pretix.plugins.paypal2.payment.PaypalMethod.init_api", init_api)
with scopes_disabled():
ReferencedPayPalObject.objects.create(order=order, payment=order.payments.first(),
reference="22A4162004478570J")
client.post('/_paypal/webhook/', json.dumps(
{
"id": "WH-7FL378472F5218625-6WC87835CR8751809",
"create_time": "2022-04-28T14:56:08.160Z",
"resource_type": "refund",
"event_type": "PAYMENT.CAPTURE.REFUNDED",
"summary": "A EUR 43.59 EUR capture payment was refunded",
"resource": {
"seller_payable_breakdown": {
"total_refunded_amount": {
"value": "43.59",
"currency_code": "EUR"
},
"paypal_fee": {
"value": "01.18",
"currency_code": "EUR"
},
"gross_amount": {
"value": "43.59",
"currency_code": "EUR"
},
"net_amount": {
"value": "42.41",
"currency_code": "EUR"
}
},
"amount": {
"value": "43.59",
"currency_code": "EUR"
},
"update_time": "2022-04-28T07:56:04-07:00",
"create_time": "2022-04-28T07:56:04-07:00",
"custom_id": "Order PAYPALV2-JWJGC",
"links": [
{
"method": "GET",
"rel": "self",
"href": "https://api.sandbox.paypal.com/v2/payments/refunds/3K87087190824201K"
},
{
"method": "GET",
"rel": "up",
"href": "https://api.sandbox.paypal.com/v2/payments/captures/22A4162004478570J"
}
],
"id": "3K87087190824201K",
"status": "COMPLETED"
},
"status": "SUCCESS",
"links": [
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-7FL378472F5218625-6WC87835CR8751809",
"rel": "self",
"method": "GET",
"encType": "application/json"
},
{
"href": "https://api.sandbox.paypal.com/v1/notifications/webhooks-events/WH-7FL378472F5218625-6WC87835CR8751809/resend",
"rel": "resend",
"method": "POST",
"encType": "application/json"
}
],
"event_version": "1.0",
"resource_version": "2.0"
}
), content_type='application_json')
order = env[1]
order.refresh_from_db()
assert order.status == Order.STATUS_PAID
with scopes_disabled():
r = order.refunds.first()
assert r.provider == 'paypal'
assert r.amount == order.total
assert r.payment == order.payments.first()
assert r.state == OrderRefund.REFUND_STATE_EXTERNAL
assert r.source == OrderRefund.REFUND_SOURCE_EXTERNAL