mirror of
https://github.com/pretix/pretix.git
synced 2026-05-04 15:04:03 +00:00
1111 lines
50 KiB
Python
1111 lines
50 KiB
Python
#
|
|
# 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
|
|
import logging
|
|
import urllib.parse
|
|
from collections import OrderedDict
|
|
from datetime import timedelta
|
|
from decimal import Decimal
|
|
|
|
from django import forms
|
|
from django.conf import settings
|
|
from django.contrib import messages
|
|
from django.core.cache import cache
|
|
from django.db import transaction
|
|
from django.http import HttpRequest
|
|
from django.template.loader import get_template
|
|
from django.templatetags.static import static
|
|
from django.urls import resolve, 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 django_scopes import scopes_disabled
|
|
from i18nfield.strings import LazyI18nString
|
|
from paypalcheckoutsdk.orders import (
|
|
OrdersCaptureRequest, OrdersCreateRequest, OrdersGetRequest,
|
|
OrdersPatchRequest,
|
|
)
|
|
from paypalcheckoutsdk.payments import CapturesRefundRequest, RefundsGetRequest
|
|
from paypalhttp import HttpError
|
|
|
|
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 import OF_SELF
|
|
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:
|
|
if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret:
|
|
if not self.settings.isu_merchant_id:
|
|
return False
|
|
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
|
|
|
|
@scopes_disabled()
|
|
def count_known_failures():
|
|
return OrderPayment.objects.filter(
|
|
provider="paypal", info__contains="RESOURCE_NOT_FOUND", created__gt=now() - timedelta(hours=2)
|
|
).count()
|
|
|
|
known_issue_failures = cache.get_or_set(
|
|
'paypal2_known_issue_failures',
|
|
count_known_failures(),
|
|
600
|
|
)
|
|
|
|
template = get_template('pretixplugins/paypal2/checkout_payment_form.html')
|
|
ctx = {
|
|
'request': request,
|
|
'event': self.event,
|
|
'settings': self.settings,
|
|
'method': self.method,
|
|
'known_issue': known_issue_failures > 1,
|
|
'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:
|
|
if "RESOURCE_NOT_FOUND" in str(e):
|
|
messages.warning(request, _('Your payment has failed due to a known issue within PayPal. Please try '
|
|
'again, there is a high chance of the payment succeeding on a second '
|
|
'or third attempt. You can also try other payment methods, if available.'))
|
|
else:
|
|
messages.warning(request, _('We had trouble communicating with PayPal'))
|
|
logger.exception('PayPal OrdersGetRequest({}): {}'.format(paypal_order_id, 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_total=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_total:
|
|
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_total and not payment:
|
|
value = self.format_price(cart_total)
|
|
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[:127],
|
|
'custom_id': custom_id[:127],
|
|
# '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)
|
|
|
|
if payment:
|
|
ReferencedPayPalObject.objects.get_or_create(order=payment.order, payment=payment, reference=response.result.id)
|
|
except IOError as e:
|
|
if "RESOURCE_NOT_FOUND" in str(e):
|
|
messages.error(request, _('Your payment has failed due to a known issue within PayPal. Please try '
|
|
'again, there is a high chance of the payment succeeding on a second '
|
|
'or third attempt. You can also try other payment methods, if available.'))
|
|
else:
|
|
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,
|
|
'url': resolve(request.path_info),
|
|
'event': self.event,
|
|
'settings': self.settings,
|
|
'method': self.method
|
|
}
|
|
return template.render(ctx)
|
|
|
|
@transaction.atomic
|
|
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
|
|
payment = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=payment.pk)
|
|
if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED:
|
|
logger.warning('payment is already confirmed; possible return-view/webhook race-condition')
|
|
return
|
|
|
|
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.'))
|
|
|
|
if self.settings.connect_client_id and self.settings.connect_secret_key and not self.settings.secret:
|
|
if not self.settings.isu_merchant_id:
|
|
raise PaymentException('Payment method misconfigured')
|
|
self.init_api()
|
|
paypal_oid = request.session.get('payment_paypal_oid')
|
|
try:
|
|
req = OrdersGetRequest(paypal_oid)
|
|
response = self.client.execute(req)
|
|
except IOError as e:
|
|
logger.exception('PayPal OrdersGetRequest({}): {}'.format(paypal_oid, str(e)))
|
|
payment.fail(info={
|
|
"error": {
|
|
"name": "IOError",
|
|
"message": str(e),
|
|
"order_id": paypal_oid,
|
|
}
|
|
})
|
|
if "RESOURCE_NOT_FOUND" in str(e):
|
|
raise PaymentException(_('Your payment has failed due to a known issue within PayPal. Please try '
|
|
'again, there is a high chance of the payment succeeding on a second '
|
|
'or third attempt. You can also try other payment methods, if available.'))
|
|
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 Decimal(pp_captured_order.purchase_units[0].amount.value) != 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.dict())))
|
|
payment.fail(info={
|
|
"error": {
|
|
"name": "ValidationError",
|
|
"message": "Value mismatch",
|
|
}
|
|
})
|
|
raise PaymentException(_('We were unable to process your payment. See below for details on how to '
|
|
'proceed.'))
|
|
|
|
if pp_captured_order.status == 'APPROVED':
|
|
# We are suspecting that some or even all APMs cannot be PATCHed after being approved by the buyer,
|
|
# without the PayPal Order losing its APPROVED-status again.
|
|
# Since APMs are already created with their proper custom_id and description (at the time the PayPal
|
|
# Order is created for the APM, we already have pretix order code), we skip the PATCH-request.
|
|
if payment.order.code not in pp_captured_order.purchase_units[0].custom_id:
|
|
try:
|
|
custom_id = '{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 ''
|
|
)
|
|
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 ''
|
|
)
|
|
patchreq = OrdersPatchRequest(pp_captured_order.id)
|
|
patchreq.request_body([
|
|
{
|
|
"op": "replace",
|
|
"path": "/purchase_units/@reference_id=='default'/custom_id",
|
|
"value": custom_id[:127],
|
|
},
|
|
{
|
|
"op": "replace",
|
|
"path": "/purchase_units/@reference_id=='default'/description",
|
|
"value": description[:127],
|
|
}
|
|
])
|
|
self.client.execute(patchreq)
|
|
except IOError as e:
|
|
if "RESOURCE_NOT_FOUND" in str(e):
|
|
messages.error(request,
|
|
_('Your payment has failed due to a known issue within PayPal. Please try '
|
|
'again, there is a high chance of the payment succeeding on a second '
|
|
'or third attempt. You can also try other payment methods, if available.'))
|
|
else:
|
|
messages.error(request, _('We had trouble communicating with PayPal'))
|
|
payment.fail(info={
|
|
"error": {
|
|
"name": "IOError",
|
|
"message": str(e),
|
|
},
|
|
"order_id": paypal_oid,
|
|
})
|
|
logger.exception('PayPal OrdersPatchRequest({}): {}'.format(paypal_oid, str(e)))
|
|
return
|
|
|
|
try:
|
|
capturereq = OrdersCaptureRequest(pp_captured_order.id)
|
|
response = self.client.execute(capturereq)
|
|
except HttpError as e:
|
|
text = _('We were unable to process your payment. See below for details on how to proceed.')
|
|
try:
|
|
error = json.loads(e.message)
|
|
except ValueError:
|
|
error = {"message": str(e.message)}
|
|
|
|
try:
|
|
if error["details"][0]["issue"] == "ORDER_ALREADY_CAPTURED":
|
|
# ignore, do nothing, write nothing, just redirect user to order page, this is likely
|
|
# a race condition
|
|
logger.info('PayPal ORDER_ALREADY_CAPTURED, ignoring')
|
|
return
|
|
elif error["details"][0]["issue"] == "INSTRUMENT_DECLINED":
|
|
# Use PayPal's rejection message
|
|
text = error["details"][0]["description"]
|
|
except (KeyError, IndexError):
|
|
pass
|
|
|
|
payment.fail(info={**pp_captured_order.dict(), "error": error}, log_data=error)
|
|
logger.exception('PayPal OrdersCaptureRequest({}): {}'.format(pp_captured_order.id, str(e)))
|
|
raise PaymentException(text)
|
|
except IOError as e:
|
|
payment.fail(info={**pp_captured_order.dict(), "error": {"message": str(e)}}, log_data={"error": str(e)})
|
|
logger.exception('PayPal OrdersCaptureRequest({}): {}'.format(pp_captured_order.id, str(e)))
|
|
if "RESOURCE_NOT_FOUND" in str(e):
|
|
raise PaymentException(
|
|
_('Your payment has failed due to a known issue within PayPal. Please try '
|
|
'again, there is a high chance of the payment succeeding on a second '
|
|
'or third attempt. You can also try other payment methods, if available.')
|
|
)
|
|
else:
|
|
raise PaymentException(_('We were unable to process your payment. See below for details on how to proceed.'))
|
|
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 != 'COMPLETED':
|
|
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' % repr(pp_captured_order.dict()))
|
|
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:
|
|
if 'payment_paypal_oid' in request.session:
|
|
del request.session['payment_paypal_oid']
|
|
|
|
def payment_pending_render(self, request, payment) -> str:
|
|
retry = True
|
|
try:
|
|
if (
|
|
payment.info
|
|
and payment.info_data['purchase_units'][0]['payments']['captures'][0]['status'] == 'pending'
|
|
):
|
|
retry = False
|
|
except (KeyError, IndexError):
|
|
pass
|
|
|
|
error = payment.info_data.get("error", {})
|
|
is_known_issue = error.get("name") == "RESOURCE_NOT_FOUND" or "RESOURCE_NOT_FOUND" in (error.get("message") or "")
|
|
|
|
template = get_template('pretixplugins/paypal2/pending.html')
|
|
ctx = {'request': request, 'event': self.event, 'settings': self.settings,
|
|
'retry': retry, 'order': payment.order, 'is_known_issue': is_known_issue}
|
|
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:
|
|
if "RESOURCE_NOT_FOUND" in str(e):
|
|
messages.warning(
|
|
request,
|
|
_('Your payment has failed due to a known issue within PayPal. Please try '
|
|
'again, there is a high chance of the payment succeeding on a second '
|
|
'or third attempt. You can also try other payment methods, if available.')
|
|
)
|
|
else:
|
|
messages.warning(request, _('We had trouble communicating with PayPal'))
|
|
logger.exception('PayPal OrdersGetRequest({}): {}'.format(paypal_order_id, 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': payment.order.tagged_secret('plugins:paypal2:pay'),
|
|
})
|