paytechuz 0.2.21__py3-none-any.whl → 0.2.23__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.

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