├── README.rst ├── pytest.ini ├── setup.cfg ├── eventyay_paypal ├── migrations │ ├── __init__.py │ ├── 0001_initial.py │ └── 0002_initial.py ├── __init__.py ├── templates │ └── plugins │ │ └── paypal │ │ ├── checkout_payment_confirm.html │ │ ├── checkout_payment_form.html │ │ ├── pending.html │ │ ├── action_refund.html │ │ ├── action_double.html │ │ ├── action_overpaid.html │ │ ├── control.html │ │ └── redirect.html ├── models.py ├── utils.py ├── apps.py ├── urls.py ├── signals.py ├── paypal_rest.py ├── views.py └── payment.py ├── setup.py ├── MANIFEST.in ├── plugin.toml ├── Makefile ├── .gitignore ├── pyproject.toml └── LICENSE /README.rst: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /eventyay_paypal/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup() 4 | -------------------------------------------------------------------------------- /eventyay_paypal/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = "1.0.0" 2 | default_app_config = 'eventyay-paypal.apps.PaypalPluginApp' 3 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | recursive-include eventyay_paypal/static * 2 | recursive-include eventyay_paypal/templates * 3 | recursive-include eventyay_paypal/locale * 4 | -------------------------------------------------------------------------------- /plugin.toml: -------------------------------------------------------------------------------- 1 | [plugin] 2 | package = "eventyay-paypal" 3 | modules = [ "eventyay_paypal" ] 4 | marketplace_name = "paypal" 5 | pypi = true 6 | repository_servers = { origin = "github.com" } 7 | tag_targets = [ "origin" ] 8 | branch_targets = [ "origin/master" ] 9 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/checkout_payment_confirm.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

{% blocktrans trimmed %} 4 | The total amount listed above will be withdrawn from your PayPal account after the 5 | confirmation of your purchase. 6 | {% endblocktrans %}

7 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: localecompile 2 | LNGS:=`find eventyay_paypal/locale/ -mindepth 1 -maxdepth 1 -type d -printf "-l %f "` 3 | 4 | localecompile: 5 | django-admin compilemessages 6 | 7 | localegen: 8 | django-admin makemessages --keep-pot -i build -i dist -i "*egg*" $(LNGS) 9 | 10 | .PHONY: all localecompile localegen 11 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/checkout_payment_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

{% blocktrans trimmed %} 4 | After you clicked continue, we will redirect you to PayPal to fill in your payment 5 | details. You will then be redirected back here to review and confirm your order. 6 | {% endblocktrans %}

7 | -------------------------------------------------------------------------------- /eventyay_paypal/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class ReferencedPayPalObject(models.Model): 5 | reference = models.CharField(max_length=190, db_index=True, unique=True) 6 | order = models.ForeignKey('pretixbase.Order', on_delete=models.CASCADE) 7 | payment = models.ForeignKey('pretixbase.OrderPayment', null=True, blank=True, on_delete=models.CASCADE) 8 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/pending.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if retry %} 4 |

{% blocktrans trimmed %} 5 | Our attempt to execute your Payment via PayPal has failed. Please try again or contact us. 6 | {% endblocktrans %}

7 | {% else %} 8 |

{% blocktrans trimmed %} 9 | We're waiting for an answer from PayPal regarding your payment. Please contact us, if this 10 | takes more than a few hours. 11 | {% endblocktrans %}

12 | {% endif %} 13 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/action_refund.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

4 | {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %} 5 | {% blocktrans trimmed with payment=data.resource.id order=""|add:data.order|add:""|safe %} 6 | PayPal reported that the payment {{ payment }} has been refunded or reversed. 7 | Do you want to mark the matching order ({{ order }}) as refunded? 8 | {% endblocktrans %} 9 |

10 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/action_double.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

4 | {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %} 5 | {% blocktrans trimmed with payment=data.payment order=""|add:data.order|add:""|safe %} 6 | The PayPal transaction {{ payment }} has succeeded, but the order {{ order }} has already been paid by other 7 | means. Please double check and refund the money via PayPal's interface. 8 | {% endblocktrans %} 9 |

10 | -------------------------------------------------------------------------------- /eventyay_paypal/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | from django.db import migrations, models 2 | 3 | 4 | class Migration(migrations.Migration): 5 | 6 | initial = True 7 | 8 | dependencies = [ 9 | ] 10 | 11 | operations = [ 12 | migrations.CreateModel( 13 | name='ReferencedPayPalObject', 14 | fields=[ 15 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), 16 | ('reference', models.CharField(db_index=True, max_length=190, unique=True)), 17 | ], 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/action_overpaid.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 |

4 | {% url "control:event.order" organizer=event.organizer.slug event=event.slug code=data.order as ourl %} 5 | {% blocktrans trimmed with payment=data.payment order=""|add:data.order|add:""|safe %} 6 | The PayPal transaction {{ payment }} has succeeded, but the order {{ order }} is expired and the product 7 | was sold out in the meantime. Therefore, the payment could not be accepted. Please contact the user and refund 8 | the money via PayPal's interface. 9 | {% endblocktrans %} 10 |

11 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/control.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | 3 | {% if payment_info %} 4 |
5 |
{% trans "Payment ID" %}
6 |
{{ payment_info.id }}
7 |
{% trans "Sale ID" %}
8 |
{{ sale_id|default_if_none:"?" }}
9 |
{% trans "Payer" %}
10 |
{{ payment_info.payer.payer_info.email }}
11 |
{% trans "Last update" %}
12 |
{{ payment_info.update_time }}
13 |
{% trans "Total value" %}
14 |
{{ payment_info.transactions.0.amount.total }}
15 |
{% trans "Currency" %}
16 |
{{ payment_info.transactions.0.amount.currency }}
17 |
18 | {% endif %} 19 | -------------------------------------------------------------------------------- /eventyay_paypal/utils.py: -------------------------------------------------------------------------------- 1 | 2 | def safe_get(data, keys, default=None): 3 | """ 4 | Recursively calls .get() on a dictionary to safely access nested keys. 5 | 6 | Args: 7 | data (dict): The dictionary to access. 8 | keys (list): The list of keys to access, in order. 9 | default: The value to return if any key is missing or not a dictionary. 10 | 11 | Returns: 12 | The value at the accessed key, or the default value if any key is missing or not a dictionary. 13 | """ 14 | if not keys: 15 | return data 16 | key = keys[0] 17 | value = data.get(key) 18 | if not keys[1:]: 19 | return value if value is not None else default 20 | if isinstance(value, dict): 21 | return safe_get(value, keys[1:], default=default) 22 | return default 23 | -------------------------------------------------------------------------------- /eventyay_paypal/migrations/0002_initial.py: -------------------------------------------------------------------------------- 1 | import django.db.models.deletion 2 | from django.db import migrations, models 3 | 4 | 5 | class Migration(migrations.Migration): 6 | 7 | initial = True 8 | 9 | dependencies = [ 10 | ('eventyay_paypal', '0001_initial'), 11 | ('pretixbase', '0001_initial'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='referencedpaypalobject', 17 | name='order', 18 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.order'), 19 | ), 20 | migrations.AddField( 21 | model_name='referencedpaypalobject', 22 | name='payment', 23 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.orderpayment'), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /eventyay_paypal/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.utils.translation import gettext_lazy as _ 3 | 4 | from . import __version__ 5 | 6 | try: 7 | from pretix.base.plugins import PluginConfig 8 | except ImportError: 9 | raise RuntimeError("Python package 'paypal' is not installed.") 10 | 11 | 12 | class PaypalPluginApp(AppConfig): 13 | default = True 14 | name = "eventyay_paypal" 15 | verbose_name = _("PayPal") 16 | 17 | class PretixPluginMeta: 18 | name = _("PayPal") 19 | author = "eventyay" 20 | version = __version__ 21 | category = "PAYMENT" 22 | featured = True 23 | visible = True 24 | description = _("This plugin allows you to receive payments via PayPal.") 25 | 26 | def ready(self): 27 | from . import signals # NOQA 28 | 29 | 30 | default_app_config = "eventyay-paypal.apps.PaypalPluginApp" 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | .ropeproject/ 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | 52 | # Django stuff: 53 | *.log 54 | 55 | # Sphinx documentation 56 | docs/_build/ 57 | 58 | # PyBuilder 59 | target/ 60 | 61 | #Ipython Notebook 62 | .ipynb_checkpoints 63 | -------------------------------------------------------------------------------- /eventyay_paypal/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import include 2 | from django.urls import re_path as url 3 | from pretix.multidomain import event_url 4 | 5 | from .views import (abort, oauth_disconnect, oauth_return, redirect_view, 6 | success, webhook) 7 | 8 | event_patterns = [ 9 | url(r'^paypal/', include([ 10 | url(r'^abort/$', abort, name='abort'), 11 | url(r'^return/$', success, name='return'), 12 | url(r'^redirect/$', redirect_view, name='redirect'), 13 | 14 | url(r'w/(?P[a-zA-Z0-9]{16})/abort/', abort, name='abort'), 15 | url(r'w/(?P[a-zA-Z0-9]{16})/return/', success, name='return'), 16 | 17 | event_url(r'^webhook/$', webhook, name='webhook', require_live=False), 18 | ])), 19 | ] 20 | 21 | urlpatterns = [ 22 | url(r'^control/event/(?P[^/]+)/(?P[^/]+)/paypal/disconnect/', 23 | oauth_disconnect, name='oauth.disconnect'), 24 | url(r'^_paypal/webhook/$', webhook, name='webhook'), 25 | url(r'^_paypal/oauth_return/$', oauth_return, name='oauth.return'), 26 | ] 27 | -------------------------------------------------------------------------------- /eventyay_paypal/templates/plugins/paypal/redirect.html: -------------------------------------------------------------------------------- 1 | {% load compress %} 2 | {% load i18n %} 3 | {% load static %} 4 | 5 | 6 | 7 | {{ settings.INSTANCE_NAME }} 8 | {% compress css %} 9 | 10 | {% endcompress %} 11 | {% compress js %} 12 | 13 | {% endcompress %} 14 | 15 | 16 |
17 |

{% trans "The payment process has started in a new window." %}

18 | 19 |

20 | {% trans "The window to enter your payment data was not opened or was closed?" %} 21 |

22 |

23 | 24 | {% trans "Click here in order to open the window." %} 25 | 26 |

27 | 30 |
31 | 32 | 33 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "eventyay-paypal" 3 | dynamic = ["version"] 4 | description = "Integrates eventyay-tickets with paypal" 5 | readme = "README.rst" 6 | requires-python = ">=3.11" 7 | license = {file = "LICENSE"} 8 | keywords = ["eventyay-tickets", "eventyay_paypal", "paypal", "eventyay"] 9 | authors = [ 10 | {name = "eventyay team", email = "support@eventyay.com"}, 11 | ] 12 | maintainers = [ 13 | {name = "eventyay team", email = "support@eventyay.com"}, 14 | ] 15 | 16 | dependencies = [] 17 | 18 | [project.entry-points."pretix.plugin"] 19 | eventyay_paypal = "eventyay_paypal:PretixPluginMeta" 20 | 21 | [project.entry-points."distutils.commands"] 22 | build = "pretix_plugin_build.build:CustomBuild" 23 | 24 | [build-system] 25 | requires = [ 26 | "setuptools", 27 | "pretix-plugin-build", 28 | ] 29 | 30 | [project.urls] 31 | homepage = "https://github.com/fossasia/eventyay-tickets" 32 | 33 | [tool.setuptools] 34 | include-package-data = true 35 | 36 | [tool.setuptools.dynamic] 37 | version = {attr = "eventyay_paypal.__version__"} 38 | 39 | [tool.setuptools.packages.find] 40 | include = ["eventyay*"] 41 | namespaces = false 42 | -------------------------------------------------------------------------------- /eventyay_paypal/signals.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict 3 | 4 | from django import forms 5 | from django.dispatch import receiver 6 | from django.template.loader import get_template 7 | from django.utils.translation import gettext_lazy as _ 8 | from pretix.base.forms import SecretKeySettingsField 9 | from pretix.base.signals import (logentry_display, register_global_settings, 10 | register_payment_providers, 11 | requiredaction_display) 12 | 13 | 14 | @receiver(register_payment_providers, dispatch_uid="payment_paypal") 15 | def register_payment_provider(sender, **kwargs): 16 | from .payment import Paypal 17 | return Paypal 18 | 19 | 20 | @receiver(signal=logentry_display, dispatch_uid="paypal_logentry_display") 21 | def pretixcontrol_logentry_display(sender, logentry, **kwargs): 22 | if logentry.action_type != 'pretix.plugins.eventyay_paypal.event': 23 | return 24 | 25 | data = json.loads(logentry.data) 26 | event_type = data.get('event_type') 27 | text = None 28 | plains = { 29 | 'PAYMENT.SALE.COMPLETED': _('Payment completed.'), 30 | 'PAYMENT.SALE.DENIED': _('Payment denied.'), 31 | 'PAYMENT.SALE.REFUNDED': _('Payment refunded.'), 32 | 'PAYMENT.SALE.REVERSED': _('Payment reversed.'), 33 | 'PAYMENT.SALE.PENDING': _('Payment pending.'), 34 | } 35 | 36 | if event_type in plains: 37 | text = plains[event_type] 38 | else: 39 | text = event_type 40 | 41 | if text: 42 | return _('PayPal reported an event: {}').format(text) 43 | 44 | 45 | @receiver(signal=requiredaction_display, dispatch_uid="paypal_requiredaction_display") 46 | def pretixcontrol_action_display(sender, action, request, **kwargs): 47 | if not action.action_type.startswith('pretix.plugins.eventyay_paypal'): 48 | return 49 | 50 | data = json.loads(action.data) 51 | 52 | if action.action_type == 'pretix.plugins.eventyay_paypal.refund': 53 | template = get_template('plugins/paypal/action_refund.html') 54 | elif action.action_type == 'pretix.plugins.eventyay_paypal.overpaid': 55 | template = get_template('plugins/paypal/action_overpaid.html') 56 | elif action.action_type == 'pretix.plugins.eventyay_paypal.double': 57 | template = get_template('plugins/paypal/action_double.html') 58 | 59 | ctx = {'data': data, 'event': sender, 'action': action} 60 | return template.render(ctx, request) 61 | 62 | 63 | @receiver(register_global_settings, dispatch_uid='paypal_global_settings') 64 | def register_global_settings(sender, **kwargs): 65 | return OrderedDict([ 66 | ('payment_paypal_connect_client_id', forms.CharField( 67 | label=_('PayPal Connect: Client ID'), 68 | required=False, 69 | )), 70 | ('payment_paypal_connect_secret_key', SecretKeySettingsField( 71 | label=_('PayPal Connect: Secret key'), 72 | required=False, 73 | )), 74 | ('payment_paypal_connect_endpoint', forms.ChoiceField( 75 | label=_('PayPal Connect Endpoint'), 76 | initial='live', 77 | choices=( 78 | ('live', 'Live'), 79 | ('sandbox', 'Sandbox'), 80 | ), 81 | )), 82 | ]) 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /eventyay_paypal/paypal_rest.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import hashlib 3 | import json 4 | import logging 5 | import time 6 | import urllib.parse 7 | import uuid 8 | from http import HTTPMethod 9 | from typing import List, Optional 10 | 11 | import jwt 12 | import requests 13 | from cryptography.fernet import Fernet 14 | from django.core.cache import cache 15 | 16 | logger = logging.getLogger("pretix.plugins.eventyay_paypal") 17 | 18 | 19 | class PaypalRequestHandler: 20 | def __init__(self, settings): 21 | # settings contain client_id and secret_key 22 | self.settings = settings 23 | if settings.connect_client_id and not settings.secret: 24 | # In case set paypal info in global settings 25 | self.connect_client_id = self.settings.connect_client_id 26 | self.secret_key = self.settings.connect_secret_key 27 | else: 28 | # In case organizer set their own info 29 | self.connect_client_id = self.settings.get("client_id") 30 | self.secret_key = self.settings.get("secret") 31 | 32 | # Redis cache key 33 | self.set_cache_token_key() 34 | 35 | # Endpoints to communicate with paypal 36 | if self.settings.connect_endpoint == "sandbox": 37 | self.endpoint = "https://api-m.sandbox.paypal.com" 38 | else: 39 | self.endpoint = "https://api-m.paypal.com" 40 | 41 | self.oauth_url = urllib.parse.urljoin(self.endpoint, "v1/oauth2/token") 42 | self.partner_referrals_url = urllib.parse.urljoin( 43 | self.endpoint, "/v2/customer/partner-referrals" 44 | ) 45 | self.order_url = urllib.parse.urljoin( 46 | self.endpoint, "v2/checkout/orders/{order_id}" 47 | ) 48 | self.create_order_url = urllib.parse.urljoin( 49 | self.endpoint, "v2/checkout/orders" 50 | ) 51 | self.capture_order_url = urllib.parse.urljoin( 52 | self.endpoint, "v2/checkout/orders/{order_id}/capture" 53 | ) 54 | self.refund_detail_url = urllib.parse.urljoin( 55 | self.endpoint, "v2/payments/refunds/{refund_id}" 56 | ) 57 | self.refund_payment_url = urllib.parse.urljoin( 58 | self.endpoint, "v2/payments/captures/{capture_id}/refund" 59 | ) 60 | self.verify_webhook_url = urllib.parse.urljoin( 61 | self.endpoint, "/v1/notifications/verify-webhook-signature" 62 | ) 63 | 64 | self.paypal_request_id = self.get_paypal_request_id() 65 | 66 | def request( 67 | self, 68 | url: str, 69 | method: HTTPMethod, 70 | data=None, 71 | params=None, 72 | headers=None, 73 | timeout=15, 74 | ) -> dict: 75 | 76 | reason = "" 77 | response_data = {} 78 | try: 79 | if method == HTTPMethod.GET: 80 | response = requests.get( 81 | url, data=data, params=params, headers=headers, timeout=timeout 82 | ) 83 | elif method == HTTPMethod.POST: 84 | response = requests.post( 85 | url, data=data, params=params, headers=headers, timeout=timeout 86 | ) 87 | elif method == HTTPMethod.PATCH: 88 | # Patch request return empty body 89 | requests.patch( 90 | url, data=data, params=params, headers=headers, timeout=timeout 91 | ) 92 | return {} 93 | 94 | # In case request failed, capture specific reason 95 | reason = response.reason 96 | response.raise_for_status() 97 | 98 | if "application/json" not in response.headers.get("Content-Type", ""): 99 | response_data["errors"] = { 100 | "type": "UnparseableResponse", 101 | "reason": reason, 102 | "exception": "Response is not json parseable", 103 | } 104 | return response_data 105 | 106 | response_data["response"] = response.json() 107 | return response_data 108 | except requests.exceptions.ReadTimeout as e: 109 | response_data["errors"] = { 110 | "type": "ReadTimeout", 111 | "reason": reason, 112 | "exception": e, 113 | } 114 | return response_data 115 | except requests.exceptions.RequestException as e: 116 | response_data["errors"] = { 117 | "type": "Ambiguous", 118 | "reason": reason, 119 | "exception": e, 120 | } 121 | return response_data 122 | 123 | @staticmethod 124 | def check_expired_token(access_token_data: dict, buffer_time: int = 300) -> bool: 125 | current_time = time.time() 126 | expiration_time = ( 127 | access_token_data["created_at"] + access_token_data["expires_in"] 128 | ) 129 | return (current_time + buffer_time) > expiration_time 130 | 131 | @staticmethod 132 | def encode_b64(connect_client_id: str, connect_secret_key: str) -> str: 133 | """Encode client_key:secret_key to base64""" 134 | key = f"{connect_client_id}:{connect_secret_key}" 135 | key_bytes = key.encode("ascii") 136 | base64_bytes = base64.b64encode(key_bytes) 137 | return base64_bytes.decode("ascii") 138 | 139 | def set_cache_token_key(self) -> str: 140 | if self.connect_client_id and self.secret_key: 141 | hash_code = hashlib.sha256( 142 | "".join([self.connect_client_id, self.secret_key]).encode() 143 | ).hexdigest() 144 | self.cache_token_key = f"paypal_token_hash_{hash_code}" 145 | # Fernet key must be 32 urlsafe b64encode 146 | self.fernet = Fernet(base64.urlsafe_b64encode(hash_code[:32].encode())) 147 | 148 | def get_paypal_request_id(self): 149 | """ 150 | https://developer.paypal.com/api/rest/reference/idempotency/ 151 | To avoid duplicate requests, set an id in each instance 152 | Used in: create order, capture order, refund payment 153 | """ 154 | return str(uuid.uuid4()) 155 | 156 | def get_paypal_auth_assertion(self, merchant_id: str) -> str: 157 | """ 158 | https://developer.paypal.com/docs/multiparty/issue-refund/ 159 | https://developer.paypal.com/docs/api/payments/v2/#captures_refund 160 | To issue a refund on behalf of the merchant, 161 | Paypal-Auth-Assertion is required 162 | """ 163 | if merchant_id is None: 164 | return "" 165 | 166 | return jwt.encode( 167 | key=None, 168 | algorithm=None, 169 | payload={"iss": self.connect_client_id, "payer_id": merchant_id}, 170 | ) 171 | 172 | def get_access_token(self) -> Optional[str]: 173 | """ 174 | https://developer.paypal.com/api/rest/authentication/ 175 | Get access token data from cache and check expiration 176 | If expired, request for new one, then set it back in cache 177 | Scope: order, invoice, ... 178 | """ 179 | 180 | def request_new_access_token() -> dict: 181 | access_token_response = self.request( 182 | url=self.oauth_url, 183 | method=HTTPMethod.POST, 184 | headers={ 185 | "Authorization": f"Basic {self.encode_b64(self.connect_client_id, self.secret_key)}", 186 | "Content-Type": "application/x-www-form-urlencoded", 187 | }, 188 | data={"grant_type": "client_credentials"}, 189 | ) 190 | 191 | if errors := access_token_response.get("errors"): 192 | logger.error( 193 | "Error getting access token from Paypal: %s", errors["reason"] 194 | ) 195 | return {} 196 | 197 | access_token_data = access_token_response.get("response") 198 | # Add this key value to check for token expiration later 199 | access_token_data["created_at"] = time.time() 200 | # Encrypt access token data and set in cache 201 | encrypted_access_token_data = self.fernet.encrypt( 202 | json.dumps(access_token_data).encode() 203 | ) 204 | cache.set(self.cache_token_key, encrypted_access_token_data, 3600 * 2) 205 | return access_token_data 206 | 207 | # Check cache data 208 | encrypted_access_token_data = cache.get(self.cache_token_key) 209 | if encrypted_access_token_data is None: 210 | access_token_data = request_new_access_token() 211 | else: 212 | access_token_data = json.loads( 213 | self.fernet.decrypt(encrypted_access_token_data).decode() 214 | ) 215 | 216 | if self.check_expired_token(access_token_data): 217 | access_token_data = request_new_access_token() 218 | 219 | access_token = access_token_data.get("access_token") 220 | return access_token 221 | 222 | def create_partner_referrals(self, data: dict) -> dict: 223 | """ 224 | https://developer.paypal.com/docs/api/orders/v2/#orders_create 225 | """ 226 | return self.request( 227 | url=self.partner_referrals_url, 228 | method=HTTPMethod.POST, 229 | headers={ 230 | "Content-Type": "application/json", 231 | "Authorization": f"Bearer {self.get_access_token()}", 232 | }, 233 | data=json.dumps(data), 234 | ) 235 | 236 | def get_order(self, order_id: str) -> dict: 237 | """ 238 | https://developer.paypal.com/docs/api/orders/v2/#orders_get 239 | """ 240 | return self.request( 241 | url=self.order_url.format(order_id=order_id), 242 | method=HTTPMethod.GET, 243 | headers={"Authorization": f"Bearer {self.get_access_token()}"}, 244 | ) 245 | 246 | def create_order(self, order_data: dict) -> dict: 247 | """ 248 | https://developer.paypal.com/docs/api/orders/v2/#orders_create 249 | """ 250 | return self.request( 251 | url=self.create_order_url, 252 | method="POST", 253 | headers={ 254 | "Content-Type": "application/json", 255 | "Authorization": f"Bearer {self.get_access_token()}", 256 | "PayPal-Request-Id": self.paypal_request_id, 257 | }, 258 | data=json.dumps(order_data), 259 | ) 260 | 261 | def capture_order(self, order_id: str) -> dict: 262 | """ 263 | https://developer.paypal.com/docs/api/orders/v2/#orders_capture 264 | """ 265 | return self.request( 266 | url=self.capture_order_url.format(order_id=order_id), 267 | method=HTTPMethod.POST, 268 | headers={ 269 | "Content-Type": "application/json", 270 | "Authorization": f"Bearer {self.get_access_token()}", 271 | "PayPal-Request-Id": self.paypal_request_id, 272 | }, 273 | ) 274 | 275 | def update_order(self, order_id: str, update_data: List[dict]) -> dict: 276 | """ 277 | https://developer.paypal.com/docs/api/orders/v2/#orders_patch 278 | """ 279 | return self.request( 280 | url=self.order_url.format(order_id=order_id), 281 | method=HTTPMethod.PATCH, 282 | headers={ 283 | "Content-Type": "application/json", 284 | "Authorization": f"Bearer {self.get_access_token()}", 285 | }, 286 | data=json.dumps(update_data), 287 | ) 288 | 289 | def get_refund_detail(self, refund_id: str, merchant_id: str) -> dict: 290 | """ 291 | https://developer.paypal.com/docs/api/payments/v2/#refunds_get 292 | """ 293 | return self.request( 294 | url=self.refund_detail_url.format(refund_id=refund_id), 295 | method=HTTPMethod.GET, 296 | headers={ 297 | "Content-Type": "application/json", 298 | "Authorization": f"Bearer {self.get_access_token()}", 299 | "PayPal-Auth-Assertion": self.get_paypal_auth_assertion(merchant_id), 300 | }, 301 | ) 302 | 303 | def refund_payment( 304 | self, 305 | capture_id: str, 306 | refund_data: dict, 307 | merchant_id: str = None, 308 | ) -> dict: 309 | """ 310 | https://developer.paypal.com/docs/api/payments/v2/#captures_refund 311 | """ 312 | return self.request( 313 | url=self.refund_payment_url.format(capture_id=capture_id), 314 | method=HTTPMethod.POST, 315 | headers={ 316 | "Content-Type": "application/json", 317 | "Authorization": f"Bearer {self.get_access_token()}", 318 | "PayPal-Auth-Assertion": self.get_paypal_auth_assertion(merchant_id), 319 | "PayPal-Request-Id": self.paypal_request_id, 320 | }, 321 | data=json.dumps(refund_data), 322 | ) 323 | 324 | def verify_webhook_signature(self, data: dict) -> dict: 325 | """ 326 | https://developer.paypal.com/docs/api/webhooks/v1/#verify-webhook-signature_post 327 | """ 328 | return self.request( 329 | url=self.verify_webhook_url, 330 | method=HTTPMethod.POST, 331 | data=json.dumps(data), 332 | headers={ 333 | "Content-Type": "application/json", 334 | "Authorization": f"Bearer {self.get_access_token()}", 335 | }, 336 | ) 337 | -------------------------------------------------------------------------------- /eventyay_paypal/views.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | import logging 4 | from datetime import datetime, timedelta, timezone 5 | from decimal import Decimal 6 | from http import HTTPStatus 7 | 8 | from django.contrib import messages 9 | from django.core import signing 10 | from django.db.models import Sum 11 | from django.http import HttpResponse, HttpResponseBadRequest 12 | from django.shortcuts import get_object_or_404, redirect, render 13 | from django.urls import reverse 14 | from django.utils.translation import gettext_lazy as _ 15 | from django.views.decorators.clickjacking import xframe_options_exempt 16 | from django.views.decorators.csrf import csrf_exempt 17 | from django.views.decorators.http import require_POST 18 | from django_scopes import scopes_disabled 19 | from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota 20 | from pretix.base.payment import PaymentException 21 | from pretix.control.permissions import event_permission_required 22 | from pretix.multidomain.urlreverse import eventreverse 23 | 24 | from .models import ReferencedPayPalObject 25 | from .payment import Paypal 26 | from .utils import safe_get 27 | 28 | logger = logging.getLogger("pretix.plugins.eventyay_paypal") 29 | 30 | 31 | @xframe_options_exempt 32 | def redirect_view(request, *args, **kwargs): 33 | signer = signing.Signer(salt="safe-redirect") 34 | try: 35 | url = signer.unsign(request.GET.get("url", "")) 36 | except signing.BadSignature: 37 | return HttpResponseBadRequest("Invalid parameter") 38 | 39 | r = render( 40 | request, 41 | "plugins/paypal/redirect.html", 42 | { 43 | "url": url, 44 | }, 45 | ) 46 | r._csp_ignore = True 47 | return r 48 | 49 | 50 | @scopes_disabled() 51 | def oauth_return(request, *args, **kwargs): 52 | """ 53 | https://developer.paypal.com/docs/multiparty/seller-onboarding/before-payment/ 54 | Reference for seller onboarding 55 | """ 56 | required_params = [ 57 | "merchantId", 58 | "merchantIdInPayPal", 59 | "permissionsGranted", 60 | "consentStatus", 61 | "isEmailConfirmed", 62 | ] 63 | required_session_params = [ 64 | "payment_paypal_oauth_event", 65 | "payment_paypal_tracking_id", 66 | ] 67 | if any(p not in request.session for p in required_session_params) or any( 68 | p not in request.GET for p in required_params 69 | ): 70 | messages.error( 71 | request, 72 | _("An error occurred during connecting with PayPal, please try again."), 73 | ) 74 | return redirect(reverse("control:index")) 75 | 76 | event = get_object_or_404(Event, pk=request.session.get("payment_paypal_oauth_event")) 77 | event.settings.payment_paypal_connect_user_id = request.GET.get("merchantId") 78 | event.settings.payment_paypal_merchant_id = request.GET.get("merchantIdInPayPal") 79 | 80 | messages.success( 81 | request, 82 | _( 83 | "Your PayPal account is now connected to Eventyay. You can change the settings in " 84 | "detail below." 85 | ), 86 | ) 87 | 88 | return redirect( 89 | reverse( 90 | "control:event.settings.payment.provider", 91 | kwargs={ 92 | "organizer": event.organizer.slug, 93 | "event": event.slug, 94 | "provider": "paypal", 95 | }, 96 | ) 97 | ) 98 | 99 | 100 | def success(request, *args, **kwargs): 101 | token = request.GET.get("token") 102 | payer = request.GET.get("PayerID") 103 | request.session["payment_paypal_token"] = token 104 | request.session["payment_paypal_payer"] = payer 105 | 106 | urlkwargs = {} 107 | if "cart_namespace" in kwargs: 108 | urlkwargs["cart_namespace"] = kwargs["cart_namespace"] 109 | 110 | if request.session.get("payment_paypal_payment"): 111 | payment = OrderPayment.objects.get( 112 | pk=request.session.get("payment_paypal_payment") 113 | ) 114 | else: 115 | payment = None 116 | 117 | if request.session.get("payment_paypal_order_id", None): 118 | if payment: 119 | prov = Paypal(request.event) 120 | try: 121 | resp = prov.execute_payment(request, payment) 122 | except PaymentException as e: 123 | messages.error(request, str(e)) 124 | urlkwargs["step"] = "payment" 125 | return redirect( 126 | eventreverse( 127 | request.event, "presale:event.checkout", kwargs=urlkwargs 128 | ) 129 | ) 130 | if resp: 131 | return resp 132 | else: 133 | messages.error(request, _("Invalid response from PayPal received.")) 134 | logger.error("Session did not contain payment_paypal_order_id") 135 | urlkwargs["step"] = "payment" 136 | return redirect( 137 | eventreverse(request.event, "presale:event.checkout", kwargs=urlkwargs) 138 | ) 139 | 140 | if payment: 141 | return redirect( 142 | eventreverse( 143 | request.event, 144 | "presale:event.order", 145 | kwargs={"order": payment.order.code, "secret": payment.order.secret}, 146 | ) 147 | + ("?paid=yes" if payment.order.status == Order.STATUS_PAID else "") 148 | ) 149 | urlkwargs["step"] = "confirm" 150 | return redirect( 151 | eventreverse(request.event, "presale:event.checkout", kwargs=urlkwargs) 152 | ) 153 | 154 | 155 | def abort(request, *args, **kwargs): 156 | messages.error(request, _("It looks like you canceled the PayPal payment")) 157 | 158 | if request.session.get("payment_paypal_payment"): 159 | payment = OrderPayment.objects.get( 160 | pk=request.session.get("payment_paypal_payment") 161 | ) 162 | else: 163 | payment = None 164 | 165 | if payment: 166 | return redirect( 167 | eventreverse( 168 | request.event, 169 | "presale:event.order", 170 | kwargs={"order": payment.order.code, "secret": payment.order.secret}, 171 | ) 172 | + ("?paid=yes" if payment.order.status == Order.STATUS_PAID else "") 173 | ) 174 | else: 175 | return redirect( 176 | eventreverse( 177 | request.event, "presale:event.checkout", kwargs={"step": "payment"} 178 | ) 179 | ) 180 | 181 | 182 | def check_webhook_signature(request, event, event_json, prov) -> bool: 183 | """ 184 | Verifies the signature of a webhook from PayPal. 185 | 186 | :param request: The current request object 187 | :param event: The event object 188 | :param event_json: The json payload of the webhook 189 | :param prov: The payment provider instance 190 | :return: True if the signature is valid, False otherwise 191 | """ 192 | 193 | required_headers = [ 194 | "PAYPAL-AUTH-ALGO", 195 | "PAYPAL-CERT-URL", 196 | "PAYPAL-TRANSMISSION-ID", 197 | "PAYPAL-TRANSMISSION-SIG", 198 | "PAYPAL-TRANSMISSION-TIME", 199 | ] 200 | if any(header not in request.headers for header in required_headers): 201 | logger.error("Paypal webhook missing required headers") 202 | return False 203 | 204 | # Prevent replay attacks: check timestamp 205 | current_time = datetime.now(timezone.utc) 206 | transmission_time = datetime.fromisoformat( 207 | request.headers.get("PAYPAL-TRANSMISSION-TIME") 208 | ) 209 | if current_time - transmission_time > timedelta(minutes=7): 210 | logger.error("Paypal webhook timestamp is too old.") 211 | return False 212 | 213 | verify_response = prov.paypal_request_handler.verify_webhook_signature( 214 | data={ 215 | "auth_algo": request.headers.get("PAYPAL-AUTH-ALGO"), 216 | "transmission_id": request.headers.get("PAYPAL-TRANSMISSION-ID"), 217 | "cert_url": request.headers.get("PAYPAL-CERT-URL"), 218 | "transmission_sig": request.headers.get("PAYPAL-TRANSMISSION-SIG"), 219 | "transmission_time": request.headers.get("PAYPAL-TRANSMISSION-TIME"), 220 | "webhook_id": event.settings.payment_paypal_webhook_id, 221 | "webhook_event": event_json, 222 | } 223 | ) 224 | 225 | if ( 226 | verify_response.get("errors") 227 | or safe_get(verify_response, ["response", "verification_status"], "") 228 | == "FAILURE" 229 | ): 230 | errors = verify_response.get("errors") 231 | logger.error("Unable to verify signature of webhook: %s", errors["reason"]) 232 | return False 233 | return True 234 | 235 | 236 | def parse_webhook_event(request, event_json): 237 | """ 238 | Parse the given webhook event and return the corresponding event, payment ID and RPO. 239 | 240 | :param request: The current request object 241 | :param event_json: The json payload of the webhook 242 | :return: A tuple of (event, payment_id, referenced_paypal_object) 243 | """ 244 | event = None 245 | payment_id = None 246 | if event_json["resource_type"] == "refund": 247 | for link in event_json["resource"]["links"]: 248 | if link["rel"] == "up": 249 | refund_url = link["href"] 250 | payment_id = refund_url.split("/")[-1] 251 | break 252 | else: 253 | payment_id = event_json["resource"]["id"] 254 | 255 | references = [payment_id] 256 | 257 | # For filtering reference, there are a lot of ids appear within json__event 258 | if ref_order_id := ( 259 | safe_get( 260 | event_json, 261 | ["resource", "supplementary_data", "related_ids", "order_id"] 262 | ) 263 | ): 264 | references.append(ref_order_id) 265 | 266 | # Grasp the corresponding RPO 267 | rpo = ( 268 | ReferencedPayPalObject.objects.select_related("order", "order__event") 269 | .filter(reference__in=references) 270 | .first() 271 | ) 272 | 273 | if rpo: 274 | event = rpo.order.event 275 | if "id" in rpo.payment.info_data: 276 | payment_id = rpo.payment.info_data["id"] 277 | elif hasattr(request, "event"): 278 | event = request.event 279 | 280 | return event, payment_id, rpo 281 | 282 | 283 | def extract_order_and_payment(payment_id, event, event_json, prov, rpo=None): 284 | """ 285 | Extracts order details and associated payment information from PayPal webhook data. 286 | 287 | :param payment_id: The ID of the payment to be extracted. 288 | :param event: The event object associated with the payment. 289 | :param event_json: The JSON payload of the webhook event. 290 | :param prov: The payment provider instance. 291 | :param rpo: Optional. The referenced PayPal object containing order and payment information. 292 | 293 | :returns: A tuple containing the order details and the payment object. 294 | Returns (None, None) if an error occurs while retrieving order details. 295 | """ 296 | order_detail = None 297 | payment = None 298 | 299 | order_response = prov.paypal_request_handler.get_order(order_id=payment_id) 300 | if errors := order_response.get("errors"): 301 | logger.error("Paypal error on webhook: %s", errors["reason"]) 302 | logger.exception("PayPal error on webhook. Event data: %s", str(event_json)) 303 | return order_detail, payment 304 | 305 | order_detail = order_response.get("response") 306 | 307 | if rpo and rpo.payment: 308 | payment = rpo.payment 309 | else: 310 | payments = OrderPayment.objects.filter( 311 | order__event=event, provider="paypal", info__icontains=order_detail.get("id") 312 | ) 313 | payment = None 314 | for p in payments: 315 | if ( 316 | "info_data" in p 317 | and "purchase_units" in p.info_data 318 | and p.info_data["purchase_units"] 319 | ): 320 | for capture in safe_get( 321 | p.info_data["purchase_units"][0], ["payments", "captures"], [] 322 | ): 323 | if capture.get("status") in [ 324 | "COMPLETED", 325 | "PARTIALLY_REFUNDED", 326 | ] and capture.get("id") == order_detail.get("id"): 327 | payment = p 328 | break 329 | 330 | return order_detail, payment 331 | 332 | 333 | @csrf_exempt 334 | @require_POST 335 | @scopes_disabled() 336 | def webhook(request, *args, **kwargs): 337 | """ 338 | https://developer.paypal.com/api/rest/webhooks/event-names/ 339 | Webhook reference 340 | """ 341 | event_body = request.body.decode("utf-8").strip() 342 | event_json = json.loads(event_body) 343 | 344 | if event_json.get("resource_type") not in ("checkout-order", "refund", "capture"): 345 | return HttpResponse("Wrong resource type", status=HTTPStatus.BAD_REQUEST) 346 | 347 | event, payment_id, rpo = parse_webhook_event(request, event_json) 348 | if event is None: 349 | return HttpResponse("Unable to get event from webhook", status=HTTPStatus.BAD_REQUEST) 350 | 351 | prov = Paypal(event) 352 | 353 | # Verify signature 354 | if not check_webhook_signature(request, event, event_json, prov): 355 | return HttpResponse("Unable to verify signature of webhook", status=HTTPStatus.BAD_REQUEST) 356 | 357 | order_detail, payment = extract_order_and_payment( 358 | payment_id, event, event_json, prov, rpo 359 | ) 360 | if order_detail is None or payment is None: 361 | return HttpResponse("Order or payment not found", status=HTTPStatus.BAD_REQUEST) 362 | 363 | payment.order.log_action("pretix.plugins.eventyay_paypal.event", data=event_json) 364 | 365 | def handle_refund(): 366 | refund_id_in_event = safe_get(event_json, ["resource", "id"]) 367 | refund_response = prov.paypal_request_handler.get_refund_detail( 368 | refund_id=refund_id_in_event, 369 | merchant_id=event.settings.payment_paypal_merchant_id, 370 | ) 371 | if errors := refund_response.get("errors"): 372 | logger.error("Paypal error on webhook: %s", errors["reason"]) 373 | logger.exception("PayPal error on webhook. Event data: %s", str(event_json)) 374 | return HttpResponse( 375 | f'Refund {refund_id_in_event} not found', status=HTTPStatus.BAD_REQUEST 376 | ) 377 | 378 | refund_detail = refund_response.get("response") 379 | if refund_id := refund_detail.get("id"): 380 | known_refunds = { 381 | refund.info_data.get("id"): refund for refund in payment.refunds.all() 382 | } 383 | if refund_id not in known_refunds: 384 | payment.create_external_refund( 385 | amount=abs( 386 | Decimal(safe_get(refund_detail, ["amount", "value"], "0.00")) 387 | ), 388 | info=json.dumps(refund_detail), 389 | ) 390 | elif know_refund := known_refunds.get(refund_id): 391 | if ( 392 | know_refund.state 393 | in ( 394 | OrderRefund.REFUND_STATE_CREATED, 395 | OrderRefund.REFUND_STATE_TRANSIT, 396 | ) 397 | and refund_detail.get("status", "") == "COMPLETED" 398 | ): 399 | know_refund.done() 400 | 401 | seller_payable_breakdown_value = safe_get( 402 | refund_detail, 403 | ["seller_payable_breakdown", "total_refunded_amount", "value"], 404 | "0.00", 405 | ) 406 | known_sum = payment.refunds.filter( 407 | state__in=( 408 | OrderRefund.REFUND_STATE_DONE, 409 | OrderRefund.REFUND_STATE_TRANSIT, 410 | OrderRefund.REFUND_STATE_CREATED, 411 | OrderRefund.REFUND_SOURCE_EXTERNAL, 412 | ) 413 | ).aggregate(s=Sum("amount"))["s"] or Decimal("0.00") 414 | total_refunded_amount = Decimal(seller_payable_breakdown_value) 415 | if known_sum < total_refunded_amount: 416 | payment.create_external_refund(amount=total_refunded_amount - known_sum) 417 | 418 | def handle_payment_state_confirmed(): 419 | if event_json.get("resource_type") == "refund": 420 | handle_refund() 421 | elif order_detail.get("status") == "REFUNDED": 422 | known_sum = payment.refunds.filter( 423 | state__in=( 424 | OrderRefund.REFUND_STATE_DONE, 425 | OrderRefund.REFUND_STATE_TRANSIT, 426 | OrderRefund.REFUND_STATE_CREATED, 427 | OrderRefund.REFUND_SOURCE_EXTERNAL, 428 | ) 429 | ).aggregate(s=Sum("amount"))["s"] or Decimal("0.00") 430 | if known_sum < payment.amount: 431 | payment.create_external_refund(amount=payment.amount - known_sum) 432 | 433 | def handle_payment_state_pending(): 434 | if order_detail.get("status") == "APPROVED": 435 | try: 436 | request.session["payment_paypal_order_id"] = payment.info_data.get("id") 437 | payment.payment_provider.execute_payment(request, payment) 438 | except PaymentException as e: 439 | logger.error( 440 | "Error executing approved payment in webhook: payment not yet populated." 441 | ) 442 | logger.exception("Unable to execute payment in webhook: %s", str(e)) 443 | elif order_detail.get("status") == "COMPLETED": 444 | captured = False 445 | captures_completed = True 446 | for purchase_unit in order_detail.get("purchase_units", []): 447 | for capture in safe_get(purchase_unit, ["payment", "captures"], []): 448 | with contextlib.suppress( 449 | ReferencedPayPalObject.MultipleObjectsReturned 450 | ): 451 | ReferencedPayPalObject.objects.get_or_create( 452 | order=payment.order, 453 | payment=payment, 454 | reference=capture.get("id"), 455 | ) 456 | if capture.get("status") in ( 457 | "COMPLETED", 458 | "REFUNDED", 459 | "PARTIALLY_REFUNDED", 460 | ): 461 | captured = True 462 | else: 463 | captures_completed = False 464 | if captured and captures_completed: 465 | with contextlib.suppress(Quota.QuotaExceededException): 466 | payment.info = json.dumps(order_detail) 467 | payment.save(update_fields=["info"]) 468 | payment.confirm() 469 | 470 | if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED and order_detail[ 471 | "status" 472 | ] in ("PARTIALLY_REFUNDED", "REFUNDED", "COMPLETED"): 473 | handle_payment_state_confirmed() 474 | elif payment.state in ( 475 | OrderPayment.PAYMENT_STATE_PENDING, 476 | OrderPayment.PAYMENT_STATE_CREATED, 477 | OrderPayment.PAYMENT_STATE_CANCELED, 478 | OrderPayment.PAYMENT_STATE_FAILED, 479 | ): 480 | handle_payment_state_pending() 481 | 482 | return HttpResponse(status=HTTPStatus.OK) 483 | 484 | 485 | @event_permission_required("can_change_event_settings") 486 | @require_POST 487 | def oauth_disconnect(request, **kwargs): 488 | del request.event.settings.payment_paypal_connect_user_id 489 | del request.event.settings.payment_paypal_merchant_id 490 | request.event.settings.payment_paypal__enabled = False 491 | messages.success(request, _("Your PayPal account has been disconnected.")) 492 | 493 | return redirect( 494 | reverse( 495 | "control:event.settings.payment.provider", 496 | kwargs={ 497 | "organizer": request.event.organizer.slug, 498 | "event": request.event.slug, 499 | "provider": "paypal", 500 | }, 501 | ) 502 | ) 503 | -------------------------------------------------------------------------------- /eventyay_paypal/payment.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import json 3 | import logging 4 | import urllib.parse 5 | from collections import OrderedDict 6 | from decimal import Decimal 7 | from typing import Union 8 | 9 | from django import forms 10 | from django.contrib import messages 11 | from django.core import signing 12 | from django.http import HttpRequest 13 | from django.template.loader import get_template 14 | from django.urls import reverse 15 | from django.utils.crypto import get_random_string 16 | from django.utils.timezone import now 17 | from django.utils.translation import gettext as __ 18 | from django.utils.translation import gettext_lazy as _ 19 | from i18nfield.strings import LazyI18nString 20 | from pretix.base.decimal import round_decimal 21 | from pretix.base.models import Event, Order, OrderPayment, OrderRefund, Quota 22 | from pretix.base.payment import BasePaymentProvider, PaymentException 23 | from pretix.base.services.mail import SendMailException 24 | from pretix.base.settings import SettingsSandbox 25 | from pretix.helpers.urls import build_absolute_uri as build_global_uri 26 | from pretix.multidomain.urlreverse import build_absolute_uri 27 | 28 | from .models import ReferencedPayPalObject 29 | from .paypal_rest import PaypalRequestHandler 30 | from .utils import safe_get 31 | 32 | logger = logging.getLogger("pretix.plugins.eventyay_paypal") 33 | 34 | SUPPORTED_CURRENCIES = [ 35 | "AUD", 36 | "BRL", 37 | "CAD", 38 | "CZK", 39 | "DKK", 40 | "EUR", 41 | "HKD", 42 | "HUF", 43 | "INR", 44 | "ILS", 45 | "JPY", 46 | "MYR", 47 | "MXN", 48 | "TWD", 49 | "NZD", 50 | "NOK", 51 | "PHP", 52 | "PLN", 53 | "GBP", 54 | "RUB", 55 | "SGD", 56 | "SEK", 57 | "CHF", 58 | "THB", 59 | "USD", 60 | ] 61 | 62 | LOCAL_ONLY_CURRENCIES = ["INR"] 63 | 64 | 65 | class Paypal(BasePaymentProvider): 66 | identifier = "paypal" 67 | verbose_name = _("PayPal") 68 | payment_form_fields = OrderedDict([]) 69 | 70 | def __init__(self, event: Event): 71 | super().__init__(event) 72 | self.settings = SettingsSandbox("payment", "paypal", event) 73 | self.paypal_request_handler = PaypalRequestHandler(self.settings) 74 | 75 | @property 76 | def test_mode_message(self): 77 | if self.settings.connect_client_id and not self.settings.secret: 78 | # in OAuth mode, sandbox mode needs to be set global 79 | is_sandbox = self.settings.connect_endpoint == "sandbox" 80 | else: 81 | is_sandbox = self.settings.get("endpoint") == "sandbox" 82 | if is_sandbox: 83 | return _( 84 | "The PayPal sandbox is being used, you can test without actually sending money but you will need a " 85 | "PayPal sandbox user to log in." 86 | ) 87 | return None 88 | 89 | @property 90 | def settings_form_fields(self): 91 | if self.settings.connect_client_id and not self.settings.secret: 92 | # PayPal connect 93 | if self.settings.connect_user_id: 94 | fields = [ 95 | ( 96 | "connect_user_id", 97 | forms.CharField(label=_("PayPal account"), disabled=True), 98 | ), 99 | ] 100 | else: 101 | return {} 102 | else: 103 | fields = [ 104 | ( 105 | "client_id", 106 | forms.CharField( 107 | label=_("Client ID"), 108 | max_length=80, 109 | min_length=80, 110 | help_text=_( 111 | '{text}' 112 | ).format( 113 | text=_( 114 | "Click here for a tutorial on how to obtain the required keys" 115 | ), 116 | docs_url="https://docs.eventyay.com/en/latest/user/payments/paypal.html", 117 | ), 118 | ), 119 | ), 120 | ( 121 | "secret", 122 | forms.CharField( 123 | label=_("Secret"), 124 | max_length=80, 125 | min_length=80, 126 | ), 127 | ), 128 | ( 129 | "endpoint", 130 | forms.ChoiceField( 131 | label=_("Endpoint"), 132 | initial="live", 133 | choices=( 134 | ("live", "Live"), 135 | ("sandbox", "Sandbox"), 136 | ), 137 | ), 138 | ), 139 | ( 140 | "webhook_id", 141 | forms.CharField( 142 | label=_("Webhook ID"), 143 | initial="test_webhook_id", 144 | max_length=20, 145 | min_length=10, 146 | ), 147 | ), 148 | ] 149 | 150 | extra_fields = [ 151 | ( 152 | "prefix", 153 | forms.CharField( 154 | label=_("Reference prefix"), 155 | help_text=_( 156 | "Any value entered here will be added in front of the regular booking reference " 157 | "containing the order number." 158 | ), 159 | required=False, 160 | ), 161 | ) 162 | ] 163 | 164 | d = OrderedDict( 165 | fields + extra_fields + list(super().settings_form_fields.items()) 166 | ) 167 | 168 | d.move_to_end("prefix") 169 | d.move_to_end("_enabled", False) 170 | return d 171 | 172 | def get_connect_url(self, request): 173 | """ 174 | Generate link for button Connect to Paypal in payment setting 175 | """ 176 | request.session["payment_paypal_oauth_event"] = request.event.pk 177 | request.session["payment_paypal_tracking_id"] = get_random_string(111) 178 | 179 | response_data = self.paypal_request_handler.create_partner_referrals( 180 | data={ 181 | "operations": [ 182 | { 183 | "operation": "API_INTEGRATION", 184 | "api_integration_preference": { 185 | "rest_api_integration": { 186 | "integration_method": "PAYPAL", 187 | "integration_type": "THIRD_PARTY", 188 | "third_party_details": { 189 | "features": [ 190 | "PAYMENT", 191 | "REFUND", 192 | "ACCESS_MERCHANT_INFORMATION", 193 | ], 194 | }, 195 | } 196 | }, 197 | } 198 | ], 199 | "products": ["EXPRESS_CHECKOUT"], 200 | "partner_config_override": { 201 | "return_url": build_global_uri( 202 | "plugins:eventyay_paypal:oauth.return" 203 | ) 204 | }, 205 | "legal_consents": [{"type": "SHARE_DATA_CONSENT", "granted": True}], 206 | "tracking_id": request.session["payment_paypal_tracking_id"], 207 | }, 208 | ) 209 | 210 | if errors := response_data.get("errors"): 211 | messages.error( 212 | request, 213 | _("An error occurred during connecting with PayPal: {}").format( 214 | errors["reason"] 215 | ), 216 | ) 217 | return 218 | 219 | response = response_data.get("response") 220 | for link in response["links"]: 221 | if link["rel"] == "action_url": 222 | return link["href"] 223 | 224 | def settings_content_render(self, request): 225 | settings_content = "" 226 | if self.settings.connect_client_id and not self.settings.secret: 227 | # Use PayPal connect 228 | if not self.settings.connect_user_id: 229 | settings_content = ( 230 | "

{}

" "{}" 231 | ).format( 232 | _( 233 | "To accept payments via PayPal, you will need an account at PayPal. By clicking on the " 234 | "following button, you can either create a new PayPal account connect Eventyay to an existing " 235 | "one." 236 | ), 237 | self.get_connect_url(request), 238 | _("Connect with {icon} PayPal").format( 239 | icon='' 240 | ), 241 | ) 242 | else: 243 | settings_content = ( 244 | "" 245 | ).format( 246 | reverse( 247 | "plugins:eventyay_paypal:oauth.disconnect", 248 | kwargs={ 249 | "organizer": self.event.organizer.slug, 250 | "event": self.event.slug, 251 | }, 252 | ), 253 | _("Disconnect from PayPal"), 254 | ) 255 | else: 256 | settings_content = "
%s
%s
" % ( 257 | _( 258 | "Please configure a PayPal Webhook to the following endpoint in order to automatically cancel orders " 259 | "when payments are refunded externally. And set webhook id to make it work properly." 260 | ), 261 | build_global_uri("plugins:eventyay_paypal:webhook"), 262 | ) 263 | 264 | if self.event.currency not in SUPPORTED_CURRENCIES: 265 | settings_content += ( 266 | '

%s ' 267 | '%s' 268 | "
" 269 | ) % ( 270 | _("PayPal does not process payments in your event's currency."), 271 | _( 272 | "Please check this PayPal page for a complete list of supported currencies." 273 | ), 274 | ) 275 | 276 | if self.event.currency in LOCAL_ONLY_CURRENCIES: 277 | settings_content += '

%s' "
" % ( 278 | _( 279 | "Your event's currency is supported by PayPal as a payment and balance currency for in-country " 280 | "accounts only. This means, that the receiving as well as the sending PayPal account must have been " 281 | "created in the same country and use the same currency. Out of country accounts will not be able to " 282 | "send any payments." 283 | ) 284 | ) 285 | 286 | return settings_content 287 | 288 | def is_allowed(self, request: HttpRequest, total: Decimal = None) -> bool: 289 | return ( 290 | super().is_allowed(request, total) 291 | and self.event.currency in SUPPORTED_CURRENCIES 292 | ) 293 | 294 | def payment_is_valid_session(self, request): 295 | return ( 296 | request.session.get("payment_paypal_order_id", "") != "" 297 | and request.session.get("payment_paypal_payer", "") != "" 298 | ) 299 | 300 | def payment_form_render(self, request) -> str: 301 | template = get_template("plugins/paypal/checkout_payment_form.html") 302 | ctx = {"request": request, "event": self.event, "settings": self.settings} 303 | return template.render(ctx) 304 | 305 | def checkout_prepare(self, request, cart): 306 | kwargs = {} 307 | if request.resolver_match and "cart_namespace" in request.resolver_match.kwargs: 308 | kwargs["cart_namespace"] = request.resolver_match.kwargs["cart_namespace"] 309 | 310 | payee = {} 311 | if self.settings.get("client_id") or self.settings.get("secret"): 312 | # In case organizer set their own info 313 | # Check undeleted infos and remove theme 314 | if request.event.settings.payment_paypal_connect_user_id: 315 | del request.event.settings.payment_paypal_connect_user_id 316 | if request.event.settings.payment_paypal_merchant_id: 317 | del request.event.settings.payment_paypal_merchant_id 318 | elif request.event.settings.payment_paypal_connect_user_id: 319 | payee = { 320 | "merchant_id": request.event.settings.payment_paypal_merchant_id, 321 | } 322 | 323 | order_response = self.paypal_request_handler.create_order( 324 | order_data={ 325 | "intent": "CAPTURE", 326 | "purchase_units": [ 327 | { 328 | "items": [ 329 | { 330 | "name": ( 331 | f"{self.settings.prefix} " 332 | if self.settings.prefix 333 | else "" 334 | ) 335 | + __("Order for %s") % str(request.event), 336 | "quantity": "1", 337 | "unit_amount": { 338 | "currency_code": request.event.currency, 339 | "value": self.format_price(cart["total"]), 340 | }, 341 | } 342 | ], 343 | "amount": { 344 | "currency_code": request.event.currency, 345 | "value": self.format_price(cart["total"]), 346 | "breakdown": { 347 | "item_total": { 348 | "currency_code": request.event.currency, 349 | "value": self.format_price(cart["total"]), 350 | } 351 | }, 352 | }, 353 | "description": __("Event tickets for {event}").format( 354 | event=request.event.name 355 | ), 356 | "payee": payee, 357 | } 358 | ], 359 | "payment_source": { 360 | "paypal": { 361 | "experience_context": { 362 | "payment_method_preference": "UNRESTRICTED", 363 | "landing_page": "LOGIN", 364 | "return_url": build_absolute_uri( 365 | request.event, 366 | "plugins:eventyay_paypal:return", 367 | kwargs=kwargs, 368 | ), 369 | "cancel_url": build_absolute_uri( 370 | request.event, 371 | "plugins:eventyay_paypal:abort", 372 | kwargs=kwargs, 373 | ), 374 | } 375 | } 376 | }, 377 | } 378 | ) 379 | 380 | if errors := order_response.get("errors"): 381 | messages.error( 382 | request, 383 | _("An error occurred during connecting with PayPal: {}").format( 384 | errors["reason"], 385 | ), 386 | ) 387 | return None 388 | 389 | order_created = order_response.get("response") 390 | request.session["payment_paypal_payment"] = None 391 | return self._create_order(request, order_created) 392 | 393 | def format_price(self, value): 394 | return str( 395 | round_decimal( 396 | value, 397 | self.event.currency, 398 | { 399 | # PayPal behaves differently than Stripe in deciding what currencies have decimal places 400 | # Source https://developer.paypal.com/docs/classic/api/currency_codes/ 401 | "HUF": 0, 402 | "JPY": 0, 403 | "MYR": 0, 404 | "TWD": 0, 405 | # However, CLPs are not listed there while PayPal requires us not to send decimal places there. WTF. 406 | "CLP": 0, 407 | # Let's just guess that the ones listed here are 0-based as well 408 | # https://developers.braintreepayments.com/reference/general/currencies 409 | "BIF": 0, 410 | "DJF": 0, 411 | "GNF": 0, 412 | "KMF": 0, 413 | "KRW": 0, 414 | "LAK": 0, 415 | "PYG": 0, 416 | "RWF": 0, 417 | "UGX": 0, 418 | "VND": 0, 419 | "VUV": 0, 420 | "XAF": 0, 421 | "XOF": 0, 422 | "XPF": 0, 423 | }, 424 | ) 425 | ) 426 | 427 | @property 428 | def abort_pending_allowed(self): 429 | return False 430 | 431 | def _create_order(self, request, order): 432 | if order.get("status") not in ("CREATED", "PAYER_ACTION_REQUIRED"): 433 | messages.error(request, _("We had trouble communicating with PayPal")) 434 | logger.error("Invalid order state: %s", str(order)) 435 | return 436 | 437 | request.session["payment_paypal_order_id"] = order["id"] 438 | for link in order.get("links", []): 439 | if link.get("rel") == "payer-action": 440 | href = link.get("href") 441 | if request.session.get("iframe_session", False): 442 | signer = signing.Signer(salt="safe-redirect") 443 | return ( 444 | build_absolute_uri( 445 | request.event, "plugins:eventyay_paypal:redirect" 446 | ) 447 | + "?url=" 448 | + urllib.parse.quote(signer.sign(href)) 449 | ) 450 | else: 451 | return str(href) 452 | 453 | def checkout_confirm_render(self, request) -> str: 454 | """ 455 | Returns the HTML that should be displayed when the user selected this provider 456 | on the 'confirm order' page. 457 | """ 458 | template = get_template("plugins/paypal/checkout_payment_confirm.html") 459 | ctx = {"request": request, "event": self.event, "settings": self.settings} 460 | return template.render(ctx) 461 | 462 | def execute_payment(self, request: HttpRequest, payment: OrderPayment): 463 | def handle_paypal_error(errors, order_id, payment, message): 464 | logger.error(message, order_id, errors["reason"]) 465 | payment.fail( 466 | info={ 467 | "error": { 468 | "name": errors["type"], 469 | "message": errors["reason"], 470 | "exception": errors["exception"], 471 | "order_id": order_id, 472 | } 473 | } 474 | ) 475 | raise PaymentException(_("Unable to process your payment with Paypal")) 476 | 477 | order_id = request.session.get("payment_paypal_order_id", "") 478 | paypal_payer = request.session.get("payment_paypal_payer", "") 479 | if not order_id or not paypal_payer: 480 | raise PaymentException( 481 | _( 482 | "We were unable to process your payment. See below for details on how to proceed." 483 | ) 484 | ) 485 | 486 | order_response = self.paypal_request_handler.get_order(order_id=order_id) 487 | if errors := order_response.get("errors"): 488 | handle_paypal_error( 489 | errors, 490 | order_id, 491 | payment, 492 | "Unable to retrieve order %s from Paypal: %s", 493 | ) 494 | 495 | order_detail = order_response.get("response") 496 | with contextlib.suppress(ReferencedPayPalObject.MultipleObjectsReturned): 497 | ReferencedPayPalObject.objects.get_or_create( 498 | order=payment.order, payment=payment, reference=order_id 499 | ) 500 | 501 | if ( 502 | str( 503 | safe_get( 504 | order_detail.get("purchase_units", [{}])[0], ["amount", "value"] 505 | ) 506 | ) 507 | != str(payment.amount) 508 | or safe_get( 509 | order_detail.get("purchase_units", [{}])[0], ["amount", "currency_code"] 510 | ) 511 | != self.event.currency 512 | ): 513 | logger.error( 514 | "Value mismatch: Payment %s vs paypal trans %s", 515 | payment.id, 516 | str(order_detail), 517 | ) 518 | payment.fail( 519 | info={ 520 | "error": { 521 | "name": "ValidationError", 522 | "message": "Value mismatch", 523 | } 524 | } 525 | ) 526 | raise PaymentException( 527 | _( 528 | "We were unable to process your payment. See below for details on how to proceed." 529 | ) 530 | ) 531 | 532 | if order_detail["status"] == "APPROVED": 533 | description = ( 534 | f"{self.settings.prefix} " if self.settings.prefix else "" 535 | ) + __("Order {order} for {event}").format( 536 | event=request.event.name, order=payment.order.code 537 | ) 538 | 539 | update_response = self.paypal_request_handler.update_order( 540 | order_id=order_id, 541 | update_data=[ 542 | { 543 | "op": "replace", 544 | "path": "/purchase_units/@reference_id=='default'/description", 545 | "value": description, 546 | } 547 | ], 548 | ) 549 | if errors := update_response.get("errors"): 550 | handle_paypal_error( 551 | errors, 552 | order_id, 553 | payment, 554 | "Unable to patch order %s in Paypal: %s", 555 | ) 556 | 557 | capture_response = self.paypal_request_handler.capture_order( 558 | order_id=order_id 559 | ) 560 | if errors := capture_response.get("errors"): 561 | handle_paypal_error( 562 | errors, 563 | order_id, 564 | payment, 565 | "Unable to capture order %s in Paypal: %s", 566 | ) 567 | 568 | captured_order = capture_response.get("response") 569 | for purchase_unit in captured_order.get("purchase_units", []): 570 | for capture in safe_get(purchase_unit, ["payments", "captures"], []): 571 | with contextlib.suppress( 572 | ReferencedPayPalObject.MultipleObjectsReturned 573 | ): 574 | ReferencedPayPalObject.objects.get_or_create( 575 | order=payment.order, 576 | payment=payment, 577 | reference=capture["id"], 578 | ) 579 | if capture["status"] != "COMPLETED": 580 | messages.warning( 581 | request, 582 | _( 583 | "PayPal has not yet approved the payment. We will inform you as soon as the payment completed." 584 | ), 585 | ) 586 | payment.info = json.dumps(captured_order) 587 | payment.state = OrderPayment.PAYMENT_STATE_PENDING 588 | payment.save() 589 | return 590 | 591 | payment.refresh_from_db() 592 | if captured_order["status"] != "COMPLETED": 593 | payment.fail(info=captured_order) 594 | logger.error("Invalid state: %s", repr(captured_order)) 595 | raise PaymentException( 596 | _( 597 | "We were unable to process your payment. See below for details on how to proceed." 598 | ) 599 | ) 600 | 601 | if payment.state == OrderPayment.PAYMENT_STATE_CONFIRMED: 602 | logger.warning( 603 | "PayPal success event even though order is already marked as paid" 604 | ) 605 | return 606 | 607 | try: 608 | payment.info = json.dumps(captured_order) 609 | payment.save(update_fields=["info"]) 610 | payment.confirm() 611 | except Quota.QuotaExceededException as e: 612 | raise PaymentException(str(e)) from e 613 | except SendMailException: 614 | messages.warning( 615 | request, _("There was an error sending the confirmation mail.") 616 | ) 617 | return None 618 | 619 | def payment_pending_render(self, request, payment) -> str: 620 | retry = True 621 | with contextlib.suppress(KeyError): 622 | if ( 623 | payment.info 624 | and safe_get( 625 | payment.info_data.get("purchase_units", [{}])[0], 626 | ["payments", "captures", "status"], 627 | ) 628 | == "pending" 629 | ): 630 | retry = False 631 | template = get_template("plugins/paypal/pending.html") 632 | ctx = { 633 | "request": request, 634 | "event": self.event, 635 | "settings": self.settings, 636 | "retry": retry, 637 | "order": payment.order, 638 | } 639 | return template.render(ctx) 640 | 641 | def matching_id(self, payment: OrderPayment): 642 | order_id = None 643 | for trans in payment.info_data.get("purchase_units", []): 644 | for res in safe_get(trans, ["payments", "captures"], []): 645 | order_id = res.get("id") 646 | break 647 | return order_id or payment.info_data.get("id", None) 648 | 649 | def api_payment_details(self, payment: OrderPayment): 650 | order_id = self.matching_id(payment) 651 | return { 652 | "payer_email": safe_get( 653 | payment.info_data, ["payer", "payer_info", "email"] 654 | ), 655 | "payer_id": safe_get( 656 | payment.info_data, ["payer", "payer_info", "payer_id"] 657 | ), 658 | "cart_id": payment.info_data.get("cart", None), 659 | "payment_id": payment.info_data.get("id", None), 660 | "sale_id": order_id, 661 | } 662 | 663 | def payment_control_render(self, request: HttpRequest, payment: OrderPayment): 664 | template = get_template("plugins/paypal/control.html") 665 | order_id = self.matching_id(payment) 666 | ctx = { 667 | "request": request, 668 | "event": self.event, 669 | "settings": self.settings, 670 | "payment_info": payment.info_data, 671 | "order": payment.order, 672 | "sale_id": order_id, 673 | } 674 | return template.render(ctx) 675 | 676 | def payment_control_render_short(self, payment: OrderPayment) -> str: 677 | return safe_get(payment.info_data, ["payer", "payer_info", "email"], "") 678 | 679 | def payment_partial_refund_supported(self, payment: OrderPayment): 680 | # Paypal refunds are possible for 180 days after purchase: 681 | # 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. 682 | return (now() - payment.payment_date).days <= 180 683 | 684 | def payment_refund_supported(self, payment: OrderPayment): 685 | self.payment_partial_refund_supported(payment) 686 | 687 | def execute_refund(self, refund: OrderRefund): 688 | payment_info_data = refund.payment.info_data 689 | 690 | capture_id = next( 691 | ( 692 | capture.get("id") 693 | for capture in safe_get( 694 | payment_info_data.get("purchase_units", [{}])[0], 695 | ["payments", "captures"], 696 | [], 697 | ) 698 | if capture.get("status") in ["COMPLETED", "PARTIALLY_REFUNDED"] 699 | ), 700 | None, 701 | ) 702 | refund_payment = self.paypal_request_handler.refund_payment( 703 | capture_id=capture_id, 704 | refund_data={ 705 | "amount": { 706 | "value": self.format_price(refund.amount), 707 | "currency_code": refund.order.event.currency, 708 | } 709 | }, 710 | merchant_id=self.event.settings.payment_paypal_merchant_id, 711 | ) 712 | if errors := refund_payment.get("errors"): 713 | logger.error("execute_refund: %s", errors["reason"]) 714 | refund.order.log_action( 715 | "pretix.event.order.refund.failed", 716 | { 717 | "local_id": refund.local_id, 718 | "provider": refund.provider, 719 | "error": str(errors), 720 | }, 721 | ) 722 | raise PaymentException( 723 | _( 724 | "An error occurred while communicating with PayPal, please try again." 725 | ) 726 | ) 727 | 728 | refund_payment_response = refund_payment.get("response") 729 | refund.info = json.dumps(refund_payment_response) 730 | refund.save(update_fields=["info"]) 731 | 732 | refund_id = refund_payment_response["id"] 733 | refund_detail = self.paypal_request_handler.get_refund_detail( 734 | refund_id=refund_id, 735 | merchant_id=self.event.settings.payment_paypal_merchant_id, 736 | ) 737 | 738 | if errors := refund_detail.get("errors"): 739 | refund.order.log_action( 740 | "pretix.event.order.refund.failed", 741 | { 742 | "local_id": refund.local_id, 743 | "provider": refund.provider, 744 | "error": str(errors), 745 | }, 746 | ) 747 | raise PaymentException( 748 | _( 749 | "An error occurred while communicating with PayPal, please try again." 750 | ) 751 | ) 752 | 753 | refund_detail_response = refund_detail.get("response") 754 | refund.info = json.dumps(refund_detail_response) 755 | refund.save(update_fields=["info"]) 756 | 757 | if refund_detail_response["status"] == "COMPLETED": 758 | refund.done() 759 | elif refund_detail_response["status"] == "PENDING": 760 | refund.state = OrderRefund.REFUND_STATE_TRANSIT 761 | refund.save(update_fields=["state"]) 762 | else: 763 | refund.order.log_action( 764 | "pretix.event.order.refund.failed", 765 | { 766 | "local_id": refund.local_id, 767 | "provider": refund.provider, 768 | "error": str(refund_detail_response["status_details"]["reason"]), 769 | }, 770 | ) 771 | raise PaymentException( 772 | _("Refunding the amount via PayPal failed: {}").format( 773 | refund_detail_response["status_details"]["reason"] 774 | ) 775 | ) 776 | 777 | def payment_prepare(self, request, payment_obj): 778 | payee = {} 779 | if self.settings.get("client_id") or self.settings.get("secret"): 780 | # In case organizer set their own info 781 | # Check undeleted infos and remove theme 782 | if request.event.settings.payment_paypal_connect_user_id: 783 | del request.event.settings.payment_paypal_connect_user_id 784 | if request.event.settings.payment_paypal_merchant_id: 785 | del request.event.settings.payment_paypal_merchant_id 786 | elif request.event.settings.payment_paypal_connect_user_id: 787 | payee = { 788 | "merchant_id": request.event.settings.payment_paypal_merchant_id, 789 | } 790 | 791 | order_response = self.paypal_request_handler.create_order( 792 | order_data={ 793 | "intent": "CAPTURE", 794 | "purchase_units": [ 795 | { 796 | "items": [ 797 | { 798 | "name": ( 799 | f"{self.settings.prefix} " 800 | if self.settings.prefix 801 | else "" 802 | ) 803 | + __("Order for %s") % str(request.event), 804 | "quantity": "1", 805 | "unit_amount": { 806 | "currency_code": request.event.currency, 807 | "value": self.format_price(payment_obj.amount), 808 | }, 809 | } 810 | ], 811 | "amount": { 812 | "currency_code": request.event.currency, 813 | "value": self.format_price(payment_obj.amount), 814 | "breakdown": { 815 | "item_total": { 816 | "currency_code": request.event.currency, 817 | "value": self.format_price(payment_obj.amount), 818 | } 819 | }, 820 | }, 821 | "description": __("Event tickets for {event}").format( 822 | event=request.event.name 823 | ), 824 | "payee": payee, 825 | } 826 | ], 827 | "payment_source": { 828 | "paypal": { 829 | "experience_context": { 830 | "payment_method_preference": "UNRESTRICTED", 831 | "landing_page": "LOGIN", 832 | "return_url": build_absolute_uri( 833 | request.event, "plugins:eventyay_paypal:return" 834 | ), 835 | "cancel_url": build_absolute_uri( 836 | request.event, "plugins:eventyay_paypal:abort" 837 | ), 838 | } 839 | } 840 | }, 841 | } 842 | ) 843 | 844 | if order_response.get("errors"): 845 | errors = order_response.get("errors") 846 | messages.error( 847 | request, 848 | _("An error occurred during connecting with PayPal: {}").format( 849 | errors["reason"] 850 | ), 851 | ) 852 | return None 853 | 854 | order_created = order_response.get("response") 855 | request.session["payment_paypal_payment"] = None 856 | return self._create_order(request, order_created) 857 | 858 | def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]): 859 | if obj.info_data: 860 | d = obj.info_data 861 | purchase_units = d.get("purchase_units", []) 862 | if purchase_units: 863 | purchase_units[0] = {"payments": purchase_units[0].get("payments", {})} 864 | new = { 865 | "id": d.get("id"), 866 | "payer": {"payer_info": {"email": "█"}}, 867 | "update_time": d.get("update_time"), 868 | "purchase_units": purchase_units, 869 | "_shredded": True, 870 | } 871 | obj.info = json.dumps(new) 872 | obj.save(update_fields=["info"]) 873 | 874 | for le in ( 875 | obj.order.all_logentries() 876 | .filter(action_type="pretix.plugins.eventyay_paypal.event") 877 | .exclude(data="") 878 | ): 879 | d = le.parsed_data 880 | if "resource" in d: 881 | d["resource"] = { 882 | "id": d["resource"].get("id"), 883 | "sale_id": d["resource"].get("sale_id"), 884 | "parent_payment": d["resource"].get("parent_payment"), 885 | } 886 | le.data = json.dumps(d) 887 | le.shredded = True 888 | le.save(update_fields=["data", "shredded"]) 889 | 890 | def render_invoice_text(self, order: Order, payment: OrderPayment) -> str: 891 | if order.status == Order.STATUS_PAID: 892 | payment_id = payment.info_data.get('id') 893 | if not payment_id: 894 | return super().render_invoice_text(order, payment) 895 | 896 | try: 897 | paypal_sale_id = payment.info_data['transactions'][0]['related_resources'][0]['sale']['id'] 898 | return ( 899 | f"{_('The payment for this invoice has already been received.')}\r\n" 900 | f"{_('PayPal payment ID')}: {payment_id}\r\n" 901 | f"{_('PayPal sale ID')}: {paypal_sale_id}" 902 | ) 903 | except (KeyError, IndexError): 904 | return ( 905 | f"{_('The payment for this invoice has already been received.')}\r\n" 906 | f"{_('PayPal payment ID')}: {payment_id}" 907 | ) 908 | return self.settings.get('_invoice_text', as_type=LazyI18nString, default='') 909 | --------------------------------------------------------------------------------