{% blocktrans trimmed %}
4 | The total amount listed above will be withdrawn from your PayPal account after the
5 | confirmation of your purchase.
6 | {% endblocktrans %}
{% 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 %}
{% 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 %}
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 |
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 |
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 |
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 | '
"
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 += '