paytechuz 0.2.19__py3-none-any.whl → 0.2.20__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,227 @@
1
+ """
2
+ Click webhook handler.
3
+ """
4
+ import hashlib
5
+ import logging
6
+ from typing import Dict, Any, Callable
7
+
8
+ from paytechuz.core.base import BaseWebhookHandler
9
+ from paytechuz.core.constants import ClickActions
10
+ from paytechuz.core.exceptions import (
11
+ PermissionDenied,
12
+ InvalidAmount,
13
+ TransactionNotFound,
14
+ UnsupportedMethod,
15
+ AccountNotFound
16
+ )
17
+ from paytechuz.core.utils import handle_exceptions
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ class ClickWebhookHandler(BaseWebhookHandler):
22
+ """
23
+ Click webhook handler.
24
+
25
+ This class handles webhook requests from the Click payment system,
26
+ including transaction preparation and completion.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ service_id: str,
32
+ secret_key: str,
33
+ find_transaction_func: Callable[[str], Dict[str, Any]],
34
+ find_account_func: Callable[[str], Dict[str, Any]],
35
+ create_transaction_func: Callable[[Dict[str, Any]], Dict[str, Any]],
36
+ complete_transaction_func: Callable[[str, bool], Dict[str, Any]],
37
+ commission_percent: float = 0.0
38
+ ):
39
+ """
40
+ Initialize the Click webhook handler.
41
+
42
+ Args:
43
+ service_id: Click service ID
44
+ secret_key: Secret key for authentication
45
+ find_transaction_func: Function to find a transaction by ID
46
+ find_account_func: Function to find an account by ID
47
+ create_transaction_func: Function to create a transaction
48
+ complete_transaction_func: Function to complete a transaction
49
+ commission_percent: Commission percentage
50
+ """
51
+ self.service_id = service_id
52
+ self.secret_key = secret_key
53
+ self.find_transaction = find_transaction_func
54
+ self.find_account = find_account_func
55
+ self.create_transaction = create_transaction_func
56
+ self.complete_transaction = complete_transaction_func
57
+ self.commission_percent = commission_percent
58
+
59
+ def _check_auth(self, params: Dict[str, Any]) -> None:
60
+ """
61
+ Check authentication using signature.
62
+
63
+ Args:
64
+ params: Request parameters
65
+
66
+ Raises:
67
+ PermissionDenied: If authentication fails
68
+ """
69
+ if str(params.get('service_id')) != self.service_id:
70
+ raise PermissionDenied("Invalid service ID")
71
+
72
+ # Check signature if secret key is provided
73
+ if self.secret_key:
74
+ sign_string = params.get('sign_string')
75
+ sign_time = params.get('sign_time')
76
+
77
+ if not sign_string or not sign_time:
78
+ raise PermissionDenied("Missing signature parameters")
79
+
80
+ # Create string to sign
81
+ to_sign = f"{params.get('click_trans_id')}{params.get('service_id')}"
82
+ to_sign += f"{self.secret_key}{params.get('merchant_trans_id')}"
83
+ to_sign += f"{params.get('amount')}{params.get('action')}"
84
+ to_sign += f"{sign_time}"
85
+
86
+ # Generate signature
87
+ signature = hashlib.md5(to_sign.encode('utf-8')).hexdigest()
88
+
89
+ if signature != sign_string:
90
+ raise PermissionDenied("Invalid signature")
91
+
92
+ def _validate_amount(
93
+ self,
94
+ received_amount: float,
95
+ expected_amount: float
96
+ ) -> None:
97
+ """
98
+ Validate payment amount.
99
+
100
+ Args:
101
+ received_amount: Amount received from Click
102
+ expected_amount: Expected amount
103
+
104
+ Raises:
105
+ InvalidAmount: If amounts don't match
106
+ """
107
+ # Add commission if needed
108
+ if self.commission_percent > 0:
109
+ expected_amount = expected_amount * (1 + self.commission_percent / 100)
110
+ expected_amount = round(expected_amount, 2)
111
+
112
+ # Allow small difference due to floating point precision
113
+ if abs(received_amount - expected_amount) > 0.01:
114
+ raise InvalidAmount(f"Incorrect amount. Expected: {expected_amount}, received: {received_amount}")
115
+
116
+ @handle_exceptions
117
+ def handle_webhook(self, data: Dict[str, Any]) -> Dict[str, Any]:
118
+ """
119
+ Handle webhook data from Click.
120
+
121
+ Args:
122
+ data: The webhook data received from Click
123
+
124
+ Returns:
125
+ Dict containing the response to be sent back to Click
126
+
127
+ Raises:
128
+ PermissionDenied: If authentication fails
129
+ UnsupportedMethod: If the requested action is not supported
130
+ """
131
+ # Check authentication
132
+ self._check_auth(data)
133
+
134
+ # Extract parameters
135
+ click_trans_id = data.get('click_trans_id')
136
+ merchant_trans_id = data.get('merchant_trans_id')
137
+ amount = float(data.get('amount', 0))
138
+ action = int(data.get('action', -1))
139
+ error = int(data.get('error', 0))
140
+
141
+ # Find account
142
+ try:
143
+ account = self.find_account(merchant_trans_id)
144
+ except AccountNotFound:
145
+ logger.error(f"Account not found: {merchant_trans_id}")
146
+ return {
147
+ 'click_trans_id': click_trans_id,
148
+ 'merchant_trans_id': merchant_trans_id,
149
+ 'error': -5,
150
+ 'error_note': "User not found"
151
+ }
152
+
153
+ # Validate amount
154
+ try:
155
+ self._validate_amount(amount, float(account.get('amount', 0)))
156
+ except InvalidAmount as e:
157
+ logger.error(f"Invalid amount: {e}")
158
+ return {
159
+ 'click_trans_id': click_trans_id,
160
+ 'merchant_trans_id': merchant_trans_id,
161
+ 'error': -2,
162
+ 'error_note': str(e)
163
+ }
164
+
165
+ # Check if transaction already exists
166
+ try:
167
+ transaction = self.find_transaction(click_trans_id)
168
+
169
+ # If transaction is already completed, return success
170
+ if transaction.get('state') == 2: # SUCCESSFULLY
171
+ return {
172
+ 'click_trans_id': click_trans_id,
173
+ 'merchant_trans_id': merchant_trans_id,
174
+ 'merchant_prepare_id': transaction.get('id'),
175
+ 'error': 0,
176
+ 'error_note': "Success"
177
+ }
178
+
179
+ # If transaction is cancelled, return error
180
+ if transaction.get('state') == -2: # CANCELLED
181
+ return {
182
+ 'click_trans_id': click_trans_id,
183
+ 'merchant_trans_id': merchant_trans_id,
184
+ 'merchant_prepare_id': transaction.get('id'),
185
+ 'error': -9,
186
+ 'error_note': "Transaction cancelled"
187
+ }
188
+ except TransactionNotFound:
189
+ # Transaction doesn't exist, continue with the flow
190
+ pass
191
+
192
+ # Handle different actions
193
+ if action == ClickActions.PREPARE:
194
+ # Create transaction
195
+ transaction = self.create_transaction({
196
+ 'click_trans_id': click_trans_id,
197
+ 'merchant_trans_id': merchant_trans_id,
198
+ 'amount': amount,
199
+ 'account': account
200
+ })
201
+
202
+ return {
203
+ 'click_trans_id': click_trans_id,
204
+ 'merchant_trans_id': merchant_trans_id,
205
+ 'merchant_prepare_id': transaction.get('id'),
206
+ 'error': 0,
207
+ 'error_note': "Success"
208
+ }
209
+
210
+ elif action == ClickActions.COMPLETE:
211
+ # Check if error is negative (payment failed)
212
+ is_successful = error >= 0
213
+
214
+ # Complete transaction
215
+ transaction = self.complete_transaction(click_trans_id, is_successful)
216
+
217
+ return {
218
+ 'click_trans_id': click_trans_id,
219
+ 'merchant_trans_id': merchant_trans_id,
220
+ 'merchant_prepare_id': transaction.get('id'),
221
+ 'error': 0,
222
+ 'error_note': "Success"
223
+ }
224
+
225
+ else:
226
+ logger.error(f"Unsupported action: {action}")
227
+ raise UnsupportedMethod(f"Unsupported action: {action}")
File without changes
@@ -0,0 +1,222 @@
1
+ """
2
+ Payme cards operations.
3
+ """
4
+ import logging
5
+ from typing import Dict, Any
6
+
7
+ from paytechuz.core.http import HttpClient
8
+ from paytechuz.core.constants import PaymeEndpoints
9
+ from paytechuz.core.utils import handle_exceptions
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ class PaymeCards:
14
+ """
15
+ Payme cards operations.
16
+
17
+ This class provides methods for working with cards in the Payme payment system,
18
+ including creating cards, verifying cards, and removing cards.
19
+ """
20
+
21
+ def __init__(self, http_client: HttpClient, payme_id: str):
22
+ """
23
+ Initialize the Payme cards component.
24
+
25
+ Args:
26
+ http_client: HTTP client for making requests
27
+ payme_id: Payme merchant ID
28
+ """
29
+ self.http_client = http_client
30
+ self.payme_id = payme_id
31
+
32
+ @handle_exceptions
33
+ def create(
34
+ self,
35
+ card_number: str,
36
+ expire_date: str,
37
+ save: bool = True,
38
+ **kwargs
39
+ ) -> Dict[str, Any]:
40
+ """
41
+ Create a new card.
42
+
43
+ Args:
44
+ card_number: Card number
45
+ expire_date: Card expiration date in format "MM/YY"
46
+ save: Whether to save the card for future use
47
+ **kwargs: Additional parameters
48
+ - phone: Customer phone number
49
+ - language: Language code (uz, ru, en)
50
+
51
+ Returns:
52
+ Dict containing card creation response
53
+ """
54
+ # Extract additional parameters
55
+ phone = kwargs.get('phone')
56
+ language = kwargs.get('language', 'uz')
57
+
58
+ # Prepare request data
59
+ data = {
60
+ "method": PaymeEndpoints.CARDS_CREATE,
61
+ "params": {
62
+ "card": {
63
+ "number": card_number,
64
+ "expire": expire_date
65
+ },
66
+ "save": save,
67
+ "merchant_id": self.payme_id
68
+ }
69
+ }
70
+
71
+ # Add optional parameters
72
+ if phone:
73
+ data["params"]["phone"] = phone
74
+
75
+ # Add language header
76
+ headers = {"Accept-Language": language}
77
+
78
+ # Make request
79
+ response = self.http_client.post(
80
+ endpoint="",
81
+ json_data=data,
82
+ headers=headers
83
+ )
84
+
85
+ return response
86
+
87
+ @handle_exceptions
88
+ def verify(
89
+ self,
90
+ token: str,
91
+ code: str,
92
+ **kwargs
93
+ ) -> Dict[str, Any]:
94
+ """
95
+ Verify a card with the verification code.
96
+
97
+ Args:
98
+ token: Card token received from create method
99
+ code: Verification code sent to the card owner
100
+ **kwargs: Additional parameters
101
+ - language: Language code (uz, ru, en)
102
+
103
+ Returns:
104
+ Dict containing card verification response
105
+ """
106
+ # Extract additional parameters
107
+ language = kwargs.get('language', 'uz')
108
+
109
+ # Prepare request data
110
+ data = {
111
+ "method": PaymeEndpoints.CARDS_VERIFY,
112
+ "params": {
113
+ "token": token,
114
+ "code": code
115
+ }
116
+ }
117
+
118
+ # Add language header
119
+ headers = {"Accept-Language": language}
120
+
121
+ # Make request
122
+ response = self.http_client.post(
123
+ endpoint="",
124
+ json_data=data,
125
+ headers=headers
126
+ )
127
+
128
+ return response
129
+
130
+ @handle_exceptions
131
+ def check(self, token: str) -> Dict[str, Any]:
132
+ """
133
+ Check if a card exists and is active.
134
+
135
+ Args:
136
+ token: Card token
137
+
138
+ Returns:
139
+ Dict containing card check response
140
+ """
141
+ # Prepare request data
142
+ data = {
143
+ "method": PaymeEndpoints.CARDS_CHECK,
144
+ "params": {
145
+ "token": token
146
+ }
147
+ }
148
+
149
+ # Make request
150
+ response = self.http_client.post(
151
+ endpoint="",
152
+ json_data=data
153
+ )
154
+
155
+ return response
156
+
157
+ @handle_exceptions
158
+ def remove(self, token: str) -> Dict[str, Any]:
159
+ """
160
+ Remove a card.
161
+
162
+ Args:
163
+ token: Card token
164
+
165
+ Returns:
166
+ Dict containing card removal response
167
+ """
168
+ # Prepare request data
169
+ data = {
170
+ "method": PaymeEndpoints.CARDS_REMOVE,
171
+ "params": {
172
+ "token": token
173
+ }
174
+ }
175
+
176
+ # Make request
177
+ response = self.http_client.post(
178
+ endpoint="",
179
+ json_data=data
180
+ )
181
+
182
+ return response
183
+
184
+ @handle_exceptions
185
+ def get_verify_code(
186
+ self,
187
+ token: str,
188
+ **kwargs
189
+ ) -> Dict[str, Any]:
190
+ """
191
+ Get a new verification code for a card.
192
+
193
+ Args:
194
+ token: Card token
195
+ **kwargs: Additional parameters
196
+ - language: Language code (uz, ru, en)
197
+
198
+ Returns:
199
+ Dict containing verification code response
200
+ """
201
+ # Extract additional parameters
202
+ language = kwargs.get('language', 'uz')
203
+
204
+ # Prepare request data
205
+ data = {
206
+ "method": PaymeEndpoints.CARDS_GET_VERIFY_CODE,
207
+ "params": {
208
+ "token": token
209
+ }
210
+ }
211
+
212
+ # Add language header
213
+ headers = {"Accept-Language": language}
214
+
215
+ # Make request
216
+ response = self.http_client.post(
217
+ endpoint="",
218
+ json_data=data,
219
+ headers=headers
220
+ )
221
+
222
+ return response
@@ -0,0 +1,262 @@
1
+ """
2
+ Payme payment gateway client.
3
+ """
4
+ import logging
5
+ from typing import Dict, Any, Optional, Union
6
+ import base64
7
+
8
+ from paytechuz.core.base import BasePaymentGateway
9
+ from paytechuz.core.http import HttpClient
10
+ from paytechuz.core.constants import PaymeNetworks
11
+ from paytechuz.core.utils import format_amount, handle_exceptions
12
+ from paytechuz.gateways.payme.cards import PaymeCards
13
+ from paytechuz.gateways.payme.receipts import PaymeReceipts
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class PaymeGateway(BasePaymentGateway):
19
+ """
20
+ Payme payment gateway implementation.
21
+
22
+ This class provides methods for interacting with the Payme payment gateway,
23
+ including creating payments, checking payment status, and canceling payments.
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ payme_id: str,
29
+ payme_key: Optional[str] = None,
30
+ fallback_id: Optional[str] = None,
31
+ is_test_mode: bool = False
32
+ ):
33
+ """
34
+ Initialize the Payme gateway.
35
+
36
+ Args:
37
+ payme_id: Payme merchant ID
38
+ payme_key: Payme merchant key for authentication
39
+ fallback_id: Fallback merchant ID
40
+ is_test_mode: Whether to use the test environment
41
+ """
42
+ super().__init__(is_test_mode)
43
+ self.payme_id = payme_id
44
+ self.payme_key = payme_key
45
+ self.fallback_id = fallback_id
46
+
47
+ # Set the API URL based on the environment
48
+ url = PaymeNetworks.TEST_NET if is_test_mode else PaymeNetworks.PROD_NET
49
+
50
+ # Initialize HTTP client
51
+ self.http_client = HttpClient(base_url=url)
52
+
53
+ # Initialize components
54
+ self.cards = PaymeCards(http_client=self.http_client, payme_id=payme_id)
55
+ self.receipts = PaymeReceipts(
56
+ http_client=self.http_client,
57
+ payme_id=payme_id,
58
+ payme_key=payme_key
59
+ )
60
+
61
+ def generate_pay_link(
62
+ self,
63
+ id: Union[int, str],
64
+ amount: Union[int, float, str],
65
+ return_url: str,
66
+ account_field_name: str = "order_id"
67
+ ) -> str:
68
+ """
69
+ Generate a payment link for a specific order.
70
+
71
+ Parameters
72
+ ----------
73
+ id : Union[int, str]
74
+ Unique identifier for the account/order.
75
+ amount : Union[int, float, str]
76
+ Payment amount in som.
77
+ return_url : str
78
+ URL to redirect after payment completion.
79
+ account_field_name : str, optional
80
+ Field name for account identifier (default: "order_id").
81
+
82
+ Returns
83
+ -------
84
+ str
85
+ Payme checkout URL with encoded parameters.
86
+
87
+ References
88
+ ----------
89
+ https://developer.help.paycom.uz/initsializatsiya-platezhey/
90
+ """
91
+ # Convert amount to tiyin (1 som = 100 tiyin)
92
+ amount_tiyin = int(float(amount) * 100)
93
+
94
+ # Build parameters
95
+ params = (
96
+ f'm={self.payme_id};'
97
+ f'ac.{account_field_name}={id};'
98
+ f'a={amount_tiyin};'
99
+ f'c={return_url}'
100
+ )
101
+ encoded_params = base64.b64encode(params.encode("utf-8")).decode("utf-8")
102
+
103
+ # Return URL based on environment
104
+ base_url = "https://test.paycom.uz" if self.is_test_mode else "https://checkout.paycom.uz"
105
+ return f"{base_url}/{encoded_params}"
106
+
107
+ async def generate_pay_link_async(
108
+ self,
109
+ id: Union[int, str],
110
+ amount: Union[int, float, str],
111
+ return_url: str,
112
+ account_field_name: str = "order_id"
113
+ ) -> str:
114
+ """
115
+ Async version of generate_pay_link.
116
+
117
+ Parameters
118
+ ----------
119
+ id : Union[int, str]
120
+ Unique identifier for the account/order.
121
+ amount : Union[int, float, str]
122
+ Payment amount in som.
123
+ return_url : str
124
+ URL to redirect after payment completion.
125
+ account_field_name : str, optional
126
+ Field name for account identifier (default: "order_id").
127
+
128
+ Returns
129
+ -------
130
+ str
131
+ Payme checkout URL with encoded parameters.
132
+ """
133
+ return self.generate_pay_link(
134
+ id=id,
135
+ amount=amount,
136
+ return_url=return_url,
137
+ account_field_name=account_field_name
138
+ )
139
+
140
+ @handle_exceptions
141
+ def create_payment(
142
+ self,
143
+ id: Union[int, str],
144
+ amount: Union[int, float, str],
145
+ return_url: str = "",
146
+ account_field_name: str = "order_id"
147
+ ) -> str:
148
+ """
149
+ Create a payment using Payme.
150
+
151
+ Args:
152
+ id: Account or order ID
153
+ amount: Payment amount in som
154
+ return_url: Return URL after payment (default: "")
155
+ account_field_name: Field name for account ID (default: "order_id")
156
+
157
+ Returns:
158
+ str: Payme payment URL
159
+ """
160
+ return self.generate_pay_link(
161
+ id=id,
162
+ amount=amount,
163
+ return_url=return_url,
164
+ account_field_name=account_field_name
165
+ )
166
+
167
+ @handle_exceptions
168
+ async def create_payment_async(
169
+ self,
170
+ id: Union[int, str],
171
+ amount: Union[int, float, str],
172
+ return_url: str = "",
173
+ account_field_name: str = "order_id"
174
+ ) -> str:
175
+ """
176
+ Async version of create_payment.
177
+
178
+ Args:
179
+ id: Account or order ID
180
+ amount: Payment amount in som
181
+ return_url: Return URL after payment (default: "")
182
+ account_field_name: Field name for account ID (default: "order_id")
183
+
184
+ Returns:
185
+ str: Payme payment URL
186
+ """
187
+ return await self.generate_pay_link_async(
188
+ id=id,
189
+ amount=amount,
190
+ return_url=return_url,
191
+ account_field_name=account_field_name
192
+ )
193
+
194
+ @handle_exceptions
195
+ def check_payment(self, transaction_id: str) -> Dict[str, Any]:
196
+ """
197
+ Check payment status using Payme receipts.
198
+
199
+ Args:
200
+ transaction_id: The receipt ID to check
201
+
202
+ Returns:
203
+ Dict containing payment status and details
204
+ """
205
+ receipt_data = self.receipts.check(receipt_id=transaction_id)
206
+
207
+ # Extract receipt status
208
+ receipt = receipt_data.get('receipt', {})
209
+ status = receipt.get('state')
210
+
211
+ # Map Payme status to our status
212
+ status_mapping = {
213
+ 0: 'created',
214
+ 1: 'waiting',
215
+ 2: 'paid',
216
+ 3: 'cancelled',
217
+ 4: 'refunded'
218
+ }
219
+
220
+ mapped_status = status_mapping.get(status, 'unknown')
221
+
222
+ return {
223
+ 'transaction_id': transaction_id,
224
+ 'status': mapped_status,
225
+ 'amount': receipt.get('amount') / 100, # Convert from tiyin to som
226
+ 'paid_at': receipt.get('pay_time'),
227
+ 'created_at': receipt.get('create_time'),
228
+ 'cancelled_at': receipt.get('cancel_time'),
229
+ 'raw_response': receipt_data
230
+ }
231
+
232
+ @handle_exceptions
233
+ def cancel_payment(
234
+ self,
235
+ transaction_id: str,
236
+ reason: Optional[str] = None
237
+ ) -> Dict[str, Any]:
238
+ """
239
+ Cancel payment using Payme receipts.
240
+
241
+ Args:
242
+ transaction_id: The receipt ID to cancel
243
+ reason: Optional reason for cancellation
244
+
245
+ Returns:
246
+ Dict containing cancellation status and details
247
+ """
248
+ receipt_data = self.receipts.cancel(
249
+ receipt_id=transaction_id,
250
+ reason=reason or "Cancelled by merchant"
251
+ )
252
+
253
+ # Extract receipt status
254
+ receipt = receipt_data.get('receipt', {})
255
+ status = receipt.get('state')
256
+
257
+ return {
258
+ 'transaction_id': transaction_id,
259
+ 'status': 'cancelled' if status == 3 else 'unknown',
260
+ 'cancelled_at': receipt.get('cancel_time'),
261
+ 'raw_response': receipt_data
262
+ }