paytechuz 0.1.0__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.

Potentially problematic release.


This version of paytechuz might be problematic. Click here for more details.

@@ -0,0 +1,1028 @@
1
+ """
2
+ FastAPI routes for PayTechUZ.
3
+ """
4
+ import base64
5
+ import hashlib
6
+ import json
7
+ import logging
8
+ from datetime import datetime
9
+ from decimal import Decimal
10
+ from typing import Dict, Any, Optional, Callable
11
+
12
+ from fastapi import (
13
+ APIRouter,
14
+ Depends,
15
+ HTTPException,
16
+ Request,
17
+ Response,
18
+ status
19
+ )
20
+ from sqlalchemy.orm import Session
21
+
22
+ from paytechuz.core.exceptions import (
23
+ PermissionDenied,
24
+ InvalidAmount,
25
+ TransactionNotFound,
26
+ AccountNotFound,
27
+ MethodNotFound,
28
+ UnsupportedMethod,
29
+ InvalidAccount
30
+ )
31
+
32
+ from .models import PaymentTransaction
33
+
34
+ logger = logging.getLogger(__name__)
35
+
36
+ router = APIRouter()
37
+
38
+
39
+ class PaymeWebhookHandler:
40
+ """
41
+ Base Payme webhook handler for FastAPI.
42
+
43
+ This class handles webhook requests from the Payme payment system.
44
+ You can extend this class and override the event methods to customize
45
+ the behavior.
46
+
47
+ Example:
48
+ ```python
49
+ from paytechuz.integrations.fastapi.routes import PaymeWebhookHandler
50
+
51
+ class CustomPaymeWebhookHandler(PaymeWebhookHandler):
52
+ def successfully_payment(self, params, transaction):
53
+ # Your custom logic here
54
+ print(f"Payment successful: {transaction.transaction_id}")
55
+
56
+ # Update your order status
57
+ order = db.query(Order).filter(
58
+ Order.id == transaction.account_id
59
+ ).first()
60
+ order.status = 'paid'
61
+ db.commit()
62
+ ```
63
+ """
64
+
65
+ def __init__(
66
+ self,
67
+ db: Session,
68
+ payme_id: str,
69
+ payme_key: str,
70
+ account_model: Any,
71
+ account_field: str = 'id',
72
+ amount_field: str = 'amount',
73
+ one_time_payment: bool = True
74
+ ):
75
+ """
76
+ Initialize the Payme webhook handler.
77
+
78
+ Args:
79
+ db: Database session
80
+ payme_id: Payme merchant ID
81
+ payme_key: Payme merchant key
82
+ account_model: Account model class
83
+ account_field: Account field name
84
+ amount_field: Amount field name
85
+ one_time_payment: Whether to validate amount
86
+ """
87
+ self.db = db
88
+ self.payme_id = payme_id
89
+ self.payme_key = payme_key
90
+ self.account_model = account_model
91
+ self.account_field = account_field
92
+ self.amount_field = amount_field
93
+ self.one_time_payment = one_time_payment
94
+
95
+ async def handle_webhook(self, request: Request) -> Response:
96
+ """
97
+ Handle webhook request from Payme.
98
+
99
+ Args:
100
+ request: FastAPI request object
101
+
102
+ Returns:
103
+ Response object with JSON data
104
+ """
105
+ try:
106
+ # Check authorization
107
+ auth_header = request.headers.get('Authorization')
108
+ self._check_auth(auth_header)
109
+
110
+ # Parse request data
111
+ data = await request.json()
112
+ method = data.get('method')
113
+ params = data.get('params', {})
114
+ request_id = data.get('id', 0)
115
+
116
+ # Process the request based on the method
117
+ if method == 'CheckPerformTransaction':
118
+ result = self._check_perform_transaction(params)
119
+ elif method == 'CreateTransaction':
120
+ result = self._create_transaction(params)
121
+ elif method == 'PerformTransaction':
122
+ result = self._perform_transaction(params)
123
+ elif method == 'CheckTransaction':
124
+ result = self._check_transaction(params)
125
+ elif method == 'CancelTransaction':
126
+ result = self._cancel_transaction(params)
127
+ elif method == 'GetStatement':
128
+ result = self._get_statement(params)
129
+ else:
130
+ return Response(
131
+ content=json.dumps({
132
+ 'jsonrpc': '2.0',
133
+ 'id': request_id,
134
+ 'error': {
135
+ 'code': -32601,
136
+ 'message': f"Method not supported: {method}"
137
+ }
138
+ }),
139
+ media_type="application/json",
140
+ status_code=200 # Always return 200 for RPC-style errors
141
+ )
142
+
143
+ # Return the result
144
+ return Response(
145
+ content=json.dumps({
146
+ 'jsonrpc': '2.0',
147
+ 'id': request_id,
148
+ 'result': result
149
+ }),
150
+ media_type="application/json",
151
+ status_code=200
152
+ )
153
+
154
+ except PermissionDenied:
155
+ return Response(
156
+ content=json.dumps({
157
+ 'jsonrpc': '2.0',
158
+ 'id': request_id if 'request_id' in locals() else 0,
159
+ 'error': {
160
+ 'code': -32504,
161
+ 'message': "permission denied"
162
+ }
163
+ }),
164
+ media_type="application/json",
165
+ status_code=200 # Return 200 status code for all errors
166
+ )
167
+
168
+ except (MethodNotFound, UnsupportedMethod) as e:
169
+ return Response(
170
+ content=json.dumps({
171
+ 'jsonrpc': '2.0',
172
+ 'id': request_id if 'request_id' in locals() else 0,
173
+ 'error': {
174
+ 'code': -32601,
175
+ 'message': str(e)
176
+ }
177
+ }),
178
+ media_type="application/json",
179
+ status_code=200 # Return 200 status code for all errors
180
+ )
181
+
182
+ except AccountNotFound as e:
183
+ return Response(
184
+ content=json.dumps({
185
+ 'jsonrpc': '2.0',
186
+ 'id': request_id if 'request_id' in locals() else 0,
187
+ 'error': {
188
+ # Code for account not found, in the range -31099 to -31050
189
+ 'code': -31050,
190
+ 'message': str(e)
191
+ }
192
+ }),
193
+ media_type="application/json",
194
+ status_code=200 # Return 200 status code for all errors
195
+ )
196
+
197
+ except InvalidAmount as e:
198
+ return Response(
199
+ content=json.dumps({
200
+ 'jsonrpc': '2.0',
201
+ 'id': request_id if 'request_id' in locals() else 0,
202
+ 'error': {
203
+ 'code': -31001, # Code for invalid amount
204
+ 'message': str(e)
205
+ }
206
+ }),
207
+ media_type="application/json",
208
+ status_code=200 # Return 200 status code for all errors
209
+ )
210
+
211
+ except InvalidAccount as e:
212
+ return Response(
213
+ content=json.dumps({
214
+ 'jsonrpc': '2.0',
215
+ 'id': request_id if 'request_id' in locals() else 0,
216
+ 'error': {
217
+ # Code for invalid account, in the range -31099 to -31050
218
+ 'code': -31050,
219
+ 'message': str(e)
220
+ }
221
+ }),
222
+ media_type="application/json",
223
+ status_code=200 # Return 200 status code for all errors
224
+ )
225
+
226
+ except TransactionNotFound as e:
227
+ return Response(
228
+ content=json.dumps({
229
+ 'jsonrpc': '2.0',
230
+ 'id': request_id if 'request_id' in locals() else 0,
231
+ 'error': {
232
+ 'code': -31001,
233
+ 'message': str(e)
234
+ }
235
+ }),
236
+ media_type="application/json",
237
+ status_code=200 # Return 200 status code for all errors
238
+ )
239
+
240
+ except Exception as e:
241
+ logger.exception(f"Unexpected error in Payme webhook: {e}")
242
+ return Response(
243
+ content=json.dumps({
244
+ 'jsonrpc': '2.0',
245
+ 'id': request_id if 'request_id' in locals() else 0,
246
+ 'error': {
247
+ 'code': -32400,
248
+ 'message': 'Internal error'
249
+ }
250
+ }),
251
+ media_type="application/json",
252
+ status_code=200 # Return 200 status code for all errors
253
+ )
254
+
255
+ def _check_auth(self, auth_header: Optional[str]) -> None:
256
+ """
257
+ Check authorization header.
258
+ """
259
+ if not auth_header:
260
+ raise PermissionDenied("Missing authentication credentials")
261
+
262
+ try:
263
+ auth_parts = auth_header.split()
264
+ if len(auth_parts) != 2 or auth_parts[0].lower() != 'basic':
265
+ raise PermissionDenied("Invalid authentication format")
266
+
267
+ auth_decoded = base64.b64decode(auth_parts[1]).decode('utf-8')
268
+ _, password = auth_decoded.split(':') # We only need the password
269
+
270
+ if password != self.payme_key:
271
+ raise PermissionDenied("Invalid merchant key")
272
+ except PermissionDenied:
273
+ # Re-raise permission denied exceptions
274
+ raise
275
+ except Exception as e:
276
+ logger.error(f"Authentication error: {e}")
277
+ raise PermissionDenied("Authentication error")
278
+
279
+ def _find_account(self, params: Dict[str, Any]) -> Any:
280
+ """
281
+ Find account by parameters.
282
+ """
283
+ account_value = params.get('account', {}).get(self.account_field)
284
+ if not account_value:
285
+ raise AccountNotFound("Account not found in parameters")
286
+
287
+ # Handle special case for 'order_id' field
288
+ lookup_field = 'id' if self.account_field == 'order_id' else (
289
+ self.account_field
290
+ )
291
+
292
+ # Try to convert account_value to int if it's a string and lookup_field is 'id'
293
+ if (lookup_field == 'id' and isinstance(account_value, str) and
294
+ account_value.isdigit()):
295
+ account_value = int(account_value)
296
+
297
+ account = self.db.query(self.account_model).filter_by(
298
+ **{lookup_field: account_value}
299
+ ).first()
300
+ if not account:
301
+ raise AccountNotFound(
302
+ f"Account with {self.account_field}={account_value} not found"
303
+ )
304
+
305
+ return account
306
+
307
+ def _validate_amount(self, account: Any, amount: int) -> bool:
308
+ """
309
+ Validate payment amount.
310
+ """
311
+ # If one_time_payment is disabled, we still validate the amount
312
+ # but we don't require it to match exactly
313
+
314
+ expected_amount = Decimal(getattr(account, self.amount_field)) * 100
315
+ received_amount = Decimal(amount)
316
+
317
+ # If one_time_payment is enabled, amount must match exactly
318
+ if self.one_time_payment and expected_amount != received_amount:
319
+ raise InvalidAmount(
320
+ (f"Invalid amount. Expected: {expected_amount}, "
321
+ f"received: {received_amount}")
322
+ )
323
+
324
+ # If one_time_payment is disabled, amount must be positive
325
+ if not self.one_time_payment and received_amount <= 0:
326
+ raise InvalidAmount(
327
+ f"Invalid amount. Amount must be positive, received: {received_amount}"
328
+ )
329
+
330
+ return True
331
+
332
+ def _check_perform_transaction(self, params: Dict[str, Any]) -> Dict[str, Any]:
333
+ """
334
+ Handle CheckPerformTransaction method.
335
+ """
336
+ account = self._find_account(params)
337
+ self._validate_amount(account, params.get('amount'))
338
+
339
+ # Call the event method
340
+ self.before_check_perform_transaction(params, account)
341
+
342
+ return {'allow': True}
343
+
344
+ def _create_transaction(self, params: Dict[str, Any]) -> Dict[str, Any]:
345
+ """
346
+ Handle CreateTransaction method.
347
+ """
348
+ transaction_id = params.get('id')
349
+ account = self._find_account(params)
350
+ amount = params.get('amount')
351
+
352
+ self._validate_amount(account, amount)
353
+
354
+ # Check if there's already a transaction for this account with a different transaction_id
355
+ # Only check if one_time_payment is enabled
356
+ if self.one_time_payment:
357
+ # Check for existing transactions in non-final states
358
+ existing_transactions = self.db.query(PaymentTransaction).filter(
359
+ PaymentTransaction.gateway == PaymentTransaction.PAYME,
360
+ PaymentTransaction.account_id == account.id
361
+ ).filter(
362
+ PaymentTransaction.transaction_id != transaction_id
363
+ ).all()
364
+
365
+ # Filter out transactions in final states (SUCCESSFULLY, CANCELLED)
366
+ non_final_transactions = [
367
+ t for t in existing_transactions
368
+ if t.state not in [PaymentTransaction.SUCCESSFULLY, PaymentTransaction.CANCELLED]
369
+ ]
370
+
371
+ if non_final_transactions:
372
+ # If there's already a transaction for this account with a different transaction_id in a non-final state, raise an error
373
+ raise InvalidAccount(
374
+ f"Account with {self.account_field}={account.id} already has a pending transaction"
375
+ )
376
+
377
+ # Check for existing transaction with the same transaction_id
378
+ transaction = self.db.query(PaymentTransaction).filter(
379
+ PaymentTransaction.gateway == PaymentTransaction.PAYME,
380
+ PaymentTransaction.transaction_id == transaction_id
381
+ ).first()
382
+
383
+ if transaction:
384
+ # Call the event method
385
+ self.transaction_already_exists(params, transaction)
386
+
387
+ # For existing transactions, use the original time from extra_data
388
+ # This ensures the same response is returned for repeated calls
389
+ create_time = transaction.extra_data.get('create_time', params.get('time'))
390
+
391
+ return {
392
+ 'transaction': transaction.transaction_id,
393
+ 'state': transaction.state,
394
+ 'create_time': create_time,
395
+ }
396
+
397
+ # Create new transaction
398
+ transaction = PaymentTransaction(
399
+ gateway=PaymentTransaction.PAYME,
400
+ transaction_id=transaction_id,
401
+ account_id=account.id,
402
+ amount=Decimal(amount) / 100, # Convert from tiyin to som
403
+ state=PaymentTransaction.INITIATING,
404
+ extra_data={
405
+ 'account_field': self.account_field,
406
+ 'account_value': params.get('account', {}).get(self.account_field),
407
+ 'create_time': params.get('time'),
408
+ 'raw_params': params
409
+ }
410
+ )
411
+
412
+ self.db.add(transaction)
413
+ self.db.commit()
414
+ self.db.refresh(transaction)
415
+
416
+ # Call the event method
417
+ self.transaction_created(params, transaction, account)
418
+
419
+ # Use the time from the request params instead of transaction.created_at
420
+ create_time = params.get('time')
421
+
422
+ return {
423
+ 'transaction': transaction.transaction_id,
424
+ 'state': transaction.state,
425
+ 'create_time': create_time,
426
+ }
427
+
428
+ def _perform_transaction(self, params: Dict[str, Any]) -> Dict[str, Any]:
429
+ """
430
+ Handle PerformTransaction method.
431
+ """
432
+ transaction_id = params.get('id')
433
+
434
+ transaction = self.db.query(PaymentTransaction).filter(
435
+ PaymentTransaction.gateway == PaymentTransaction.PAYME,
436
+ PaymentTransaction.transaction_id == transaction_id
437
+ ).first()
438
+
439
+ if not transaction:
440
+ raise HTTPException(
441
+ status_code=status.HTTP_404_NOT_FOUND,
442
+ detail=f"Transaction {transaction_id} not found"
443
+ )
444
+
445
+ # Mark transaction as paid
446
+ transaction.mark_as_paid(self.db)
447
+
448
+ # Call the event method
449
+ self.successfully_payment(params, transaction)
450
+
451
+ return {
452
+ 'transaction': transaction.transaction_id,
453
+ 'state': transaction.state,
454
+ 'perform_time': int(transaction.performed_at.timestamp() * 1000) if transaction.performed_at else 0,
455
+ }
456
+
457
+ def _check_transaction(self, params: Dict[str, Any]) -> Dict[str, Any]:
458
+ """
459
+ Handle CheckTransaction method.
460
+ """
461
+ transaction_id = params.get('id')
462
+
463
+ transaction = self.db.query(PaymentTransaction).filter(
464
+ PaymentTransaction.gateway == PaymentTransaction.PAYME,
465
+ PaymentTransaction.transaction_id == transaction_id
466
+ ).first()
467
+
468
+ if not transaction:
469
+ raise HTTPException(
470
+ status_code=status.HTTP_404_NOT_FOUND,
471
+ detail=f"Transaction {transaction_id} not found"
472
+ )
473
+
474
+ # Call the event method
475
+ self.check_transaction(params, transaction)
476
+
477
+ # Use the original time from extra_data for consistency
478
+ create_time = transaction.extra_data.get('create_time', int(transaction.created_at.timestamp() * 1000))
479
+
480
+ return {
481
+ 'transaction': transaction.transaction_id,
482
+ 'state': transaction.state,
483
+ 'create_time': create_time,
484
+ 'perform_time': int(transaction.performed_at.timestamp() * 1000) if transaction.performed_at else 0,
485
+ 'cancel_time': int(transaction.cancelled_at.timestamp() * 1000) if transaction.cancelled_at else 0,
486
+ 'reason': transaction.reason,
487
+ }
488
+
489
+ def _cancel_response(self, transaction: PaymentTransaction) -> Dict[str, Any]:
490
+ """
491
+ Helper method to generate cancel transaction response.
492
+
493
+ Args:
494
+ transaction: Transaction object
495
+
496
+ Returns:
497
+ Dict containing the response
498
+ """
499
+ # Get reason from the transaction model
500
+ reason = transaction.reason
501
+
502
+ # If reason is None, use default reason
503
+ if reason is None:
504
+ from paytechuz.core.constants import PaymeCancelReason
505
+ reason = PaymeCancelReason.REASON_FUND_RETURNED # Default reason 5
506
+ # Update the transaction with the default reason
507
+ transaction.reason = reason
508
+ self.db.commit()
509
+ self.db.refresh(transaction)
510
+
511
+ return {
512
+ 'transaction': transaction.transaction_id,
513
+ 'state': transaction.state,
514
+ 'cancel_time': int(transaction.cancelled_at.timestamp() * 1000) if transaction.cancelled_at else 0,
515
+ 'reason': reason,
516
+ }
517
+
518
+ def _cancel_transaction(self, params: Dict[str, Any]) -> Dict[str, Any]:
519
+ """
520
+ Handle CancelTransaction method.
521
+ """
522
+ transaction_id = params.get('id')
523
+ reason = params.get('reason')
524
+
525
+ transaction = self.db.query(PaymentTransaction).filter(
526
+ PaymentTransaction.gateway == PaymentTransaction.PAYME,
527
+ PaymentTransaction.transaction_id == transaction_id
528
+ ).first()
529
+
530
+ if not transaction:
531
+ raise HTTPException(
532
+ status_code=status.HTTP_404_NOT_FOUND,
533
+ detail=f"Transaction {transaction_id} not found"
534
+ )
535
+
536
+ # Check if transaction is already cancelled
537
+ if transaction.state == PaymentTransaction.CANCELLED:
538
+ # If transaction is already cancelled, update the reason if provided
539
+ if 'reason' in params:
540
+ reason = params.get('reason')
541
+
542
+ # If reason is not provided, use default reason from PaymeCancelReason
543
+ if reason is None:
544
+ from paytechuz.core.constants import PaymeCancelReason
545
+ reason = PaymeCancelReason.REASON_FUND_RETURNED # Default reason 5
546
+
547
+ # Convert reason to int if it's a string
548
+ if isinstance(reason, str) and reason.isdigit():
549
+ reason = int(reason)
550
+
551
+ # Store the reason directly in the reason column
552
+ transaction.reason = reason
553
+
554
+ # For backward compatibility, also store in extra_data
555
+ extra_data = transaction.extra_data or {}
556
+ extra_data['cancel_reason'] = reason
557
+ transaction.extra_data = extra_data
558
+
559
+ self.db.commit()
560
+ self.db.refresh(transaction)
561
+
562
+ # Return the existing data
563
+ return self._cancel_response(transaction)
564
+
565
+ # Use mark_as_cancelled method to ensure consistent behavior
566
+ reason = params.get('reason')
567
+ transaction.mark_as_cancelled(self.db, reason=reason)
568
+
569
+ # Ensure the reason is stored in extra_data
570
+ extra_data = transaction.extra_data or {}
571
+ if 'cancel_reason' not in extra_data:
572
+ extra_data['cancel_reason'] = reason if reason is not None else 5 # Default reason 5
573
+ transaction.extra_data = extra_data
574
+ self.db.commit()
575
+ self.db.refresh(transaction)
576
+
577
+ # Call the event method
578
+ self.cancelled_payment(params, transaction)
579
+
580
+ # Return cancel response
581
+ return self._cancel_response(transaction)
582
+
583
+ def _get_statement(self, params: Dict[str, Any]) -> Dict[str, Any]:
584
+ """
585
+ Handle GetStatement method.
586
+ """
587
+ from_date = params.get('from')
588
+ to_date = params.get('to')
589
+
590
+ # Convert milliseconds to datetime objects
591
+ if from_date:
592
+ from_datetime = datetime.fromtimestamp(from_date / 1000)
593
+ else:
594
+ from_datetime = datetime.fromtimestamp(0) # Unix epoch start
595
+
596
+ if to_date:
597
+ to_datetime = datetime.fromtimestamp(to_date / 1000)
598
+ else:
599
+ to_datetime = datetime.now() # Current time
600
+
601
+ # Get transactions in the date range
602
+ transactions = self.db.query(PaymentTransaction).filter(
603
+ PaymentTransaction.gateway == PaymentTransaction.PAYME,
604
+ PaymentTransaction.created_at >= from_datetime,
605
+ PaymentTransaction.created_at <= to_datetime
606
+ ).all()
607
+
608
+ # Format transactions for response
609
+ result = []
610
+ for transaction in transactions:
611
+ result.append({
612
+ 'id': transaction.transaction_id,
613
+ 'time': int(transaction.created_at.timestamp() * 1000),
614
+ 'amount': int(transaction.amount * 100), # Convert to tiyin
615
+ 'account': {
616
+ self.account_field: transaction.account_id
617
+ },
618
+ 'state': transaction.state,
619
+ 'create_time': transaction.extra_data.get('create_time', int(transaction.created_at.timestamp() * 1000)),
620
+ 'perform_time': int(transaction.performed_at.timestamp() * 1000) if transaction.performed_at else 0,
621
+ 'cancel_time': int(transaction.cancelled_at.timestamp() * 1000) if transaction.cancelled_at else 0,
622
+ 'reason': transaction.reason,
623
+ })
624
+
625
+ # Call the event method
626
+ self.get_statement(params, result)
627
+
628
+ return {'transactions': result}
629
+
630
+ # Event methods that can be overridden by subclasses
631
+
632
+ def before_check_perform_transaction(self, params: Dict[str, Any], account: Any) -> None:
633
+ """
634
+ Called before checking if a transaction can be performed.
635
+
636
+ Args:
637
+ params: Request parameters
638
+ account: Account object
639
+ """
640
+ pass
641
+
642
+ def transaction_already_exists(self, params: Dict[str, Any], transaction: PaymentTransaction) -> None:
643
+ """
644
+ Called when a transaction already exists.
645
+
646
+ Args:
647
+ params: Request parameters
648
+ transaction: Transaction object
649
+ """
650
+ pass
651
+
652
+ def transaction_created(self, params: Dict[str, Any], transaction: PaymentTransaction, account: Any) -> None:
653
+ """
654
+ Called when a transaction is created.
655
+
656
+ Args:
657
+ params: Request parameters
658
+ transaction: Transaction object
659
+ account: Account object
660
+ """
661
+ pass
662
+
663
+ def successfully_payment(self, params: Dict[str, Any], transaction: PaymentTransaction) -> None:
664
+ """
665
+ Called when a payment is successful.
666
+
667
+ Args:
668
+ params: Request parameters
669
+ transaction: Transaction object
670
+ """
671
+ pass
672
+
673
+ def check_transaction(self, params: Dict[str, Any], transaction: PaymentTransaction) -> None:
674
+ """
675
+ Called when checking a transaction.
676
+
677
+ Args:
678
+ params: Request parameters
679
+ transaction: Transaction object
680
+ """
681
+ pass
682
+
683
+ def cancelled_payment(self, params: Dict[str, Any], transaction: PaymentTransaction) -> None:
684
+ """
685
+ Called when a payment is cancelled.
686
+
687
+ Args:
688
+ params: Request parameters
689
+ transaction: Transaction object
690
+ """
691
+ pass
692
+
693
+ def get_statement(self, params: Dict[str, Any], transactions: list) -> None:
694
+ """
695
+ Called when getting a statement.
696
+
697
+ Args:
698
+ params: Request parameters
699
+ transactions: List of transactions
700
+ """
701
+ pass
702
+
703
+
704
+ class ClickWebhookHandler:
705
+ """
706
+ Base Click webhook handler for FastAPI.
707
+
708
+ This class handles webhook requests from the Click payment system.
709
+ You can extend this class and override the event methods to customize
710
+ the behavior.
711
+
712
+ Example:
713
+ ```python
714
+ from paytechuz.integrations.fastapi.routes import ClickWebhookHandler
715
+
716
+ class CustomClickWebhookHandler(ClickWebhookHandler):
717
+ def successfully_payment(self, params, transaction):
718
+ # Your custom logic here
719
+ print(f"Payment successful: {transaction.transaction_id}")
720
+
721
+ # Update your order status
722
+ order = db.query(Order).filter(Order.id == transaction.account_id).first()
723
+ order.status = 'paid'
724
+ db.commit()
725
+ ```
726
+ """
727
+
728
+ def __init__(
729
+ self,
730
+ db: Session,
731
+ service_id: str,
732
+ secret_key: str,
733
+ account_model: Any,
734
+ commission_percent: float = 0.0
735
+ ):
736
+ """
737
+ Initialize the Click webhook handler.
738
+
739
+ Args:
740
+ db: Database session
741
+ service_id: Click service ID
742
+ secret_key: Click secret key
743
+ account_model: Account model class
744
+ commission_percent: Commission percentage
745
+ """
746
+ self.db = db
747
+ self.service_id = service_id
748
+ self.secret_key = secret_key
749
+ self.account_model = account_model
750
+ self.commission_percent = commission_percent
751
+
752
+ async def handle_webhook(self, request: Request) -> Dict[str, Any]:
753
+ """
754
+ Handle webhook request from Click.
755
+
756
+ Args:
757
+ request: FastAPI request object
758
+
759
+ Returns:
760
+ Response data
761
+ """
762
+ try:
763
+ # Get parameters from request
764
+ form_data = await request.form()
765
+ params = {key: form_data.get(key) for key in form_data}
766
+
767
+ # Check authorization
768
+ self._check_auth(params)
769
+
770
+ # Extract parameters
771
+ click_trans_id = params.get('click_trans_id')
772
+ merchant_trans_id = params.get('merchant_trans_id')
773
+ amount = float(params.get('amount', 0))
774
+ action = int(params.get('action', -1))
775
+ error = int(params.get('error', 0))
776
+
777
+ # Find account
778
+ try:
779
+ account = self._find_account(merchant_trans_id)
780
+ except Exception:
781
+ logger.error(f"Account not found: {merchant_trans_id}")
782
+ return {
783
+ 'click_trans_id': click_trans_id,
784
+ 'merchant_trans_id': merchant_trans_id,
785
+ 'error': -5,
786
+ 'error_note': "User not found"
787
+ }
788
+
789
+ # Validate amount
790
+ try:
791
+ self._validate_amount(amount, float(getattr(account, 'amount', 0)))
792
+ except Exception as e:
793
+ logger.error(f"Invalid amount: {e}")
794
+ return {
795
+ 'click_trans_id': click_trans_id,
796
+ 'merchant_trans_id': merchant_trans_id,
797
+ 'error': -2,
798
+ 'error_note': str(e)
799
+ }
800
+
801
+ # Check if transaction already exists
802
+ transaction = self.db.query(PaymentTransaction).filter(
803
+ PaymentTransaction.gateway == PaymentTransaction.CLICK,
804
+ PaymentTransaction.transaction_id == click_trans_id
805
+ ).first()
806
+
807
+ if transaction:
808
+ # If transaction is already completed, return success
809
+ if transaction.state == PaymentTransaction.SUCCESSFULLY:
810
+ # Call the event method
811
+ self.transaction_already_exists(params, transaction)
812
+
813
+ return {
814
+ 'click_trans_id': click_trans_id,
815
+ 'merchant_trans_id': merchant_trans_id,
816
+ 'merchant_prepare_id': transaction.id,
817
+ 'error': 0,
818
+ 'error_note': "Success"
819
+ }
820
+
821
+ # If transaction is cancelled, return error
822
+ if transaction.state == PaymentTransaction.CANCELLED:
823
+ return {
824
+ 'click_trans_id': click_trans_id,
825
+ 'merchant_trans_id': merchant_trans_id,
826
+ 'merchant_prepare_id': transaction.id,
827
+ 'error': -9,
828
+ 'error_note': "Transaction cancelled"
829
+ }
830
+
831
+ # Handle different actions
832
+ if action == 0: # Prepare
833
+ # Create transaction
834
+ transaction = PaymentTransaction(
835
+ gateway=PaymentTransaction.CLICK,
836
+ transaction_id=click_trans_id,
837
+ account_id=merchant_trans_id,
838
+ amount=amount,
839
+ state=PaymentTransaction.INITIATING,
840
+ extra_data={
841
+ 'raw_params': params
842
+ }
843
+ )
844
+
845
+ self.db.add(transaction)
846
+ self.db.commit()
847
+ self.db.refresh(transaction)
848
+
849
+ # Call the event method
850
+ self.transaction_created(params, transaction, account)
851
+
852
+ return {
853
+ 'click_trans_id': click_trans_id,
854
+ 'merchant_trans_id': merchant_trans_id,
855
+ 'merchant_prepare_id': transaction.id,
856
+ 'error': 0,
857
+ 'error_note': "Success"
858
+ }
859
+
860
+ elif action == 1: # Complete
861
+ # Check if error is negative (payment failed)
862
+ is_successful = error >= 0
863
+
864
+ if not transaction:
865
+ # Create transaction if it doesn't exist
866
+ transaction = PaymentTransaction(
867
+ gateway=PaymentTransaction.CLICK,
868
+ transaction_id=click_trans_id,
869
+ account_id=merchant_trans_id,
870
+ amount=amount,
871
+ state=PaymentTransaction.INITIATING,
872
+ extra_data={
873
+ 'raw_params': params
874
+ }
875
+ )
876
+
877
+ self.db.add(transaction)
878
+ self.db.commit()
879
+ self.db.refresh(transaction)
880
+
881
+ if is_successful:
882
+ # Mark transaction as paid
883
+ transaction.mark_as_paid(self.db)
884
+
885
+ # Call the event method
886
+ self.successfully_payment(params, transaction)
887
+ else:
888
+ # Mark transaction as cancelled
889
+ transaction.mark_as_cancelled(self.db, reason=f"Error code: {error}")
890
+
891
+ # Call the event method
892
+ self.cancelled_payment(params, transaction)
893
+
894
+ return {
895
+ 'click_trans_id': click_trans_id,
896
+ 'merchant_trans_id': merchant_trans_id,
897
+ 'merchant_prepare_id': transaction.id,
898
+ 'error': 0,
899
+ 'error_note': "Success"
900
+ }
901
+
902
+ else:
903
+ logger.error(f"Unsupported action: {action}")
904
+ return {
905
+ 'click_trans_id': click_trans_id,
906
+ 'merchant_trans_id': merchant_trans_id,
907
+ 'error': -3,
908
+ 'error_note': "Action not found"
909
+ }
910
+
911
+ except Exception as e:
912
+ logger.exception(f"Unexpected error in Click webhook: {e}")
913
+ return {
914
+ 'error': -7,
915
+ 'error_note': "Internal error"
916
+ }
917
+
918
+ def _check_auth(self, params: Dict[str, Any]) -> None:
919
+ """
920
+ Check authentication using signature.
921
+ """
922
+ if str(params.get('service_id')) != self.service_id:
923
+ raise HTTPException(
924
+ status_code=status.HTTP_401_UNAUTHORIZED,
925
+ detail="Invalid service ID"
926
+ )
927
+
928
+ # Check signature if secret key is provided
929
+ if self.secret_key:
930
+ sign_string = params.get('sign_string')
931
+ sign_time = params.get('sign_time')
932
+
933
+ if not sign_string or not sign_time:
934
+ raise HTTPException(
935
+ status_code=status.HTTP_401_UNAUTHORIZED,
936
+ detail="Missing signature parameters"
937
+ )
938
+
939
+ # Create string to sign
940
+ to_sign = f"{params.get('click_trans_id')}{params.get('service_id')}"
941
+ to_sign += f"{self.secret_key}{params.get('merchant_trans_id')}"
942
+ to_sign += f"{params.get('amount')}{params.get('action')}"
943
+ to_sign += f"{sign_time}"
944
+
945
+ # Generate signature
946
+ signature = hashlib.md5(to_sign.encode('utf-8')).hexdigest()
947
+
948
+ if signature != sign_string:
949
+ raise HTTPException(
950
+ status_code=status.HTTP_401_UNAUTHORIZED,
951
+ detail="Invalid signature"
952
+ )
953
+
954
+ def _find_account(self, merchant_trans_id: str) -> Any:
955
+ """
956
+ Find account by merchant_trans_id.
957
+ """
958
+ # Try to convert merchant_trans_id to int if it's a string and a digit
959
+ if isinstance(merchant_trans_id, str) and merchant_trans_id.isdigit():
960
+ merchant_trans_id = int(merchant_trans_id)
961
+
962
+ account = self.db.query(self.account_model).filter_by(id=merchant_trans_id).first()
963
+ if not account:
964
+ raise HTTPException(
965
+ status_code=status.HTTP_404_NOT_FOUND,
966
+ detail=f"Account with id={merchant_trans_id} not found"
967
+ )
968
+
969
+ return account
970
+
971
+ def _validate_amount(self, received_amount: float, expected_amount: float) -> None:
972
+ """
973
+ Validate payment amount.
974
+ """
975
+ # Add commission if needed
976
+ if self.commission_percent > 0:
977
+ expected_amount = expected_amount * (1 + self.commission_percent / 100)
978
+ expected_amount = round(expected_amount, 2)
979
+
980
+ # Allow small difference due to floating point precision
981
+ if abs(received_amount - expected_amount) > 0.01:
982
+ raise HTTPException(
983
+ status_code=status.HTTP_400_BAD_REQUEST,
984
+ detail=f"Incorrect amount. Expected: {expected_amount}, received: {received_amount}"
985
+ )
986
+
987
+ # Event methods that can be overridden by subclasses
988
+
989
+ def transaction_already_exists(self, params: Dict[str, Any], transaction: PaymentTransaction) -> None:
990
+ """
991
+ Called when a transaction already exists.
992
+
993
+ Args:
994
+ params: Request parameters
995
+ transaction: Transaction object
996
+ """
997
+ pass
998
+
999
+ def transaction_created(self, params: Dict[str, Any], transaction: PaymentTransaction, account: Any) -> None:
1000
+ """
1001
+ Called when a transaction is created.
1002
+
1003
+ Args:
1004
+ params: Request parameters
1005
+ transaction: Transaction object
1006
+ account: Account object
1007
+ """
1008
+ pass
1009
+
1010
+ def successfully_payment(self, params: Dict[str, Any], transaction: PaymentTransaction) -> None:
1011
+ """
1012
+ Called when a payment is successful.
1013
+
1014
+ Args:
1015
+ params: Request parameters
1016
+ transaction: Transaction object
1017
+ """
1018
+ pass
1019
+
1020
+ def cancelled_payment(self, params: Dict[str, Any], transaction: PaymentTransaction) -> None:
1021
+ """
1022
+ Called when a payment is cancelled.
1023
+
1024
+ Args:
1025
+ params: Request parameters
1026
+ transaction: Transaction object
1027
+ """
1028
+ pass