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.
@@ -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)