PayPal: Migrate to Order v2 API and ISU authentication (#2493)

Co-authored-by: Raphael Michel <michel@rami.io>
This commit is contained in:
Martin Gross
2022-04-28 18:42:19 +02:00
committed by GitHub
parent 129d206946
commit 9af1565db1
21 changed files with 2228 additions and 675 deletions

View File

@@ -700,8 +700,8 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Question,
Quota,
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.

View File

@@ -33,7 +33,6 @@
# License for the specific language governing permissions and limitations under the License.
from django.apps import AppConfig
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
@@ -55,12 +54,3 @@ class PaypalApp(AppConfig):
def ready(self):
from . import signals # NOQA
@cached_property
def compatibility_errors(self):
errs = []
try:
import paypalrestsdk # NOQA
except ImportError:
errs.append("Python package 'paypalrestsdk' is not installed.")
return errs

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)

File diff suppressed because it is too large Load Diff

View File

@@ -24,18 +24,27 @@ 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.paypal.payment import PaypalMethod
from pretix.presale.signals import html_head, process_response
@receiver(register_payment_providers, dispatch_uid="payment_paypal")
def register_payment_provider(sender, **kwargs):
from .payment import Paypal
return Paypal
from .payment import PaypalAPM, PaypalSettingsHolder, PaypalWallet
return [PaypalSettingsHolder, PaypalWallet, PaypalAPM]
@receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display")
@@ -52,6 +61,7 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
'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:
@@ -67,15 +77,20 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
def register_global_settings(sender, **kwargs):
return OrderedDict([
('payment_paypal_connect_client_id', forms.CharField(
label=_('PayPal Connect: Client ID'),
label=_('PayPal ISU/Connect: Client ID'),
required=False,
)),
('payment_paypal_connect_secret_key', SecretKeySettingsField(
label=_('PayPal Connect: Secret key'),
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 Connect Endpoint'),
label=_('PayPal ISU/Connect Endpoint'),
initial='live',
choices=(
('live', 'Live'),
@@ -83,3 +98,73 @@ def register_global_settings(sender, **kwargs):
),
)),
])
@receiver(html_head, dispatch_uid="payment_paypal_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:paypal" and url.url_name == "pay")
):
provider.init_api()
template = get_template('pretixplugins/paypal/presale_head.html')
ctx = {
'client_id': provider.client.environment.client_id,
'merchant_id': provider.client.environment.merchant_id,
'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_paypal_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:paypal" 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...
'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,281 @@
/*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);
}
}
};
},
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) {
return data.id;
});
},
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();
(async() => {
while(!pretixpaypal.paypal)
await new Promise(resolve => setTimeout(resolve, 1000));
pretixpaypal.ready();
})();
});

View File

@@ -1,6 +1,14 @@
{% load i18n %}
<p>{% blocktrans trimmed %}
The total amount listed above will be withdrawn from your PayPal account after the
confirmation of your purchase.
{% endblocktrans %}</p>
<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

@@ -1,6 +1,18 @@
{% load i18n %}
<p>{% 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 %}</p>
<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

@@ -2,17 +2,17 @@
{% if payment_info %}
<dl class="dl-horizontal">
<dt>{% trans "Payment ID" %}</dt>
<dt>{% trans "Order ID" %}</dt>
<dd>{{ payment_info.id }}</dd>
<dt>{% trans "Sale ID" %}</dt>
<dd>{{ sale_id|default_if_none:"?" }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>{{ payment_info.status }}</dd>
<dt>{% trans "Payer" %}</dt>
<dd>{{ payment_info.payer.payer_info.email }}</dd>
<dd>{{ payment_info.payer.email_address }}</dd>
<dt>{% trans "Last update" %}</dt>
<dd>{{ payment_info.update_time }}</dd>
<dd>{{ payment_info.purchase_units.0.payments.captures.0.update_time }}</dd>
<dt>{% trans "Total value" %}</dt>
<dd>{{ payment_info.transactions.0.amount.total }}</dd>
<dd>{{ payment_info.purchase_units.0.payments.captures.0.amount.value }}</dd>
<dt>{% trans "Currency" %}</dt>
<dd>{{ payment_info.transactions.0.amount.currency }}</dd>
<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,20 @@
{% load static %}
{% load compress %}
{% load i18n %}
{% compress js %}
<script type="text/javascript" src="{% static "pretixplugins/paypal/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

@@ -24,7 +24,8 @@ 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,
PayView, XHRView, abort, isu_disconnect, isu_return, redirect_view,
success, webhook,
)
event_patterns = [
@@ -32,17 +33,21 @@ event_patterns = [
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'),
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'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/paypal/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'),
re_path(r'^_paypal/oauth_return/$', oauth_return, name='oauth.return'),
]

View File

@@ -31,36 +31,73 @@
# 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
import paypalrestsdk
import paypalrestsdk.exceptions
from django.contrib import messages
from django.core import signing
from django.db.models import Sum
from django.http import HttpResponse, HttpResponseBadRequest
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.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 paypalrestsdk.openid_connect import Tokeninfo
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.settings import GlobalSettingsObject
from pretix.control.permissions import event_permission_required
from pretix.multidomain.urlreverse import eventreverse
from pretix.plugins.paypal.client.customer.partners_merchantintegrations_get_request import (
PartnersMerchantIntegrationsGetRequest,
)
from pretix.plugins.paypal.models import ReferencedPayPalObject
from pretix.plugins.paypal.payment import Paypal
from pretix.plugins.paypal.payment import PaypalMethod, PaypalMethod as Paypal
from pretix.presale.views import get_cart, get_cart_total
logger = logging.getLogger('pretix.plugins.paypal')
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')
@@ -76,40 +113,136 @@ def redirect_view(request, *args, **kwargs):
return r
@method_decorator(csrf_exempt, name='dispatch')
@method_decorator(xframe_options_exempt, 'dispatch')
class XHRView(TemplateView):
template_name = ''
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.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,
'total': order.pending_sum,
'fee': fee,
}
else:
cart = {
'positions': get_cart(request),
'total': get_cart_total(request),
'fee': prov.calculate_fee(get_cart_total(request)),
}
paypal_order = prov._create_paypal_order(request, None, cart)
r = JsonResponse(paypal_order.dict())
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/paypal/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()
def oauth_return(request, *args, **kwargs):
if 'payment_paypal_oauth_event' not in request.session:
@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_oauth_event'])
event = get_object_or_404(Event, pk=request.session['payment_paypal_isu_event'])
gs = GlobalSettingsObject()
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')
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:
messages.success(request,
_('Your PayPal account is now connected to pretix. You can change the settings in '
'detail below.'))
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_connect_refresh_token = tokeninfo['refresh_token']
event.settings.payment_paypal_connect_user_id = userinfo.email
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'
'provider': 'paypal_settings'
}))
def success(request, *args, **kwargs):
pid = request.GET.get('paymentId')
token = request.GET.get('token')
payer = request.GET.get('PayerID')
request.session['payment_paypal_token'] = token
@@ -124,7 +257,7 @@ def success(request, *args, **kwargs):
else:
payment = None
if pid == request.session.get('payment_paypal_id', None):
if request.session.get('payment_paypal_id', None):
if payment:
prov = Paypal(request.event)
try:
@@ -178,18 +311,20 @@ def webhook(request, *args, **kwargs):
# 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 ('sale', 'refund'):
if event_json['resource_type'] not in ["checkout-order", "refund", "capture"]:
return HttpResponse("Not interested in this resource type", status=200)
if event_json['resource_type'] == 'sale':
saleid = event_json['resource']['id']
# 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:
saleid = event_json['resource']['sale_id']
payloadid = event_json['resource']['id']
try:
refs = [saleid]
if event_json['resource'].get('parent_payment'):
refs.append(event_json['resource'].get('parent_payment'))
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').get(
reference__in=refs
@@ -206,8 +341,10 @@ def webhook(request, *args, **kwargs):
prov.init_api()
try:
sale = paypalrestsdk.Sale.find(saleid)
except paypalrestsdk.exceptions.ConnectionError:
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)
@@ -218,47 +355,58 @@ def webhook(request, *args, **kwargs):
info__icontains=sale['id'])
payment = None
for p in payments:
payment_info = p.info_data
for res in payment_info['transactions'][0]['related_resources']:
for k, v in res.items():
if k == 'sale' and v['id'] == sale['id']:
payment = p
break
# 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['state'] in ('partially_refunded', 'refunded'):
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and sale['status'] in ('PARTIALLY_REFUNDED', 'REFUNDED', 'COMPLETED'):
if event_json['resource_type'] == 'refund':
try:
refund = paypalrestsdk.Refund.find(event_json['resource']['id'])
except paypalrestsdk.exceptions.ConnectionError:
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']['total'])),
info=json.dumps(refund.to_dict() if not isinstance(refund, dict) else 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['state'] == 'completed':
OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT) and refund['status'] == 'COMPLETED':
known_refunds.get(refund['id']).done()
if 'total_refunded_amount' in refund:
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['total_refunded_amount']['value'])
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['state'] == 'refunded':
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)
@@ -269,7 +417,8 @@ def webhook(request, *args, **kwargs):
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['state'] == 'completed':
OrderPayment.PAYMENT_STATE_CANCELED, OrderPayment.PAYMENT_STATE_FAILED) \
and sale['status'] == 'COMPLETED':
try:
payment.confirm()
except Quota.QuotaExceededException:
@@ -280,14 +429,24 @@ def webhook(request, *args, **kwargs):
@event_permission_required('can_change_event_settings')
@require_POST
def oauth_disconnect(request, **kwargs):
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.'))
return redirect(reverse('control:event.settings.payment.provider', kwargs={
'organizer': request.event.organizer.slug,
'event': request.event.slug,
'provider': 'paypal'
'provider': 'paypal_settings'
}))
def get_link(links, rel):
for link in links:
if link['rel'] == rel:
return link
return None