paytechuz 0.2.24__py3-none-any.whl → 0.3.1__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.
- paytechuz/__init__.py +6 -4
- paytechuz/core/constants.py +25 -0
- paytechuz/gateways/atmos/__init__.py +2 -0
- paytechuz/gateways/atmos/client.py +212 -0
- paytechuz/gateways/atmos/webhook.py +99 -0
- paytechuz/integrations/django/migrations/0002_alter_paymenttransaction_gateway.py +18 -0
- paytechuz/integrations/django/models.py +2 -0
- paytechuz/integrations/django/views.py +37 -1
- paytechuz/integrations/django/webhooks.py +131 -0
- {paytechuz-0.2.24.dist-info → paytechuz-0.3.1.dist-info}/METADATA +95 -5
- {paytechuz-0.2.24.dist-info → paytechuz-0.3.1.dist-info}/RECORD +13 -9
- {paytechuz-0.2.24.dist-info → paytechuz-0.3.1.dist-info}/WHEEL +0 -0
- {paytechuz-0.2.24.dist-info → paytechuz-0.3.1.dist-info}/top_level.txt +0 -0
paytechuz/__init__.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
2
|
PayTechUZ - Unified payment library for Uzbekistan payment systems.
|
|
3
3
|
|
|
4
|
-
This library provides a unified interface for working with Payme and
|
|
4
|
+
This library provides a unified interface for working with Payme, Click, and Atmos
|
|
5
5
|
payment systems in Uzbekistan. It supports Django, Flask, and FastAPI.
|
|
6
6
|
"""
|
|
7
|
-
from typing import Any
|
|
8
7
|
|
|
9
|
-
__version__ = '0.
|
|
8
|
+
__version__ = '0.3.1'
|
|
10
9
|
|
|
11
10
|
# Import framework integrations - these imports are used to check availability
|
|
12
11
|
# of frameworks, not for direct usage
|
|
@@ -31,6 +30,7 @@ except ImportError:
|
|
|
31
30
|
from paytechuz.core.base import BasePaymentGateway # noqa: E402
|
|
32
31
|
from paytechuz.gateways.payme.client import PaymeGateway # noqa: E402
|
|
33
32
|
from paytechuz.gateways.click.client import ClickGateway # noqa: E402
|
|
33
|
+
from paytechuz.gateways.atmos.client import AtmosGateway # noqa: E402
|
|
34
34
|
from paytechuz.core.constants import PaymentGateway # noqa: E402
|
|
35
35
|
|
|
36
36
|
|
|
@@ -39,7 +39,7 @@ def create_gateway(gateway_type: str, **kwargs) -> BasePaymentGateway:
|
|
|
39
39
|
Create a payment gateway instance.
|
|
40
40
|
|
|
41
41
|
Args:
|
|
42
|
-
gateway_type: Type of gateway ('payme' or '
|
|
42
|
+
gateway_type: Type of gateway ('payme', 'click', or 'atmos')
|
|
43
43
|
**kwargs: Gateway-specific configuration
|
|
44
44
|
|
|
45
45
|
Returns:
|
|
@@ -53,5 +53,7 @@ def create_gateway(gateway_type: str, **kwargs) -> BasePaymentGateway:
|
|
|
53
53
|
return PaymeGateway(**kwargs)
|
|
54
54
|
if gateway_type.lower() == PaymentGateway.CLICK.value:
|
|
55
55
|
return ClickGateway(**kwargs)
|
|
56
|
+
if gateway_type.lower() == PaymentGateway.ATMOS.value:
|
|
57
|
+
return AtmosGateway(**kwargs)
|
|
56
58
|
|
|
57
59
|
raise ValueError(f"Unsupported gateway type: {gateway_type}")
|
paytechuz/core/constants.py
CHANGED
|
@@ -16,6 +16,7 @@ class PaymentGateway(Enum):
|
|
|
16
16
|
"""Payment gateway types."""
|
|
17
17
|
PAYME = "payme"
|
|
18
18
|
CLICK = "click"
|
|
19
|
+
ATMOS = "atmos"
|
|
19
20
|
|
|
20
21
|
class PaymeEndpoints:
|
|
21
22
|
"""Payme API endpoints."""
|
|
@@ -66,3 +67,27 @@ class PaymeCancelReason:
|
|
|
66
67
|
REASON_CANCELLED_BY_USER = 7
|
|
67
68
|
REASON_SUSPICIOUS_OPERATION = 8
|
|
68
69
|
REASON_MERCHANT_DECISION = 9
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AtmosEndpoints:
|
|
73
|
+
"""Atmos API endpoints."""
|
|
74
|
+
TOKEN = "/token"
|
|
75
|
+
CREATE_PAYMENT = "/merchant/pay/create"
|
|
76
|
+
CHECK_PAYMENT = "/merchant/pay/get-status"
|
|
77
|
+
CANCEL_PAYMENT = "/merchant/pay/cancel"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class AtmosNetworks:
|
|
81
|
+
"""Atmos API networks."""
|
|
82
|
+
PROD_NET = "https://partner.atmos.uz"
|
|
83
|
+
TEST_CHECKOUT = "https://test-checkout.pays.uz/invoice/get"
|
|
84
|
+
PROD_CHECKOUT = "https://checkout.pays.uz/invoice/get"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class AtmosTransactionStatus:
|
|
88
|
+
"""Atmos transaction status codes."""
|
|
89
|
+
CREATED = "created"
|
|
90
|
+
PENDING = "pending"
|
|
91
|
+
SUCCESS = "success"
|
|
92
|
+
FAILED = "failed"
|
|
93
|
+
CANCELLED = "cancelled"
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Atmos payment gateway client.
|
|
3
|
+
"""
|
|
4
|
+
import base64
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Dict, Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
from paytechuz.core.base import BasePaymentGateway
|
|
9
|
+
from paytechuz.core.http import HttpClient
|
|
10
|
+
from paytechuz.core.utils import handle_exceptions
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AtmosGateway(BasePaymentGateway):
|
|
16
|
+
"""
|
|
17
|
+
Atmos payment gateway implementation.
|
|
18
|
+
|
|
19
|
+
This class provides methods for interacting with the Atmos payment gateway,
|
|
20
|
+
including creating payments, checking payment status, and canceling
|
|
21
|
+
payments.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
consumer_key: str,
|
|
27
|
+
consumer_secret: str,
|
|
28
|
+
store_id: str,
|
|
29
|
+
terminal_id: Optional[str] = None,
|
|
30
|
+
is_test_mode: bool = False
|
|
31
|
+
):
|
|
32
|
+
"""
|
|
33
|
+
Initialize the Atmos gateway.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
consumer_key: Atmos consumer key
|
|
37
|
+
consumer_secret: Atmos consumer secret
|
|
38
|
+
store_id: Atmos store ID
|
|
39
|
+
terminal_id: Atmos terminal ID (optional)
|
|
40
|
+
is_test_mode: Whether to use the test environment
|
|
41
|
+
"""
|
|
42
|
+
super().__init__(is_test_mode)
|
|
43
|
+
self.consumer_key = consumer_key
|
|
44
|
+
self.consumer_secret = consumer_secret
|
|
45
|
+
self.store_id = store_id
|
|
46
|
+
self.terminal_id = terminal_id
|
|
47
|
+
|
|
48
|
+
# Base URL is hard coded as per requirements
|
|
49
|
+
self.base_url = 'https://partner.atmos.uz'
|
|
50
|
+
|
|
51
|
+
# Initialize HTTP client
|
|
52
|
+
self.client = HttpClient(base_url=self.base_url)
|
|
53
|
+
|
|
54
|
+
# Get access token
|
|
55
|
+
self._access_token = None
|
|
56
|
+
self._get_access_token()
|
|
57
|
+
|
|
58
|
+
def _get_access_token(self) -> str:
|
|
59
|
+
"""Get access token for API authentication."""
|
|
60
|
+
try:
|
|
61
|
+
# Create basic auth header
|
|
62
|
+
credentials = f"{self.consumer_key}:{self.consumer_secret}"
|
|
63
|
+
encoded_credentials = base64.b64encode(
|
|
64
|
+
credentials.encode('utf-8')).decode('utf-8')
|
|
65
|
+
|
|
66
|
+
headers = {
|
|
67
|
+
'Authorization': f'Basic {encoded_credentials}',
|
|
68
|
+
'Content-Type': 'application/x-www-form-urlencoded'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
data = {'grant_type': 'client_credentials'}
|
|
72
|
+
|
|
73
|
+
response = self.client.post('/token', data=data, headers=headers)
|
|
74
|
+
|
|
75
|
+
if response.get('access_token'):
|
|
76
|
+
self._access_token = response['access_token']
|
|
77
|
+
return self._access_token
|
|
78
|
+
|
|
79
|
+
raise ValueError("Failed to get access token")
|
|
80
|
+
|
|
81
|
+
except Exception as e:
|
|
82
|
+
logger.error("Error getting access token: %s", e)
|
|
83
|
+
raise
|
|
84
|
+
|
|
85
|
+
def _make_request(self, endpoint: str,
|
|
86
|
+
data: Dict[str, Any]) -> Dict[str, Any]:
|
|
87
|
+
"""Make authenticated request to Atmos API."""
|
|
88
|
+
if not self._access_token:
|
|
89
|
+
self._get_access_token()
|
|
90
|
+
|
|
91
|
+
headers = {
|
|
92
|
+
'Authorization': f'Bearer {self._access_token}',
|
|
93
|
+
'Content-Type': 'application/json'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
response = self.client.post(endpoint, json_data=data,
|
|
98
|
+
headers=headers)
|
|
99
|
+
return response
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error("API request failed: %s", e)
|
|
102
|
+
raise
|
|
103
|
+
|
|
104
|
+
@handle_exceptions
|
|
105
|
+
def create_payment(
|
|
106
|
+
self,
|
|
107
|
+
account_id: Union[int, str],
|
|
108
|
+
amount: Union[int, float, str],
|
|
109
|
+
**kwargs
|
|
110
|
+
) -> Dict[str, Any]:
|
|
111
|
+
"""
|
|
112
|
+
Create a payment transaction.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
account_id: The account ID or order ID
|
|
116
|
+
amount: The payment amount
|
|
117
|
+
**kwargs: Additional parameters
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Dict containing payment details including transaction ID and
|
|
121
|
+
payment URL
|
|
122
|
+
"""
|
|
123
|
+
# Convert amount to tiyin (multiply by 100)
|
|
124
|
+
amount_tiyin = int(float(amount) * 100)
|
|
125
|
+
|
|
126
|
+
# Prepare request data
|
|
127
|
+
create_data = {
|
|
128
|
+
'amount': amount_tiyin,
|
|
129
|
+
'account': str(account_id),
|
|
130
|
+
'store_id': self.store_id
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# Add terminal_id if provided
|
|
134
|
+
if self.terminal_id:
|
|
135
|
+
create_data['terminal_id'] = self.terminal_id
|
|
136
|
+
|
|
137
|
+
# Create transaction
|
|
138
|
+
response = self._make_request('/merchant/pay/create', create_data)
|
|
139
|
+
transaction_id = response['transaction_id']
|
|
140
|
+
|
|
141
|
+
# Generate payment URL
|
|
142
|
+
if self.is_test_mode:
|
|
143
|
+
base_url = "https://test-checkout.pays.uz/invoice/get"
|
|
144
|
+
else:
|
|
145
|
+
base_url = "https://checkout.pays.uz/invoice/get"
|
|
146
|
+
|
|
147
|
+
payment_url = (f"{base_url}?storeId={self.store_id}"
|
|
148
|
+
f"&transactionId={transaction_id}")
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
'transaction_id': transaction_id,
|
|
152
|
+
'payment_url': payment_url,
|
|
153
|
+
'amount': amount,
|
|
154
|
+
'account': str(account_id),
|
|
155
|
+
'status': 'created'
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
@handle_exceptions
|
|
159
|
+
def check_payment(self, transaction_id: str) -> Dict[str, Any]:
|
|
160
|
+
"""
|
|
161
|
+
Check payment status.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
transaction_id: The transaction ID to check
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
Dict containing payment status and details
|
|
168
|
+
"""
|
|
169
|
+
data = {
|
|
170
|
+
'transaction_id': transaction_id,
|
|
171
|
+
'store_id': self.store_id
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
response = self._make_request('/merchant/pay/get-status', data)
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
'transaction_id': transaction_id,
|
|
178
|
+
'status': response.get('status', 'unknown'),
|
|
179
|
+
'details': response
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
@handle_exceptions
|
|
183
|
+
def cancel_payment(
|
|
184
|
+
self,
|
|
185
|
+
transaction_id: str,
|
|
186
|
+
reason: Optional[str] = None
|
|
187
|
+
) -> Dict[str, Any]:
|
|
188
|
+
"""
|
|
189
|
+
Cancel payment.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
transaction_id: The transaction ID to cancel
|
|
193
|
+
reason: Optional reason for cancellation
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
Dict containing cancellation status and details
|
|
197
|
+
"""
|
|
198
|
+
data = {
|
|
199
|
+
'transaction_id': transaction_id,
|
|
200
|
+
'store_id': self.store_id
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
if reason:
|
|
204
|
+
data['reason'] = reason
|
|
205
|
+
|
|
206
|
+
response = self._make_request('/merchant/pay/cancel', data)
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
'transaction_id': transaction_id,
|
|
210
|
+
'status': 'cancelled',
|
|
211
|
+
'details': response
|
|
212
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Atmos webhook handler.
|
|
3
|
+
"""
|
|
4
|
+
import hashlib
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Dict, Any
|
|
7
|
+
|
|
8
|
+
from paytechuz.core.base import BaseWebhookHandler
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AtmosWebhookHandler(BaseWebhookHandler):
|
|
14
|
+
"""
|
|
15
|
+
Atmos webhook handler for processing payment notifications.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, api_key: str):
|
|
19
|
+
"""
|
|
20
|
+
Initialize the webhook handler.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: API key for signature verification
|
|
24
|
+
"""
|
|
25
|
+
self.api_key = api_key
|
|
26
|
+
|
|
27
|
+
def verify_signature(self, webhook_data: Dict[str, Any],
|
|
28
|
+
received_signature: str) -> bool:
|
|
29
|
+
"""
|
|
30
|
+
Verify webhook signature from Atmos.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
webhook_data: The webhook data received
|
|
34
|
+
received_signature: The signature received from Atmos
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
bool: True if signature is valid, False otherwise
|
|
38
|
+
"""
|
|
39
|
+
# Extract data from webhook
|
|
40
|
+
store_id = str(webhook_data.get('store_id', ''))
|
|
41
|
+
transaction_id = str(webhook_data.get('transaction_id', ''))
|
|
42
|
+
invoice = str(webhook_data.get('invoice', ''))
|
|
43
|
+
amount = str(webhook_data.get('amount', ''))
|
|
44
|
+
|
|
45
|
+
# Create signature string:
|
|
46
|
+
# store_id+transaction_id+invoice+amount+api_key
|
|
47
|
+
signature_string = (f"{store_id}{transaction_id}{invoice}"
|
|
48
|
+
f"{amount}{self.api_key}")
|
|
49
|
+
|
|
50
|
+
# Generate MD5 hash
|
|
51
|
+
calculated_signature = hashlib.md5(
|
|
52
|
+
signature_string.encode('utf-8')).hexdigest()
|
|
53
|
+
|
|
54
|
+
# Compare signatures
|
|
55
|
+
return calculated_signature == received_signature
|
|
56
|
+
|
|
57
|
+
def handle_webhook(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
|
58
|
+
"""
|
|
59
|
+
Handle webhook data from Atmos.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
data: The webhook data received from Atmos
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dict containing the response to be sent back to Atmos
|
|
66
|
+
"""
|
|
67
|
+
try:
|
|
68
|
+
# Extract signature
|
|
69
|
+
received_signature = data.get('sign', '')
|
|
70
|
+
|
|
71
|
+
# Verify signature
|
|
72
|
+
if not self.verify_signature(data, received_signature):
|
|
73
|
+
logger.error("Invalid webhook signature")
|
|
74
|
+
return {
|
|
75
|
+
'status': 0,
|
|
76
|
+
'message': 'Invalid signature'
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
# Extract webhook data
|
|
80
|
+
transaction_id = data.get('transaction_id')
|
|
81
|
+
amount = data.get('amount')
|
|
82
|
+
invoice = data.get('invoice')
|
|
83
|
+
|
|
84
|
+
logger.info("Webhook received for transaction %s, "
|
|
85
|
+
"invoice %s, amount %s",
|
|
86
|
+
transaction_id, invoice, amount)
|
|
87
|
+
|
|
88
|
+
# Return success response
|
|
89
|
+
return {
|
|
90
|
+
'status': 1,
|
|
91
|
+
'message': 'Успешно'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
logger.error("Webhook processing error: %s", e)
|
|
96
|
+
return {
|
|
97
|
+
'status': 0,
|
|
98
|
+
'message': f'Error: {str(e)}'
|
|
99
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# Generated by Django 5.2.5 on 2025-08-19 08:13
|
|
2
|
+
|
|
3
|
+
from django.db import migrations, models
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Migration(migrations.Migration):
|
|
7
|
+
|
|
8
|
+
dependencies = [
|
|
9
|
+
('django', '0001_initial'),
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
operations = [
|
|
13
|
+
migrations.AlterField(
|
|
14
|
+
model_name='paymenttransaction',
|
|
15
|
+
name='gateway',
|
|
16
|
+
field=models.CharField(choices=[('payme', 'Payme'), ('click', 'Click'), ('atmos', 'Atmos')], max_length=10),
|
|
17
|
+
),
|
|
18
|
+
]
|
|
@@ -12,10 +12,12 @@ class PaymentTransaction(models.Model):
|
|
|
12
12
|
# Payment gateway choices
|
|
13
13
|
PAYME = 'payme'
|
|
14
14
|
CLICK = 'click'
|
|
15
|
+
ATMOS = 'atmos'
|
|
15
16
|
|
|
16
17
|
GATEWAY_CHOICES = [
|
|
17
18
|
(PAYME, 'Payme'),
|
|
18
19
|
(CLICK, 'Click'),
|
|
20
|
+
(ATMOS, 'Atmos'),
|
|
19
21
|
]
|
|
20
22
|
|
|
21
23
|
# Transaction states
|
|
@@ -5,7 +5,7 @@ import logging
|
|
|
5
5
|
from django.views.decorators.csrf import csrf_exempt
|
|
6
6
|
from django.utils.decorators import method_decorator
|
|
7
7
|
|
|
8
|
-
from .webhooks import PaymeWebhook, ClickWebhook
|
|
8
|
+
from .webhooks import PaymeWebhook, ClickWebhook, AtmosWebhook
|
|
9
9
|
|
|
10
10
|
logger = logging.getLogger(__name__)
|
|
11
11
|
|
|
@@ -98,3 +98,39 @@ class BaseClickWebhookView(ClickWebhook):
|
|
|
98
98
|
transaction: Transaction object
|
|
99
99
|
"""
|
|
100
100
|
logger.info(f"Click payment cancelled: {transaction.transaction_id}")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@method_decorator(csrf_exempt, name='dispatch')
|
|
104
|
+
class BaseAtmosWebhookView(AtmosWebhook):
|
|
105
|
+
"""
|
|
106
|
+
Default Atmos webhook view.
|
|
107
|
+
|
|
108
|
+
This view handles webhook requests from the Atmos payment system.
|
|
109
|
+
You can extend this class and override the event methods to customize
|
|
110
|
+
the behavior.
|
|
111
|
+
|
|
112
|
+
Example:
|
|
113
|
+
```python
|
|
114
|
+
from paytechuz.integrations.django.views import AtmosWebhookView
|
|
115
|
+
|
|
116
|
+
class CustomAtmosWebhookView(AtmosWebhookView):
|
|
117
|
+
def successfully_payment(self, params, transaction):
|
|
118
|
+
# Your custom logic here
|
|
119
|
+
print(f"Payment successful: {transaction.transaction_id}")
|
|
120
|
+
|
|
121
|
+
# Update your order status
|
|
122
|
+
order = Order.objects.get(id=transaction.account_id)
|
|
123
|
+
order.status = 'paid'
|
|
124
|
+
order.save()
|
|
125
|
+
```
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def successfully_payment(self, params, transaction):
|
|
129
|
+
"""
|
|
130
|
+
Called when a payment is successful.
|
|
131
|
+
|
|
132
|
+
Args:
|
|
133
|
+
params: Request parameters
|
|
134
|
+
transaction: Transaction object
|
|
135
|
+
"""
|
|
136
|
+
logger.info(f"Atmos payment successful: {transaction.transaction_id}")
|
|
@@ -898,3 +898,134 @@ class ClickWebhook(View):
|
|
|
898
898
|
transaction: Transaction object
|
|
899
899
|
"""
|
|
900
900
|
# This method is meant to be overridden by subclasses
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class AtmosWebhook(View):
|
|
904
|
+
"""
|
|
905
|
+
Base Atmos webhook handler for Django.
|
|
906
|
+
|
|
907
|
+
This class handles webhook requests from the Atmos payment system.
|
|
908
|
+
You can extend this class and override the event methods to customize
|
|
909
|
+
the behavior.
|
|
910
|
+
"""
|
|
911
|
+
|
|
912
|
+
def __init__(self, **kwargs):
|
|
913
|
+
super().__init__(**kwargs)
|
|
914
|
+
|
|
915
|
+
paytechuz_settings = getattr(settings, 'PAYTECHUZ', {})
|
|
916
|
+
atmos_settings = paytechuz_settings.get('ATMOS', {})
|
|
917
|
+
|
|
918
|
+
self.api_key = (
|
|
919
|
+
atmos_settings.get('API_KEY') or
|
|
920
|
+
getattr(settings, 'ATMOS_API_KEY', '')
|
|
921
|
+
)
|
|
922
|
+
|
|
923
|
+
account_model_path = (
|
|
924
|
+
atmos_settings.get('ACCOUNT_MODEL') or
|
|
925
|
+
getattr(settings, 'ATMOS_ACCOUNT_MODEL', 'django.contrib.auth.models.User')
|
|
926
|
+
)
|
|
927
|
+
try:
|
|
928
|
+
self.account_model = import_string(account_model_path)
|
|
929
|
+
except ImportError:
|
|
930
|
+
logger.error(
|
|
931
|
+
"Could not import %s. Check PAYTECHUZ.ATMOS.ACCOUNT_MODEL setting.",
|
|
932
|
+
account_model_path
|
|
933
|
+
)
|
|
934
|
+
raise ImportError(f"Import error: {account_model_path}") from None
|
|
935
|
+
|
|
936
|
+
self.account_field = (
|
|
937
|
+
atmos_settings.get('ACCOUNT_FIELD') or
|
|
938
|
+
getattr(settings, 'ATMOS_ACCOUNT_FIELD', 'id')
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
def post(self, request, **_):
|
|
942
|
+
"""
|
|
943
|
+
Handle POST requests from Atmos.
|
|
944
|
+
"""
|
|
945
|
+
try:
|
|
946
|
+
# Parse request data
|
|
947
|
+
data = json.loads(request.body.decode('utf-8'))
|
|
948
|
+
|
|
949
|
+
# Verify signature
|
|
950
|
+
received_signature = data.get('sign', '')
|
|
951
|
+
if not self._verify_signature(data, received_signature):
|
|
952
|
+
logger.error("Invalid webhook signature")
|
|
953
|
+
return JsonResponse({
|
|
954
|
+
'status': 0,
|
|
955
|
+
'message': 'Invalid signature'
|
|
956
|
+
}, status=400)
|
|
957
|
+
|
|
958
|
+
# Extract webhook data
|
|
959
|
+
store_id = data.get('store_id')
|
|
960
|
+
transaction_id = data.get('transaction_id')
|
|
961
|
+
amount = data.get('amount')
|
|
962
|
+
invoice = data.get('invoice')
|
|
963
|
+
transaction_time = data.get('transaction_time')
|
|
964
|
+
|
|
965
|
+
logger.info(f"Webhook received for transaction {transaction_id}, "
|
|
966
|
+
f"invoice {invoice}, amount {amount}")
|
|
967
|
+
|
|
968
|
+
# Find transaction by invoice (account)
|
|
969
|
+
try:
|
|
970
|
+
transaction = PaymentTransaction._default_manager.get(
|
|
971
|
+
gateway=PaymentTransaction.ATMOS,
|
|
972
|
+
account_id=invoice
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
# Update transaction with webhook data
|
|
976
|
+
transaction.transaction_id = transaction_id
|
|
977
|
+
transaction.mark_as_paid()
|
|
978
|
+
|
|
979
|
+
# Call the event method
|
|
980
|
+
self.successfully_payment(data, transaction)
|
|
981
|
+
|
|
982
|
+
return JsonResponse({
|
|
983
|
+
'status': 1,
|
|
984
|
+
'message': 'Успешно'
|
|
985
|
+
})
|
|
986
|
+
|
|
987
|
+
except PaymentTransaction.DoesNotExist:
|
|
988
|
+
logger.error(f"Transaction not found for invoice: {invoice}")
|
|
989
|
+
return JsonResponse({
|
|
990
|
+
'status': 0,
|
|
991
|
+
'message': f'Transaction not found for invoice: {invoice}'
|
|
992
|
+
}, status=400)
|
|
993
|
+
|
|
994
|
+
except Exception as e:
|
|
995
|
+
logger.exception("Unexpected error in Atmos webhook: %s", e)
|
|
996
|
+
return JsonResponse({
|
|
997
|
+
'status': 0,
|
|
998
|
+
'message': f'Error: {str(e)}'
|
|
999
|
+
}, status=500)
|
|
1000
|
+
|
|
1001
|
+
def _verify_signature(self, webhook_data, received_signature):
|
|
1002
|
+
"""
|
|
1003
|
+
Verify webhook signature from Atmos.
|
|
1004
|
+
"""
|
|
1005
|
+
# Extract data from webhook
|
|
1006
|
+
store_id = str(webhook_data.get('store_id', ''))
|
|
1007
|
+
transaction_id = str(webhook_data.get('transaction_id', ''))
|
|
1008
|
+
invoice = str(webhook_data.get('invoice', ''))
|
|
1009
|
+
amount = str(webhook_data.get('amount', ''))
|
|
1010
|
+
|
|
1011
|
+
# Create signature string: store_id+transaction_id+invoice+amount+api_key
|
|
1012
|
+
signature_string = f"{store_id}{transaction_id}{invoice}{amount}{self.api_key}"
|
|
1013
|
+
|
|
1014
|
+
# Generate MD5 hash
|
|
1015
|
+
calculated_signature = hashlib.md5(
|
|
1016
|
+
signature_string.encode('utf-8')).hexdigest()
|
|
1017
|
+
|
|
1018
|
+
# Compare signatures
|
|
1019
|
+
return calculated_signature == received_signature
|
|
1020
|
+
|
|
1021
|
+
# Event methods that can be overridden by subclasses
|
|
1022
|
+
|
|
1023
|
+
def successfully_payment(self, params, transaction):
|
|
1024
|
+
"""
|
|
1025
|
+
Called when a payment is successful.
|
|
1026
|
+
|
|
1027
|
+
Args:
|
|
1028
|
+
params: Request parameters
|
|
1029
|
+
transaction: Transaction object
|
|
1030
|
+
"""
|
|
1031
|
+
# This method is meant to be overridden by subclasses
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: paytechuz
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary: Unified Python package for Uzbekistan payment gateways
|
|
3
|
+
Version: 0.3.1
|
|
4
|
+
Summary: Unified Python package for Uzbekistan payment gateways (Payme, Click, Atmos)
|
|
5
5
|
Home-page: https://github.com/Muhammadali-Akbarov/paytechuz
|
|
6
6
|
Author: Muhammadali Akbarov
|
|
7
7
|
Author-email: muhammadali17abc@gmail.com
|
|
@@ -20,7 +20,7 @@ Dynamic: requires-python
|
|
|
20
20
|
[](https://pay-tech.uz)
|
|
21
21
|
[](https://opensource.org/licenses/MIT)
|
|
22
22
|
|
|
23
|
-
PayTechUZ is a unified payment library for integrating with popular payment systems in Uzbekistan. It provides a simple and consistent interface for working with Payme and
|
|
23
|
+
PayTechUZ is a unified payment library for integrating with popular payment systems in Uzbekistan. It provides a simple and consistent interface for working with Payme, Click, and Atmos payment gateways.
|
|
24
24
|
|
|
25
25
|
📖 **[Complete Documentation](https://pay-tech.uz)** | 🚀 **[Quick Start Guide](https://pay-tech.uz/quickstart)**
|
|
26
26
|
|
|
@@ -59,6 +59,7 @@ pip install paytechuz[fastapi]
|
|
|
59
59
|
```python
|
|
60
60
|
from paytechuz.gateways.payme import PaymeGateway
|
|
61
61
|
from paytechuz.gateways.click import ClickGateway
|
|
62
|
+
from paytechuz.gateways.atmos import AtmosGateway
|
|
62
63
|
|
|
63
64
|
# Initialize Payme gateway
|
|
64
65
|
payme = PaymeGateway(
|
|
@@ -76,6 +77,15 @@ click = ClickGateway(
|
|
|
76
77
|
is_test_mode=True # Set to False in production environment
|
|
77
78
|
)
|
|
78
79
|
|
|
80
|
+
# Initialize Atmos gateway
|
|
81
|
+
atmos = AtmosGateway(
|
|
82
|
+
consumer_key="your_consumer_key",
|
|
83
|
+
consumer_secret="your_consumer_secret",
|
|
84
|
+
store_id="your_store_id",
|
|
85
|
+
terminal_id="your_terminal_id", # optional
|
|
86
|
+
is_test_mode=True # Set to False in production environment
|
|
87
|
+
)
|
|
88
|
+
|
|
79
89
|
# Generate payment links
|
|
80
90
|
payme_link = payme.create_payment(
|
|
81
91
|
id="order_123",
|
|
@@ -89,6 +99,24 @@ click_link = click.create_payment(
|
|
|
89
99
|
description="Test payment",
|
|
90
100
|
return_url="https://example.com/return"
|
|
91
101
|
)
|
|
102
|
+
|
|
103
|
+
atmos_payment = atmos.create_payment(
|
|
104
|
+
account_id="order_123",
|
|
105
|
+
amount=150000 # amount in UZS
|
|
106
|
+
)
|
|
107
|
+
atmos_link = atmos_payment['payment_url']
|
|
108
|
+
|
|
109
|
+
# Check payment status
|
|
110
|
+
status = atmos.check_payment(atmos_payment['transaction_id'])
|
|
111
|
+
print(f"Payment status: {status['status']}")
|
|
112
|
+
|
|
113
|
+
# Cancel payment if needed
|
|
114
|
+
if status['status'] == 'pending':
|
|
115
|
+
cancel_result = atmos.cancel_payment(
|
|
116
|
+
transaction_id=atmos_payment['transaction_id'],
|
|
117
|
+
reason="Customer request"
|
|
118
|
+
)
|
|
119
|
+
print(f"Cancellation status: {cancel_result['status']}")
|
|
92
120
|
```
|
|
93
121
|
|
|
94
122
|
### Django Integration
|
|
@@ -144,6 +172,16 @@ PAYTECHUZ = {
|
|
|
144
172
|
'ACCOUNT_MODEL': 'your_app.models.Order',
|
|
145
173
|
'COMMISSION_PERCENT': 0.0,
|
|
146
174
|
'IS_TEST_MODE': True, # Set to False in production
|
|
175
|
+
},
|
|
176
|
+
'ATMOS': {
|
|
177
|
+
'CONSUMER_KEY': 'your_atmos_consumer_key',
|
|
178
|
+
'CONSUMER_SECRET': 'your_atmos_consumer_secret',
|
|
179
|
+
'STORE_ID': 'your_atmos_store_id',
|
|
180
|
+
'TERMINAL_ID': 'your_atmos_terminal_id', # Optional
|
|
181
|
+
'API_KEY': 'your_atmos_api_key'
|
|
182
|
+
'ACCOUNT_MODEL': 'your_app.models.Order',
|
|
183
|
+
'ACCOUNT_FIELD': 'id',
|
|
184
|
+
'IS_TEST_MODE': True, # Set to False in production
|
|
147
185
|
}
|
|
148
186
|
}
|
|
149
187
|
```
|
|
@@ -152,7 +190,11 @@ PAYTECHUZ = {
|
|
|
152
190
|
|
|
153
191
|
```python
|
|
154
192
|
# views.py
|
|
155
|
-
from paytechuz.integrations.django.views import
|
|
193
|
+
from paytechuz.integrations.django.views import (
|
|
194
|
+
BasePaymeWebhookView,
|
|
195
|
+
BaseClickWebhookView,
|
|
196
|
+
BaseAtmosWebhookView
|
|
197
|
+
)
|
|
156
198
|
from .models import Order
|
|
157
199
|
|
|
158
200
|
class PaymeWebhookView(BasePaymeWebhookView):
|
|
@@ -176,6 +218,17 @@ class ClickWebhookView(BaseClickWebhookView):
|
|
|
176
218
|
order = Order.objects.get(id=transaction.account_id)
|
|
177
219
|
order.status = 'cancelled'
|
|
178
220
|
order.save()
|
|
221
|
+
|
|
222
|
+
class AtmosWebhookView(BaseAtmosWebhookView):
|
|
223
|
+
def successfully_payment(self, params, transaction):
|
|
224
|
+
order = Order.objects.get(id=transaction.account_id)
|
|
225
|
+
order.status = 'paid'
|
|
226
|
+
order.save()
|
|
227
|
+
|
|
228
|
+
def cancelled_payment(self, params, transaction):
|
|
229
|
+
order = Order.objects.get(id=transaction.account_id)
|
|
230
|
+
order.status = 'cancelled'
|
|
231
|
+
order.save()
|
|
179
232
|
```
|
|
180
233
|
|
|
181
234
|
4. Add webhook URLs to `urls.py`:
|
|
@@ -184,12 +237,13 @@ class ClickWebhookView(BaseClickWebhookView):
|
|
|
184
237
|
# urls.py
|
|
185
238
|
from django.urls import path
|
|
186
239
|
from django.views.decorators.csrf import csrf_exempt
|
|
187
|
-
from .views import PaymeWebhookView, ClickWebhookView
|
|
240
|
+
from .views import PaymeWebhookView, ClickWebhookView, AtmosWebhookView
|
|
188
241
|
|
|
189
242
|
urlpatterns = [
|
|
190
243
|
# ...
|
|
191
244
|
path('payments/webhook/payme/', csrf_exempt(PaymeWebhookView.as_view()), name='payme_webhook'),
|
|
192
245
|
path('payments/webhook/click/', csrf_exempt(ClickWebhookView.as_view()), name='click_webhook'),
|
|
246
|
+
path('payments/webhook/atmos/', csrf_exempt(AtmosWebhookView.as_view()), name='atmos_webhook'),
|
|
193
247
|
]
|
|
194
248
|
```
|
|
195
249
|
|
|
@@ -243,6 +297,7 @@ from fastapi import FastAPI, Request, Depends
|
|
|
243
297
|
from sqlalchemy.orm import Session
|
|
244
298
|
|
|
245
299
|
from paytechuz.integrations.fastapi import PaymeWebhookHandler, ClickWebhookHandler
|
|
300
|
+
from paytechuz.gateways.atmos.webhook import AtmosWebhookHandler
|
|
246
301
|
|
|
247
302
|
|
|
248
303
|
app = FastAPI()
|
|
@@ -302,6 +357,39 @@ async def click_webhook(request: Request, db: Session = Depends(get_db)):
|
|
|
302
357
|
account_model=Order
|
|
303
358
|
)
|
|
304
359
|
return await handler.handle_webhook(request)
|
|
360
|
+
|
|
361
|
+
@app.post("/payments/atmos/webhook")
|
|
362
|
+
async def atmos_webhook(request: Request, db: Session = Depends(get_db)):
|
|
363
|
+
import json
|
|
364
|
+
|
|
365
|
+
# Atmos webhook handler
|
|
366
|
+
atmos_handler = AtmosWebhookHandler(api_key="your_atmos_api_key")
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
# Get request body
|
|
370
|
+
body = await request.body()
|
|
371
|
+
webhook_data = json.loads(body.decode('utf-8'))
|
|
372
|
+
|
|
373
|
+
# Process webhook
|
|
374
|
+
response = atmos_handler.handle_webhook(webhook_data)
|
|
375
|
+
|
|
376
|
+
if response['status'] == 1:
|
|
377
|
+
# Payment successful
|
|
378
|
+
invoice = webhook_data.get('invoice')
|
|
379
|
+
|
|
380
|
+
# Update order status
|
|
381
|
+
order = db.query(Order).filter(Order.id == invoice).first()
|
|
382
|
+
if order:
|
|
383
|
+
order.status = "paid"
|
|
384
|
+
db.commit()
|
|
385
|
+
|
|
386
|
+
return response
|
|
387
|
+
|
|
388
|
+
except Exception as e:
|
|
389
|
+
return {
|
|
390
|
+
'status': 0,
|
|
391
|
+
'message': f'Error: {str(e)}'
|
|
392
|
+
}
|
|
305
393
|
```
|
|
306
394
|
|
|
307
395
|
## Documentation
|
|
@@ -315,11 +403,13 @@ Detailed documentation is available in multiple languages:
|
|
|
315
403
|
|
|
316
404
|
- [Django Integration Guide](src/docs/en/django_integration.md) | [Django integratsiyasi bo'yicha qo'llanma](src/docs/django_integration.md)
|
|
317
405
|
- [FastAPI Integration Guide](src/docs/en/fastapi_integration.md) | [FastAPI integratsiyasi bo'yicha qo'llanma](src/docs/fastapi_integration.md)
|
|
406
|
+
- [Atmos Integration Guide](src/docs/en/atmos_integration.md) | [Atmos integratsiyasi bo'yicha qo'llanma](src/docs/atmos_integration.md)
|
|
318
407
|
|
|
319
408
|
## Supported Payment Systems
|
|
320
409
|
|
|
321
410
|
- **Payme** - [Official Website](https://payme.uz)
|
|
322
411
|
- **Click** - [Official Website](https://click.uz)
|
|
412
|
+
- **Atmos** - [Official Website](https://atmos.uz)
|
|
323
413
|
|
|
324
414
|
## Contributing
|
|
325
415
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
-
paytechuz/__init__.py,sha256=
|
|
1
|
+
paytechuz/__init__.py,sha256=te3Ao8uvuOI6bl9Uq1HsNfmztVZ9c_uvqi3gdODFkEE,1916
|
|
2
2
|
paytechuz/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
3
|
paytechuz/core/base.py,sha256=Es6eEGNgDjQJe-kEJVAHSAh8AWbgtIuQMm0xn7qfjl4,2549
|
|
4
|
-
paytechuz/core/constants.py,sha256=
|
|
4
|
+
paytechuz/core/constants.py,sha256=hzCy5Kc8HVSDhXxNgObbJ2e9SYcXTYL3qky_q0tlpHU,2305
|
|
5
5
|
paytechuz/core/exceptions.py,sha256=XMJkqiponTkvhjoh3S2iFNuU3UbBdFW4130kd0hpudg,5489
|
|
6
6
|
paytechuz/core/http.py,sha256=1PFv_Fo62GtfyYKUK2nsT4AaeQNuMgZlFUFL1q9p2MI,7672
|
|
7
7
|
paytechuz/core/utils.py,sha256=EbNtDweR1ABOtCu4D6cYlolM0t_fbiE3gNoc_qfcKKA,4704
|
|
8
8
|
paytechuz/core/payme/errors.py,sha256=CZE62MbYDMsRfNIX23Syt6of_tPMMGLnXhYMii4hw3A,542
|
|
9
9
|
paytechuz/gateways/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
paytechuz/gateways/atmos/__init__.py,sha256=piVcHu32K-D8I0Kr6bSXXrdtny2S5TY0hxMbyCuVuIo,91
|
|
11
|
+
paytechuz/gateways/atmos/client.py,sha256=Llo4utBVYPq1a3eLQaCtOMIzXooRhati3Eh2o6qbVdE,6212
|
|
12
|
+
paytechuz/gateways/atmos/webhook.py,sha256=bqMHipJ_GowGYuxVaZd2f8bSEzSNHavdsvpGpsqy96s,2989
|
|
10
13
|
paytechuz/gateways/click/__init__.py,sha256=35RPIrZYHgMWDzxjQkJMZYjzHDa8cY_BqQztCdZZmBM,90
|
|
11
14
|
paytechuz/gateways/click/client.py,sha256=NwsPGfXacO0tWvZCA0V9KZ9XhFV7AnyHBmtxsWAvci8,6736
|
|
12
15
|
paytechuz/gateways/click/merchant.py,sha256=tvHUwNr_eiDz_ED4-m2GNBh_LXN0b5lwtq1jw1e0zAQ,7191
|
|
@@ -20,17 +23,18 @@ paytechuz/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3
|
|
|
20
23
|
paytechuz/integrations/django/__init__.py,sha256=fNs4c2IWpCe78-_Yvgz59TdKbHiYRYDkLR33QOBf-Ok,356
|
|
21
24
|
paytechuz/integrations/django/admin.py,sha256=6fs6GiKcdc-hGlLxJ0BthY7TFo_2RVVJRhQwhxMroCY,2664
|
|
22
25
|
paytechuz/integrations/django/apps.py,sha256=Q9wG2osL7_Ip2BcAkq7lmmhu4UKJAg6UtSsSq_RgHlc,640
|
|
23
|
-
paytechuz/integrations/django/models.py,sha256=
|
|
26
|
+
paytechuz/integrations/django/models.py,sha256=EInAti6LZlf7t1kiTSceV-xQZ-hNcVe8qzem2yjOWh4,5742
|
|
24
27
|
paytechuz/integrations/django/signals.py,sha256=VtNYEAnu13wi9PqadEaCU9LY_k2tY26AS4bnPIAqw7M,1319
|
|
25
|
-
paytechuz/integrations/django/views.py,sha256=
|
|
26
|
-
paytechuz/integrations/django/webhooks.py,sha256=
|
|
28
|
+
paytechuz/integrations/django/views.py,sha256=KFiuMcr4BtMbzbrW_RbIkv0-Fk214WIWWwaStscArzs,4149
|
|
29
|
+
paytechuz/integrations/django/webhooks.py,sha256=j5f4BbOQaFniK26RTQW7KlM90KFOioCUwjBjJQCdkek,36646
|
|
27
30
|
paytechuz/integrations/django/migrations/0001_initial.py,sha256=SWHIUuwq91crzaxa9v1UK0kay8CxsjUo6t4bqg7j0Gw,1896
|
|
31
|
+
paytechuz/integrations/django/migrations/0002_alter_paymenttransaction_gateway.py,sha256=XiZx5urgfhXxra3W_KWksQ1LbaDOs3sjPn4w0T2cW50,457
|
|
28
32
|
paytechuz/integrations/django/migrations/__init__.py,sha256=KLQ5NdjOMLDS21-u3b_g08G1MjPMMhG95XI_N8m4FSo,41
|
|
29
33
|
paytechuz/integrations/fastapi/__init__.py,sha256=DLnhAZQZf2ghu8BuFFfE7FzbNKWQQ2SLG8qxldRuwR4,565
|
|
30
34
|
paytechuz/integrations/fastapi/models.py,sha256=9IqrsndIVuIDwDbijZ89biJxEWQASXRBfWVShxgerAc,5113
|
|
31
35
|
paytechuz/integrations/fastapi/routes.py,sha256=t8zbqhMZsaJmEvMDgmF-NoRmbqksfX_AvIrx-3kCjg8,37845
|
|
32
36
|
paytechuz/integrations/fastapi/schemas.py,sha256=PgRqviJiD4-u3_CIkUOX8R7L8Yqn8L44WLte7968G0E,3887
|
|
33
|
-
paytechuz-0.
|
|
34
|
-
paytechuz-0.
|
|
35
|
-
paytechuz-0.
|
|
36
|
-
paytechuz-0.
|
|
37
|
+
paytechuz-0.3.1.dist-info/METADATA,sha256=5mtAMGPdOCQ9Tophk_QBPcADUCC2Pjtv5wegmPk_5JM,13096
|
|
38
|
+
paytechuz-0.3.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
39
|
+
paytechuz-0.3.1.dist-info/top_level.txt,sha256=oloyKGNVj9Z2h3wpKG5yPyTlpdpWW0-CWr-j-asCWBc,10
|
|
40
|
+
paytechuz-0.3.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|