pretix-thepay 9.0.18__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pretix_thepay/__init__.py +15 -0
- pretix_thepay/apps.py +40 -0
- pretix_thepay/locale/en/LC_MESSAGES/django.po +5 -0
- pretix_thepay/payment.py +763 -0
- pretix_thepay/pretix_plugin_urls.py +10 -0
- pretix_thepay/signals.py +18 -0
- pretix_thepay/urls.py +15 -0
- pretix_thepay/views.py +345 -0
- pretix_thepay-9.0.18.dist-info/METADATA +139 -0
- pretix_thepay-9.0.18.dist-info/RECORD +13 -0
- pretix_thepay-9.0.18.dist-info/WHEEL +5 -0
- pretix_thepay-9.0.18.dist-info/entry_points.txt +2 -0
- pretix_thepay-9.0.18.dist-info/top_level.txt +1 -0
pretix_thepay/payment.py
ADDED
|
@@ -0,0 +1,763 @@
|
|
|
1
|
+
"""
|
|
2
|
+
The Pay payment provider for Pretix.
|
|
3
|
+
|
|
4
|
+
This module implements the The Pay payment gateway integration for Pretix,
|
|
5
|
+
providing secure payment processing with The Pay REST API.
|
|
6
|
+
"""
|
|
7
|
+
import base64
|
|
8
|
+
import hashlib
|
|
9
|
+
import hmac
|
|
10
|
+
import logging
|
|
11
|
+
import requests
|
|
12
|
+
from collections import OrderedDict
|
|
13
|
+
from decimal import Decimal, ROUND_HALF_UP
|
|
14
|
+
from email.utils import formatdate
|
|
15
|
+
from typing import Dict, Optional, Any
|
|
16
|
+
|
|
17
|
+
from django import forms
|
|
18
|
+
from django.http import HttpRequest
|
|
19
|
+
from django.urls import reverse
|
|
20
|
+
from django.utils.html import format_html
|
|
21
|
+
from django.utils.translation import gettext_lazy as _
|
|
22
|
+
from pretix.base.models import Order, OrderPayment, OrderRefund
|
|
23
|
+
from pretix.base.payment import BasePaymentProvider, PaymentException
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ThePaySettingsForm(forms.Form):
|
|
29
|
+
"""
|
|
30
|
+
Configuration form for The Pay payment provider settings.
|
|
31
|
+
|
|
32
|
+
Collects merchant credentials and gateway configuration
|
|
33
|
+
required for The Pay payment processing.
|
|
34
|
+
"""
|
|
35
|
+
merchant_id = forms.CharField(
|
|
36
|
+
label=_('Merchant ID'),
|
|
37
|
+
help_text=_('Your The Pay merchant ID'),
|
|
38
|
+
required=True,
|
|
39
|
+
max_length=50,
|
|
40
|
+
)
|
|
41
|
+
project_id = forms.IntegerField(
|
|
42
|
+
label=_('Project ID'),
|
|
43
|
+
help_text=_('Your The Pay project ID'),
|
|
44
|
+
required=True,
|
|
45
|
+
)
|
|
46
|
+
api_password = forms.CharField(
|
|
47
|
+
label=_('API Password'),
|
|
48
|
+
help_text=_('Your The Pay API password'),
|
|
49
|
+
required=True,
|
|
50
|
+
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password'}),
|
|
51
|
+
)
|
|
52
|
+
language = forms.ChoiceField(
|
|
53
|
+
label=_('Language'),
|
|
54
|
+
help_text=_('Default language for payment gateway'),
|
|
55
|
+
choices=[
|
|
56
|
+
('cs', 'Czech'),
|
|
57
|
+
('sk', 'Slovak'),
|
|
58
|
+
('en', 'English'),
|
|
59
|
+
],
|
|
60
|
+
required=True,
|
|
61
|
+
initial='en',
|
|
62
|
+
)
|
|
63
|
+
test_mode = forms.BooleanField(
|
|
64
|
+
label=_('Test mode'),
|
|
65
|
+
help_text=_('Enable test mode for development'),
|
|
66
|
+
required=False,
|
|
67
|
+
initial=False,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class ThePay(BasePaymentProvider):
|
|
72
|
+
"""
|
|
73
|
+
The Pay payment provider implementation for Pretix.
|
|
74
|
+
|
|
75
|
+
Handles payment processing using The Pay REST API.
|
|
76
|
+
"""
|
|
77
|
+
identifier = 'thepay'
|
|
78
|
+
verbose_name = _('The Pay')
|
|
79
|
+
public_name = _('The Pay')
|
|
80
|
+
abort_pending_allowed = False
|
|
81
|
+
refunds_allowed = True
|
|
82
|
+
execute_payment_needs_user = True
|
|
83
|
+
test_mode_message = _(
|
|
84
|
+
'This payment provider can operate in demo mode. Enable "Test mode" in the settings to use The Pay demo environment.'
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def settings_form_fields(self):
|
|
89
|
+
# Preserve default fields (_enabled, fees, availability, etc.) and add ours
|
|
90
|
+
fields = OrderedDict(super().settings_form_fields)
|
|
91
|
+
fields.update(ThePaySettingsForm.base_fields)
|
|
92
|
+
return fields
|
|
93
|
+
|
|
94
|
+
def settings_content_render(self, request):
|
|
95
|
+
return """
|
|
96
|
+
<p>Configure your The Pay payment gateway settings.</p>
|
|
97
|
+
<p>You need to:</p>
|
|
98
|
+
<ul>
|
|
99
|
+
<li>Obtain your merchant ID from The Pay</li>
|
|
100
|
+
<li>Obtain your project ID from The Pay</li>
|
|
101
|
+
<li>Configure your API password</li>
|
|
102
|
+
</ul>
|
|
103
|
+
<p><strong>Note:</strong> Enable test mode to use the demo environment.</p>
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def payment_form_render(self, request, total, order=None) -> str:
|
|
107
|
+
"""
|
|
108
|
+
Render payment form HTML.
|
|
109
|
+
|
|
110
|
+
For The Pay, customers are redirected immediately to the gateway,
|
|
111
|
+
so no form is displayed.
|
|
112
|
+
"""
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
def checkout_confirm_render(self, request, order=None, info_data=None) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Render checkout confirmation page HTML.
|
|
118
|
+
|
|
119
|
+
Displays information about the The Pay payment method and
|
|
120
|
+
informs customers they will be redirected to the gateway.
|
|
121
|
+
"""
|
|
122
|
+
return _("You will be redirected to The Pay to complete your payment.")
|
|
123
|
+
|
|
124
|
+
def payment_is_valid_session(self, request):
|
|
125
|
+
"""
|
|
126
|
+
Validate payment session.
|
|
127
|
+
|
|
128
|
+
Returns True as The Pay redirects immediately to the gateway
|
|
129
|
+
without requiring session validation.
|
|
130
|
+
"""
|
|
131
|
+
return True
|
|
132
|
+
|
|
133
|
+
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> Optional[str]:
|
|
134
|
+
"""
|
|
135
|
+
Execute the payment by creating a payment in The Pay and redirecting.
|
|
136
|
+
"""
|
|
137
|
+
order = payment.order
|
|
138
|
+
event = order.event
|
|
139
|
+
|
|
140
|
+
settings_dict = self.settings
|
|
141
|
+
merchant_id = settings_dict.get('merchant_id', '')
|
|
142
|
+
project_id = settings_dict.get('project_id', '')
|
|
143
|
+
api_password = settings_dict.get('api_password', '')
|
|
144
|
+
language = settings_dict.get('language', 'en')
|
|
145
|
+
test_mode = settings_dict.get('test_mode', False)
|
|
146
|
+
|
|
147
|
+
if not merchant_id or not project_id or not api_password:
|
|
148
|
+
raise PaymentException(_('The Pay is not configured properly.'))
|
|
149
|
+
|
|
150
|
+
api_url = self._get_api_url(test_mode)
|
|
151
|
+
|
|
152
|
+
# Create payment in The Pay
|
|
153
|
+
try:
|
|
154
|
+
payment_url = self._create_payment(
|
|
155
|
+
payment=payment,
|
|
156
|
+
order=order,
|
|
157
|
+
merchant_id=merchant_id,
|
|
158
|
+
project_id=project_id,
|
|
159
|
+
api_password=api_password,
|
|
160
|
+
api_url=api_url,
|
|
161
|
+
language=language,
|
|
162
|
+
return_url=request.build_absolute_uri(
|
|
163
|
+
reverse('plugins:pretix_thepay:return', kwargs={
|
|
164
|
+
'order': order.code,
|
|
165
|
+
'payment': payment.id,
|
|
166
|
+
'hash': payment.order.secret
|
|
167
|
+
})
|
|
168
|
+
),
|
|
169
|
+
notify_url=request.build_absolute_uri(
|
|
170
|
+
reverse('plugins:pretix_thepay:notify', kwargs={
|
|
171
|
+
'order': order.code,
|
|
172
|
+
'payment': payment.id,
|
|
173
|
+
'hash': payment.order.secret
|
|
174
|
+
})
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
except Exception as e:
|
|
178
|
+
logger.error(f'The Pay payment creation error: {e}', exc_info=True)
|
|
179
|
+
raise PaymentException(_('Error preparing payment request.'))
|
|
180
|
+
|
|
181
|
+
return payment_url
|
|
182
|
+
|
|
183
|
+
def _create_payment(self, payment: OrderPayment, order: Order, merchant_id: str,
|
|
184
|
+
project_id: int, api_password: str, api_url: str,
|
|
185
|
+
language: str, return_url: str, notify_url: str) -> str:
|
|
186
|
+
"""
|
|
187
|
+
Create a payment in The Pay and return the payment URL.
|
|
188
|
+
|
|
189
|
+
Implementation based on The Pay API documentation:
|
|
190
|
+
https://docs.thepay.eu/#tag/Payment-Creation
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
payment: OrderPayment instance
|
|
194
|
+
order: Order instance
|
|
195
|
+
merchant_id: The Pay merchant ID
|
|
196
|
+
project_id: The Pay project ID
|
|
197
|
+
api_password: The Pay API password
|
|
198
|
+
api_url: The Pay API base URL
|
|
199
|
+
language: Language code
|
|
200
|
+
return_url: URL to redirect customer after payment
|
|
201
|
+
notify_url: URL for server-to-server notification
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Payment URL to redirect customer to
|
|
205
|
+
|
|
206
|
+
Note: Verify the following from The Pay documentation:
|
|
207
|
+
- Exact API endpoint path (may be /api/v1/payments or similar)
|
|
208
|
+
- Request format (JSON vs form-encoded)
|
|
209
|
+
- Required vs optional parameters
|
|
210
|
+
- Response format and field names
|
|
211
|
+
"""
|
|
212
|
+
# Prepare payment data according to The Pay API format
|
|
213
|
+
# Documentation: https://gate.thepay.cz/openapi.yaml
|
|
214
|
+
# Note: project_id is in the URL path, not in the request body
|
|
215
|
+
currency = self._get_order_currency(order)
|
|
216
|
+
payment_data = {
|
|
217
|
+
'amount': self._format_amount_minor_units(payment.amount, currency),
|
|
218
|
+
'currency_code': self._get_currency_code(currency),
|
|
219
|
+
'uid': f'pretix-{payment.id}-{order.code}', # Unique identifier (must be unique per project)
|
|
220
|
+
'order_id': order.code,
|
|
221
|
+
'description_for_customer': f'Order {order.code}',
|
|
222
|
+
'description_for_merchant': f'Pretix order {order.code} - Payment {payment.id}',
|
|
223
|
+
'return_url': return_url,
|
|
224
|
+
'notif_url': notify_url,
|
|
225
|
+
'language_code': language,
|
|
226
|
+
'is_customer_notification_enabled': False,
|
|
227
|
+
# Sensible defaults matching The Pay API examples
|
|
228
|
+
'can_customer_change_method': True,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
# Add customer information (required by The Pay)
|
|
232
|
+
customer_data = {}
|
|
233
|
+
if order.invoice_address:
|
|
234
|
+
name_parts = getattr(order.invoice_address, 'name_parts', None)
|
|
235
|
+
if name_parts:
|
|
236
|
+
customer_data['name'] = name_parts.get('given_name') or 'Customer'
|
|
237
|
+
customer_data['surname'] = name_parts.get('family_name') or 'Customer'
|
|
238
|
+
else:
|
|
239
|
+
full_name = getattr(order.invoice_address, 'name', None)
|
|
240
|
+
if full_name:
|
|
241
|
+
parts = full_name.split(None, 1)
|
|
242
|
+
customer_data['name'] = parts[0] if parts else 'Customer'
|
|
243
|
+
customer_data['surname'] = parts[1] if len(parts) > 1 else 'Customer'
|
|
244
|
+
else:
|
|
245
|
+
customer_data['name'] = 'Customer'
|
|
246
|
+
customer_data['surname'] = 'Customer'
|
|
247
|
+
else:
|
|
248
|
+
customer_data['name'] = 'Customer'
|
|
249
|
+
customer_data['surname'] = 'Customer'
|
|
250
|
+
|
|
251
|
+
if order.email:
|
|
252
|
+
customer_data['email'] = order.email
|
|
253
|
+
elif order.invoice_address and getattr(order.invoice_address, 'phone', None):
|
|
254
|
+
customer_data['phone'] = order.invoice_address.phone
|
|
255
|
+
else:
|
|
256
|
+
raise PaymentException(_('Customer email or phone is required for The Pay.'))
|
|
257
|
+
|
|
258
|
+
if order.invoice_address:
|
|
259
|
+
if getattr(order.invoice_address, 'phone', None):
|
|
260
|
+
customer_data.setdefault('phone', order.invoice_address.phone)
|
|
261
|
+
if getattr(order.invoice_address, 'country', None):
|
|
262
|
+
country = getattr(order.invoice_address, 'country', '')
|
|
263
|
+
country_code = getattr(country, 'alpha2', None) or str(country)
|
|
264
|
+
city = getattr(order.invoice_address, 'city', '') or ''
|
|
265
|
+
zipcode = getattr(order.invoice_address, 'zipcode', '') or ''
|
|
266
|
+
street = getattr(order.invoice_address, 'street', '') or ''
|
|
267
|
+
if country_code and city and zipcode and street:
|
|
268
|
+
customer_data['billing_address'] = {
|
|
269
|
+
'country_code': country_code,
|
|
270
|
+
'city': city,
|
|
271
|
+
'zip': zipcode,
|
|
272
|
+
'street': street,
|
|
273
|
+
}
|
|
274
|
+
payment_data['customer'] = customer_data
|
|
275
|
+
|
|
276
|
+
# Create payment via API
|
|
277
|
+
# Documentation: https://gate.thepay.cz/openapi.yaml
|
|
278
|
+
# Endpoint format (per spec): https://api.thepay.cz/v1/projects/{project_id}/payments?merchant_id=...
|
|
279
|
+
# Demo: https://demo.api.thepay.cz/v1/projects/{project_id}/payments?merchant_id=...
|
|
280
|
+
# Authentication: Signature and SignatureDate headers (not Basic Auth)
|
|
281
|
+
try:
|
|
282
|
+
base_url = api_url.rstrip('/')
|
|
283
|
+
if 'demo.api.thepay.cz' in api_url:
|
|
284
|
+
endpoint_url = f'https://demo.api.thepay.cz/v1/projects/{project_id}/payments'
|
|
285
|
+
elif 'api.thepay.cz' in api_url or 'thepay.cz' in api_url:
|
|
286
|
+
endpoint_url = f'https://api.thepay.cz/v1/projects/{project_id}/payments'
|
|
287
|
+
else:
|
|
288
|
+
endpoint_url = f'{base_url}/v1/projects/{project_id}/payments'
|
|
289
|
+
|
|
290
|
+
# Generate authentication headers according to The Pay API spec
|
|
291
|
+
# Signature = hash256(merchant_id + password + DateTime)
|
|
292
|
+
# SignatureDate = current datetime in RFC7231 format
|
|
293
|
+
signature_date = self._get_signature_date()
|
|
294
|
+
signature = self._calculate_signature(merchant_id, api_password, signature_date)
|
|
295
|
+
|
|
296
|
+
# Add merchant_id to query string (required by API)
|
|
297
|
+
endpoint_url_with_params = f'{endpoint_url}?merchant_id={merchant_id}'
|
|
298
|
+
|
|
299
|
+
response = requests.post(
|
|
300
|
+
endpoint_url_with_params,
|
|
301
|
+
json=payment_data,
|
|
302
|
+
headers={
|
|
303
|
+
'Content-Type': 'application/json',
|
|
304
|
+
'Signature': signature,
|
|
305
|
+
'SignatureDate': signature_date,
|
|
306
|
+
},
|
|
307
|
+
timeout=30
|
|
308
|
+
)
|
|
309
|
+
response.raise_for_status()
|
|
310
|
+
result = response.json()
|
|
311
|
+
|
|
312
|
+
# The Pay returns payment URLs; uid is the one we provided
|
|
313
|
+
payment_uid = payment_data.get('uid')
|
|
314
|
+
if not payment_uid:
|
|
315
|
+
logger.error('The Pay API response missing payment UID in request payload.')
|
|
316
|
+
raise PaymentException(_('Invalid request data for The Pay.'))
|
|
317
|
+
|
|
318
|
+
# Store payment_uid in payment.info for later status queries
|
|
319
|
+
payment_info = self._get_payment_info(payment)
|
|
320
|
+
payment_info['payment_uid'] = payment_uid
|
|
321
|
+
if result.get('detail_url'):
|
|
322
|
+
payment_info['detail_url'] = result.get('detail_url')
|
|
323
|
+
if result.get('pay_url'):
|
|
324
|
+
payment_info['pay_url'] = result.get('pay_url')
|
|
325
|
+
self._save_payment_info(payment, payment_info)
|
|
326
|
+
|
|
327
|
+
# Get payment URL from response or construct it
|
|
328
|
+
payment_url = result.get('pay_url')
|
|
329
|
+
if not payment_url:
|
|
330
|
+
logger.error(f'The Pay API response: {response.text[:500]}')
|
|
331
|
+
raise PaymentException(_('Invalid response from The Pay API: payment URL not found.'))
|
|
332
|
+
|
|
333
|
+
return payment_url
|
|
334
|
+
|
|
335
|
+
except requests.RequestException as e:
|
|
336
|
+
logger.error(f'The Pay API request failed: {e}', exc_info=True)
|
|
337
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
338
|
+
logger.error(f'The Pay API error response: {e.response.text[:500]}')
|
|
339
|
+
raise PaymentException(_('Failed to create payment in The Pay.'))
|
|
340
|
+
|
|
341
|
+
def _get_signature_date(self) -> str:
|
|
342
|
+
"""
|
|
343
|
+
Get current datetime in RFC7231 format for SignatureDate header.
|
|
344
|
+
|
|
345
|
+
Format: "Mon, 23 Sep 2019 06:07:08 GMT"
|
|
346
|
+
According to The Pay API spec: https://gate.thepay.cz/openapi.yaml
|
|
347
|
+
|
|
348
|
+
Returns:
|
|
349
|
+
Current datetime string in RFC7231 format
|
|
350
|
+
"""
|
|
351
|
+
return formatdate(timeval=None, localtime=False, usegmt=True)
|
|
352
|
+
|
|
353
|
+
def _get_payment_info(self, payment: OrderPayment) -> Dict[str, Any]:
|
|
354
|
+
info = getattr(payment, 'info_data', None)
|
|
355
|
+
if info is None:
|
|
356
|
+
info = getattr(payment, 'info', None) or {}
|
|
357
|
+
return info
|
|
358
|
+
|
|
359
|
+
def _save_payment_info(self, payment: OrderPayment, info: Dict[str, Any]) -> None:
|
|
360
|
+
if hasattr(payment, 'info_data'):
|
|
361
|
+
payment.info_data = info
|
|
362
|
+
else:
|
|
363
|
+
payment.info = info
|
|
364
|
+
payment.save(update_fields=['info'])
|
|
365
|
+
|
|
366
|
+
def _calculate_signature(self, merchant_id: str, api_password: str, signature_date: str) -> str:
|
|
367
|
+
"""
|
|
368
|
+
Calculate Signature header for The Pay API authentication.
|
|
369
|
+
|
|
370
|
+
According to The Pay API spec: https://gate.thepay.cz/openapi.yaml
|
|
371
|
+
Signature = hash256(merchant_id + password + DateTime)
|
|
372
|
+
|
|
373
|
+
Format: merchant_idPasswordDatetime
|
|
374
|
+
Example: "1passwordMon, 23 Sep 2019 06:07:08 GMT"
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
merchant_id: Merchant ID
|
|
378
|
+
api_password: API password
|
|
379
|
+
signature_date: Current datetime in RFC7231 format
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
SHA256 hash as hexadecimal string
|
|
383
|
+
"""
|
|
384
|
+
# Concatenate: merchant_id + password + DateTime
|
|
385
|
+
signature_string = f'{merchant_id}{api_password}{signature_date}'
|
|
386
|
+
|
|
387
|
+
# Calculate SHA256 hash
|
|
388
|
+
signature_hash = hashlib.sha256(signature_string.encode('utf-8')).hexdigest()
|
|
389
|
+
|
|
390
|
+
return signature_hash
|
|
391
|
+
|
|
392
|
+
def _get_currency_code(self, currency: str) -> str:
|
|
393
|
+
"""
|
|
394
|
+
Convert ISO 4217 currency code to The Pay format.
|
|
395
|
+
|
|
396
|
+
Args:
|
|
397
|
+
currency: ISO 4217 currency code (e.g., 'EUR', 'USD')
|
|
398
|
+
|
|
399
|
+
Returns:
|
|
400
|
+
Currency code (The Pay uses ISO 4217 codes)
|
|
401
|
+
"""
|
|
402
|
+
# The Pay uses ISO 4217 currency codes
|
|
403
|
+
return currency.upper()
|
|
404
|
+
|
|
405
|
+
def _get_order_currency(self, order: Order) -> str:
|
|
406
|
+
currency = getattr(order, 'currency', None)
|
|
407
|
+
if currency:
|
|
408
|
+
return currency
|
|
409
|
+
if getattr(order, 'event', None) and getattr(order.event, 'currency', None):
|
|
410
|
+
return order.event.currency
|
|
411
|
+
raise PaymentException(_('Order currency is not available.'))
|
|
412
|
+
|
|
413
|
+
def _get_api_url(self, test_mode: bool) -> str:
|
|
414
|
+
return 'https://demo.api.thepay.cz' if test_mode else 'https://api.thepay.cz'
|
|
415
|
+
|
|
416
|
+
def _get_currency_precision(self, currency: str) -> int:
|
|
417
|
+
try:
|
|
418
|
+
from babel.numbers import get_currency_precision
|
|
419
|
+
return int(get_currency_precision(currency))
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
logger.warning('Falling back to 2-decimal currency precision: %s', exc)
|
|
422
|
+
return 2
|
|
423
|
+
|
|
424
|
+
def _format_amount_minor_units(self, amount: Decimal, currency: str) -> str:
|
|
425
|
+
precision = self._get_currency_precision(currency)
|
|
426
|
+
factor = Decimal(10) ** precision
|
|
427
|
+
minor_units = (amount * factor).quantize(Decimal('1'), rounding=ROUND_HALF_UP)
|
|
428
|
+
return str(int(minor_units))
|
|
429
|
+
|
|
430
|
+
def _get_payment_status(self, payment_uid: str, merchant_id: str, project_id: int,
|
|
431
|
+
api_password: str, api_url: str) -> Optional[str]:
|
|
432
|
+
"""
|
|
433
|
+
Query payment status from The Pay API.
|
|
434
|
+
|
|
435
|
+
According to The Pay API spec: https://gate.thepay.cz/openapi.yaml
|
|
436
|
+
Endpoint: GET /v1/projects/{project_id}/payments/{payment_uid}
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
payment_uid: Payment UID (the uid we sent when creating payment)
|
|
440
|
+
merchant_id: Merchant ID
|
|
441
|
+
project_id: Project ID
|
|
442
|
+
api_password: API password
|
|
443
|
+
api_url: API base URL
|
|
444
|
+
|
|
445
|
+
Returns:
|
|
446
|
+
Payment state ('paid', 'waiting_for_payment', 'expired', etc.) or None if error
|
|
447
|
+
"""
|
|
448
|
+
try:
|
|
449
|
+
# Construct endpoint URL
|
|
450
|
+
if 'demo.api.thepay.cz' in api_url:
|
|
451
|
+
endpoint_url = f'https://demo.api.thepay.cz/v1/projects/{project_id}/payments/{payment_uid}'
|
|
452
|
+
elif 'api.thepay.cz' in api_url or 'thepay.cz' in api_url:
|
|
453
|
+
endpoint_url = f'https://api.thepay.cz/v1/projects/{project_id}/payments/{payment_uid}'
|
|
454
|
+
else:
|
|
455
|
+
base_url = api_url.rstrip('/')
|
|
456
|
+
endpoint_url = f'{base_url}/v1/projects/{project_id}/payments/{payment_uid}'
|
|
457
|
+
|
|
458
|
+
# Generate authentication headers
|
|
459
|
+
signature_date = self._get_signature_date()
|
|
460
|
+
signature = self._calculate_signature(merchant_id, api_password, signature_date)
|
|
461
|
+
|
|
462
|
+
# Add merchant_id to query string
|
|
463
|
+
endpoint_url_with_params = f'{endpoint_url}?merchant_id={merchant_id}'
|
|
464
|
+
|
|
465
|
+
response = requests.get(
|
|
466
|
+
endpoint_url_with_params,
|
|
467
|
+
headers={
|
|
468
|
+
'Content-Type': 'application/json',
|
|
469
|
+
'Signature': signature,
|
|
470
|
+
'SignatureDate': signature_date,
|
|
471
|
+
},
|
|
472
|
+
timeout=8
|
|
473
|
+
)
|
|
474
|
+
response.raise_for_status()
|
|
475
|
+
result = response.json()
|
|
476
|
+
|
|
477
|
+
# Return payment state
|
|
478
|
+
return result.get('state') or result.get('status')
|
|
479
|
+
|
|
480
|
+
except Exception as e:
|
|
481
|
+
logger.error(f'Error querying The Pay payment status: {e}', exc_info=True)
|
|
482
|
+
return None
|
|
483
|
+
|
|
484
|
+
def payment_refund_supported(self, payment: OrderPayment) -> bool:
|
|
485
|
+
info = self._get_payment_info(payment)
|
|
486
|
+
payment_uid = info.get('payment_uid') or info.get('uid')
|
|
487
|
+
supported = bool(payment_uid)
|
|
488
|
+
if not supported:
|
|
489
|
+
fallback_uid = f'pretix-{payment.id}-{payment.order.code}'
|
|
490
|
+
info['payment_uid'] = fallback_uid
|
|
491
|
+
self._save_payment_info(payment, info)
|
|
492
|
+
payment_uid = fallback_uid
|
|
493
|
+
supported = True
|
|
494
|
+
return supported
|
|
495
|
+
|
|
496
|
+
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
|
|
497
|
+
return self.payment_refund_supported(payment)
|
|
498
|
+
|
|
499
|
+
# Compatibility with older Pretix versions
|
|
500
|
+
def refund_supported(self, payment: OrderPayment) -> bool:
|
|
501
|
+
return self.payment_refund_supported(payment)
|
|
502
|
+
|
|
503
|
+
def partial_refund_supported(self, payment: OrderPayment) -> bool:
|
|
504
|
+
return self.payment_partial_refund_supported(payment)
|
|
505
|
+
|
|
506
|
+
def execute_refund(self, refund: OrderRefund):
|
|
507
|
+
payment = refund.payment
|
|
508
|
+
if not payment:
|
|
509
|
+
raise PaymentException(_('No payment found for refund.'))
|
|
510
|
+
|
|
511
|
+
info = self._get_payment_info(payment)
|
|
512
|
+
payment_uid = info.get('payment_uid') or info.get('uid')
|
|
513
|
+
if not payment_uid:
|
|
514
|
+
payment_uid = f'pretix-{payment.id}-{payment.order.code}'
|
|
515
|
+
info['payment_uid'] = payment_uid
|
|
516
|
+
self._save_payment_info(payment, info)
|
|
517
|
+
|
|
518
|
+
settings_dict = self.settings
|
|
519
|
+
merchant_id = settings_dict.get('merchant_id', '')
|
|
520
|
+
project_id = settings_dict.get('project_id', '')
|
|
521
|
+
api_password = settings_dict.get('api_password', '')
|
|
522
|
+
test_mode = settings_dict.get('test_mode', False)
|
|
523
|
+
|
|
524
|
+
if not merchant_id or not project_id or not api_password:
|
|
525
|
+
raise PaymentException(_('The Pay is not configured properly.'))
|
|
526
|
+
|
|
527
|
+
api_url = self._get_api_url(test_mode).rstrip('/')
|
|
528
|
+
currency = self._get_order_currency(payment.order)
|
|
529
|
+
amount = self._format_amount_minor_units(refund.amount, currency)
|
|
530
|
+
reason = (
|
|
531
|
+
getattr(refund, 'reason', None) or
|
|
532
|
+
getattr(refund, 'comment', None) or
|
|
533
|
+
_('Refund from Pretix')
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
refund_info_before = self._get_refund_info(
|
|
538
|
+
payment_uid, merchant_id, project_id, api_password, api_url
|
|
539
|
+
)
|
|
540
|
+
if not refund_info_before:
|
|
541
|
+
raise PaymentException(_('Refund information is not available from The Pay.'))
|
|
542
|
+
|
|
543
|
+
refund_currency = refund_info_before.get('currency')
|
|
544
|
+
if refund_currency and refund_currency.upper() != currency.upper():
|
|
545
|
+
raise PaymentException(_('Refund currency does not match payment currency.'))
|
|
546
|
+
|
|
547
|
+
available_amount = int(refund_info_before.get('available_amount', 0))
|
|
548
|
+
amount_int = int(amount)
|
|
549
|
+
if amount_int < 1 or amount_int > available_amount:
|
|
550
|
+
raise PaymentException(_('Refund amount exceeds available amount.'))
|
|
551
|
+
|
|
552
|
+
self._request_refund(
|
|
553
|
+
payment_uid=payment_uid,
|
|
554
|
+
merchant_id=merchant_id,
|
|
555
|
+
project_id=project_id,
|
|
556
|
+
api_password=api_password,
|
|
557
|
+
api_url=api_url,
|
|
558
|
+
amount=amount_int,
|
|
559
|
+
reason=str(reason),
|
|
560
|
+
)
|
|
561
|
+
|
|
562
|
+
refund_info_after = self._get_refund_info(
|
|
563
|
+
payment_uid, merchant_id, project_id, api_password, api_url
|
|
564
|
+
)
|
|
565
|
+
self._store_refund_info(refund, refund_info_after or refund_info_before)
|
|
566
|
+
|
|
567
|
+
refund_state = self._get_refund_state(refund_info_after or {}, amount_int)
|
|
568
|
+
if refund_state in {'declined', 'failed'}:
|
|
569
|
+
raise PaymentException(_('Refund was declined by The Pay.'))
|
|
570
|
+
if refund_state == 'returned' or self._is_refund_visible(
|
|
571
|
+
refund_info_before, refund_info_after, amount_int
|
|
572
|
+
):
|
|
573
|
+
refund.done()
|
|
574
|
+
else:
|
|
575
|
+
refund.state = OrderRefund.REFUND_STATE_TRANSIT
|
|
576
|
+
refund.save(update_fields=['state'])
|
|
577
|
+
logger.info('The Pay refund is pending for refund %s', refund.id)
|
|
578
|
+
except requests.RequestException as e:
|
|
579
|
+
logger.error('The Pay refund request failed: %s', e, exc_info=True)
|
|
580
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
581
|
+
logger.error('The Pay refund error response: %s', e.response.text[:500])
|
|
582
|
+
raise PaymentException(_('Failed to create refund in The Pay.'))
|
|
583
|
+
|
|
584
|
+
def _get_refund_info(self, payment_uid: str, merchant_id: str, project_id: int,
|
|
585
|
+
api_password: str, api_url: str) -> Optional[Dict[str, Any]]:
|
|
586
|
+
try:
|
|
587
|
+
if 'demo.api.thepay.cz' in api_url:
|
|
588
|
+
endpoint_url = f'https://demo.api.thepay.cz/v1/projects/{project_id}/payments/{payment_uid}/refund'
|
|
589
|
+
elif 'api.thepay.cz' in api_url or 'thepay.cz' in api_url:
|
|
590
|
+
endpoint_url = f'https://api.thepay.cz/v1/projects/{project_id}/payments/{payment_uid}/refund'
|
|
591
|
+
else:
|
|
592
|
+
endpoint_url = f'{api_url}/v1/projects/{project_id}/payments/{payment_uid}/refund'
|
|
593
|
+
|
|
594
|
+
signature_date = self._get_signature_date()
|
|
595
|
+
signature = self._calculate_signature(merchant_id, api_password, signature_date)
|
|
596
|
+
endpoint_url_with_params = f'{endpoint_url}?merchant_id={merchant_id}'
|
|
597
|
+
|
|
598
|
+
response = requests.get(
|
|
599
|
+
endpoint_url_with_params,
|
|
600
|
+
headers={
|
|
601
|
+
'Content-Type': 'application/json',
|
|
602
|
+
'Signature': signature,
|
|
603
|
+
'SignatureDate': signature_date,
|
|
604
|
+
},
|
|
605
|
+
timeout=15
|
|
606
|
+
)
|
|
607
|
+
response.raise_for_status()
|
|
608
|
+
return response.json()
|
|
609
|
+
except requests.RequestException as e:
|
|
610
|
+
logger.error('The Pay refund info request failed: %s', e, exc_info=True)
|
|
611
|
+
if hasattr(e, 'response') and e.response is not None:
|
|
612
|
+
logger.error('The Pay refund info error response: %s', e.response.text[:500])
|
|
613
|
+
return None
|
|
614
|
+
|
|
615
|
+
def _request_refund(self, payment_uid: str, merchant_id: str, project_id: int,
|
|
616
|
+
api_password: str, api_url: str, amount: int, reason: str) -> None:
|
|
617
|
+
if 'demo.api.thepay.cz' in api_url:
|
|
618
|
+
endpoint_url = f'https://demo.api.thepay.cz/v1/projects/{project_id}/payments/{payment_uid}/refund'
|
|
619
|
+
elif 'api.thepay.cz' in api_url or 'thepay.cz' in api_url:
|
|
620
|
+
endpoint_url = f'https://api.thepay.cz/v1/projects/{project_id}/payments/{payment_uid}/refund'
|
|
621
|
+
else:
|
|
622
|
+
endpoint_url = f'{api_url}/v1/projects/{project_id}/payments/{payment_uid}/refund'
|
|
623
|
+
|
|
624
|
+
signature_date = self._get_signature_date()
|
|
625
|
+
signature = self._calculate_signature(merchant_id, api_password, signature_date)
|
|
626
|
+
endpoint_url_with_params = f'{endpoint_url}?merchant_id={merchant_id}'
|
|
627
|
+
|
|
628
|
+
response = requests.post(
|
|
629
|
+
endpoint_url_with_params,
|
|
630
|
+
json={
|
|
631
|
+
'amount': amount,
|
|
632
|
+
'reason': reason,
|
|
633
|
+
},
|
|
634
|
+
headers={
|
|
635
|
+
'Content-Type': 'application/json',
|
|
636
|
+
'Signature': signature,
|
|
637
|
+
'SignatureDate': signature_date,
|
|
638
|
+
},
|
|
639
|
+
timeout=30
|
|
640
|
+
)
|
|
641
|
+
response.raise_for_status()
|
|
642
|
+
|
|
643
|
+
def _store_refund_info(self, refund: OrderRefund, data: Dict[str, Any]) -> None:
|
|
644
|
+
info = getattr(refund, 'info_data', None)
|
|
645
|
+
if info is None:
|
|
646
|
+
info = getattr(refund, 'info', None) or {}
|
|
647
|
+
info['thepay_refund_info'] = data
|
|
648
|
+
if hasattr(refund, 'info_data'):
|
|
649
|
+
refund.info_data = info
|
|
650
|
+
else:
|
|
651
|
+
refund.info = info
|
|
652
|
+
refund.save(update_fields=['info'])
|
|
653
|
+
|
|
654
|
+
def _get_refund_state(self, info: Dict[str, Any], amount: int) -> Optional[str]:
|
|
655
|
+
partials = info.get('partial_refunds') or []
|
|
656
|
+
for item in partials:
|
|
657
|
+
if int(item.get('amount', 0)) == amount:
|
|
658
|
+
return item.get('state')
|
|
659
|
+
return None
|
|
660
|
+
|
|
661
|
+
def _is_refund_visible(self, before: Dict[str, Any], after: Dict[str, Any],
|
|
662
|
+
amount: int) -> bool:
|
|
663
|
+
try:
|
|
664
|
+
if not after:
|
|
665
|
+
return False
|
|
666
|
+
before_amount = int(before.get('available_amount', 0))
|
|
667
|
+
after_amount = int(after.get('available_amount', 0))
|
|
668
|
+
if before_amount - after_amount >= amount:
|
|
669
|
+
return True
|
|
670
|
+
|
|
671
|
+
partials = after.get('partial_refunds') or []
|
|
672
|
+
for item in partials:
|
|
673
|
+
if int(item.get('amount', 0)) == amount:
|
|
674
|
+
return True
|
|
675
|
+
except Exception:
|
|
676
|
+
return False
|
|
677
|
+
return False
|
|
678
|
+
|
|
679
|
+
def _verify_signature(self, params: Dict[str, str], signature: str, api_password: str) -> bool:
|
|
680
|
+
"""
|
|
681
|
+
Verify a signature from The Pay using HMAC-SHA256.
|
|
682
|
+
|
|
683
|
+
Args:
|
|
684
|
+
params: Request parameters (excluding signature)
|
|
685
|
+
signature: Base64-encoded signature to verify
|
|
686
|
+
api_password: API password for verification
|
|
687
|
+
|
|
688
|
+
Returns:
|
|
689
|
+
True if signature is valid, False otherwise
|
|
690
|
+
"""
|
|
691
|
+
try:
|
|
692
|
+
# Sort parameters by key and build query string
|
|
693
|
+
sorted_params = sorted(params.items())
|
|
694
|
+
query_string = '&'.join(f'{k}={v}' for k, v in sorted_params if v)
|
|
695
|
+
|
|
696
|
+
# Generate expected signature
|
|
697
|
+
expected_signature = hmac.new(
|
|
698
|
+
api_password.encode('utf-8'),
|
|
699
|
+
query_string.encode('utf-8'),
|
|
700
|
+
hashlib.sha256
|
|
701
|
+
).digest()
|
|
702
|
+
|
|
703
|
+
# Decode provided signature
|
|
704
|
+
provided_signature = base64.b64decode(signature)
|
|
705
|
+
|
|
706
|
+
# Compare signatures using constant-time comparison
|
|
707
|
+
return hmac.compare_digest(expected_signature, provided_signature)
|
|
708
|
+
except Exception as e:
|
|
709
|
+
logger.error(f'Error verifying signature: {e}', exc_info=True)
|
|
710
|
+
return False
|
|
711
|
+
|
|
712
|
+
def payment_pending_render(self, request, payment: OrderPayment):
|
|
713
|
+
"""
|
|
714
|
+
Render HTML for pending payment status.
|
|
715
|
+
|
|
716
|
+
Args:
|
|
717
|
+
request: HTTP request object
|
|
718
|
+
payment: OrderPayment instance
|
|
719
|
+
|
|
720
|
+
Returns:
|
|
721
|
+
HTML string to display while payment is pending
|
|
722
|
+
"""
|
|
723
|
+
info = self._get_payment_info(payment)
|
|
724
|
+
pay_url = info.get('pay_url')
|
|
725
|
+
if pay_url:
|
|
726
|
+
return format_html(
|
|
727
|
+
'<p>{}</p><p>{}</p><p><a class="btn btn-primary" href="{}">{}</a></p>',
|
|
728
|
+
_('Your payment is being processed.'),
|
|
729
|
+
_('If you paid by bank transfer, it can take a while. If card payment failed, you can retry below.'),
|
|
730
|
+
pay_url,
|
|
731
|
+
_('Check payment status'),
|
|
732
|
+
)
|
|
733
|
+
return format_html('<p>{} {}</p>', _('Your payment is being processed.'), _('Please wait...'))
|
|
734
|
+
|
|
735
|
+
def payment_control_render(self, request, payment: OrderPayment):
|
|
736
|
+
"""
|
|
737
|
+
Render payment information in the control panel.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
request: HTTP request object
|
|
741
|
+
payment: OrderPayment instance
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
HTML string displaying payment details
|
|
745
|
+
"""
|
|
746
|
+
return f"""
|
|
747
|
+
<dl class="dl-horizontal">
|
|
748
|
+
<dt>{_('Payment ID')}</dt>
|
|
749
|
+
<dd>{payment.id}</dd>
|
|
750
|
+
<dt>{_('Status')}</dt>
|
|
751
|
+
<dd>{payment.state}</dd>
|
|
752
|
+
</dl>
|
|
753
|
+
"""
|
|
754
|
+
|
|
755
|
+
def payment_control_render_short(self, payment: OrderPayment) -> str:
|
|
756
|
+
info = self._get_payment_info(payment)
|
|
757
|
+
payment_uid = info.get('payment_uid')
|
|
758
|
+
if payment_uid:
|
|
759
|
+
return f"{_('The Pay')} ({payment_uid[-6:]})"
|
|
760
|
+
return _('The Pay')
|
|
761
|
+
|
|
762
|
+
def payment_presale_render(self, payment: OrderPayment) -> str:
|
|
763
|
+
return self.payment_control_render_short(payment)
|