irgateway 0.1.0__py2.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.
irgateway/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Python SDK for Zarinpal Payment Gateway with sync and async support."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ """Python SDK for Zarinpal Payment Gateway with sync and async support."""
2
+
3
+ from irgateway.zarinpal.client import ZarinpalClient, AsyncZarinpalClient
4
+ from irgateway.zarinpal.types import ZarinpalConfig
@@ -0,0 +1,556 @@
1
+ from typing import Optional, List, Dict, Tuple
2
+ from datetime import datetime
3
+ from urllib.parse import urljoin
4
+
5
+ from irgateway.zarinpal.errors import ERROR_MAP, ZarinpalException
6
+ from irgateway.zarinpal.types import (
7
+ PaymentRequest, PaymentVerification,
8
+ UnverifiedTransactions, UnverifiedTransaction,
9
+ PaymentMetadata, ZarinpalConfig, PaymentCartData, Wage, FeeCalculation
10
+ )
11
+ from irgateway.zarinpal.enums import Environment, Currency, PaymentStatus
12
+
13
+ try:
14
+ import requests
15
+
16
+ REQUESTS_AVAILABLE = True
17
+ except ImportError:
18
+ REQUESTS_AVAILABLE = False
19
+
20
+ try:
21
+ import aiohttp
22
+
23
+ AIOHTTP_AVAILABLE = True
24
+ except ImportError:
25
+ AIOHTTP_AVAILABLE = False
26
+
27
+
28
+ class BaseClient:
29
+ """Base client containing shared logic between sync and async clients."""
30
+
31
+ def __init__(
32
+ self,
33
+ merchant_id: str,
34
+ environment: Environment = Environment.PRODUCTION,
35
+ config: Optional[ZarinpalConfig] = None,
36
+ ):
37
+ """
38
+ Initialize base client.
39
+
40
+ Args:
41
+ merchant_id: Zarinpal merchant ID (UUID format)
42
+ environment: Payment environment (PRODUCTION or SANDBOX)
43
+ config: Optional configuration override
44
+ """
45
+ self.merchant_id = merchant_id
46
+ self.environment = environment
47
+ self.config = config or ZarinpalConfig()
48
+ self._base_url = environment.value
49
+
50
+ def _get_endpoint(self, path: str) -> str:
51
+ """Construct full API endpoint URL."""
52
+ return urljoin(self._base_url, path)
53
+
54
+ def _get_custom_endpoint(self, authority: str) -> str:
55
+ """Get payment gateway start URL (supports custom StartPay endpoint)."""
56
+ if self.config.startpay_endpoint:
57
+ return urljoin(self.config.startpay_endpoint, authority)
58
+ return urljoin(self._base_url, f"pg/StartPay/{authority}")
59
+
60
+ @staticmethod
61
+ def _get_response(response: dict) -> Tuple[Optional[int], dict]:
62
+ """Normalize API response structure."""
63
+ if response.get('data'):
64
+ data = response['data']
65
+ code = data.get('code')
66
+ elif response.get('errors'):
67
+ data = response['errors']
68
+ code = data.get('code')
69
+ else:
70
+ data = {}
71
+ code = None
72
+ return code, data
73
+
74
+ @staticmethod
75
+ def _handle_error(code: Optional[int], message: str = None):
76
+ """Raise appropriate exception based on error code."""
77
+ if code and code in ERROR_MAP:
78
+ raise ERROR_MAP[code]()
79
+ raise ZarinpalException(code, message or "Unknown error", "")
80
+
81
+ @staticmethod
82
+ def _parse_unverified(data: Dict) -> UnverifiedTransactions:
83
+ """Parse unverified transactions response into structured object."""
84
+ authorities = []
85
+ for auth in data.get("authorities", []):
86
+ authorities.append(UnverifiedTransaction(
87
+ authority=auth.get("authority"),
88
+ amount=auth.get("amount"),
89
+ callback_url=auth.get("callback_url"),
90
+ referer=auth.get("referer"),
91
+ date=datetime.strptime(auth["date"], "%Y-%m-%d %H:%M:%S") if auth.get("date") else None
92
+ ))
93
+ return UnverifiedTransactions(
94
+ code=data.get("code", 100),
95
+ authorities=authorities
96
+ )
97
+
98
+
99
+ class ZarinpalClient(BaseClient):
100
+ """Synchronous client for Zarinpal Payment Gateway."""
101
+ __type__ = 'SYNC'
102
+
103
+ def __init__(
104
+ self,
105
+ merchant_id: str,
106
+ environment: Environment = Environment.PRODUCTION,
107
+ config: Optional[ZarinpalConfig] = None,
108
+ ):
109
+ """
110
+ Initialize synchronous Zarinpal client.
111
+
112
+ Args:
113
+ merchant_id: Your Zarinpal merchant ID
114
+ environment: Use SANDBOX for testing
115
+ config: Custom configuration (timeout, custom StartPay, etc.)
116
+
117
+ Raises:
118
+ ImportError: If `requests` library is not installed
119
+ """
120
+ if not REQUESTS_AVAILABLE:
121
+ raise ImportError("Install 'requests' to use synchronous client: pip install requests")
122
+
123
+ super().__init__(merchant_id, environment, config)
124
+ self._session = requests.Session()
125
+ self._session.headers.update({
126
+ "Content-Type": "application/json",
127
+ "Accept": "application/json"
128
+ })
129
+
130
+ def __enter__(self):
131
+ return self
132
+
133
+ def __exit__(self, *args):
134
+ self.close()
135
+
136
+ def close(self):
137
+ """Close underlying HTTP session."""
138
+ self._session.close()
139
+
140
+ def request(
141
+ self,
142
+ amount: int,
143
+ callback_url: str,
144
+ description: str,
145
+ currency: Currency = Currency.RIAL,
146
+ metadata: Optional[PaymentMetadata] = None,
147
+ cart_data: Optional[PaymentCartData] = None,
148
+ wages: Optional[List[Wage]] = None,
149
+ ) -> PaymentRequest:
150
+ """
151
+ Create a new payment request.
152
+
153
+ Args:
154
+ amount: Amount in Rial/Toman (min 1000 IRT or 10000 IRR)
155
+ callback_url: URL user returns to after payment
156
+ description: Payment description shown to user
157
+ currency: Currency.RIAL (Toman) or Currency.TOMAN (Rial)
158
+ metadata: Mobile/email or custom metadata
159
+ cart_data: Structured cart information
160
+ wages: List of IBANs and amounts for shared settlement
161
+
162
+ Returns:
163
+ PaymentRequest with authority and payment URL
164
+ """
165
+ payload = {
166
+ "merchant_id": self.merchant_id,
167
+ "amount": amount,
168
+ "callback_url": callback_url,
169
+ "description": description,
170
+ "currency": currency.value,
171
+ }
172
+
173
+ if metadata:
174
+ payload["metadata"] = metadata.to_dict()
175
+ if cart_data:
176
+ payload["cart_data"] = cart_data.to_dict()
177
+ if wages:
178
+ payload["wages"] = [w.to_dict() for w in wages]
179
+
180
+ response = self._session.post(
181
+ self._get_endpoint("pg/v4/payment/request.json"),
182
+ json=payload,
183
+ timeout=self.config.timeout
184
+ )
185
+ code, data = self._get_response(response.json())
186
+
187
+ if code != 100:
188
+ self._handle_error(code)
189
+
190
+ payment_url = self._get_custom_endpoint(data["authority"])
191
+
192
+ return PaymentRequest(
193
+ authority=data["authority"],
194
+ code=code,
195
+ payment_url=payment_url,
196
+ fee_type=data.get("fee_type"),
197
+ fee=data.get("fee")
198
+ )
199
+
200
+ def verify(self, authority: str, amount: int) -> PaymentVerification:
201
+ """Verify payment status after user returns from bank."""
202
+ payload = {
203
+ "merchant_id": self.merchant_id,
204
+ "authority": authority,
205
+ "amount": amount
206
+ }
207
+
208
+ response = self._session.post(
209
+ self._get_endpoint("pg/v4/payment/verify.json"),
210
+ json=payload,
211
+ timeout=self.config.timeout
212
+ )
213
+ code, data = self._get_response(response.json())
214
+
215
+ if code != 100:
216
+ self._handle_error(code)
217
+
218
+ return PaymentVerification(
219
+ code=code,
220
+ ref_id=data.get("ref_id"),
221
+ card_pan=data.get("card_pan"),
222
+ card_hash=data.get("card_hash"),
223
+ fee_type=data.get("fee_type"),
224
+ fee=data.get("fee"),
225
+ wages=[Wage(**w) for w in data.get("wages", [])] if data.get("wages") else None,
226
+ )
227
+
228
+ def unverified(self) -> UnverifiedTransactions:
229
+ """Retrieve list of successful but unverified transactions."""
230
+ payload = {"merchant_id": self.merchant_id}
231
+
232
+ response = self._session.post(
233
+ self._get_endpoint("pg/v4/payment/unVerified.json"),
234
+ json=payload,
235
+ timeout=self.config.timeout
236
+ )
237
+ code, data = self._get_response(response.json())
238
+
239
+ if code != 100:
240
+ self._handle_error(code)
241
+
242
+ return self._parse_unverified(data)
243
+
244
+ def reverse(self, authority: str) -> bool:
245
+ """Request refund (requires special permission and access token)."""
246
+ payload = {"merchant_id": self.merchant_id, "authority": authority}
247
+
248
+ response = self._session.post(
249
+ self._get_endpoint("pg/v4/payment/reverse.json"),
250
+ json=payload,
251
+ timeout=self.config.timeout
252
+ )
253
+ code, _ = self._get_response(response.json())
254
+ return code == 100
255
+
256
+ def inquiry(self, authority: str) -> PaymentStatus:
257
+ """Check current status of a payment authority."""
258
+ payload = {"merchant_id": self.merchant_id, "authority": authority}
259
+
260
+ response = self._session.post(
261
+ self._get_endpoint("pg/v4/payment/inquiry.json"),
262
+ json=payload,
263
+ timeout=self.config.timeout
264
+ )
265
+ code, data = self._get_response(response.json())
266
+
267
+ if code != 100:
268
+ self._handle_error(code)
269
+
270
+ return PaymentStatus(data.get("status"))
271
+
272
+ def calculate_fee(self, amount: int, currency: Currency = Currency.RIAL) -> FeeCalculation:
273
+ """Calculate gateway fee for a specific amount and authority."""
274
+ payload = {
275
+ "merchant_id": self.merchant_id,
276
+ "amount": amount,
277
+ "currency": currency.value,
278
+ }
279
+
280
+ response = self._session.post(
281
+ self._get_endpoint("pg/v4/payment/feeCalculation.json"),
282
+ json=payload,
283
+ timeout=self.config.timeout
284
+ )
285
+ code, data = self._get_response(response.json())
286
+
287
+ if code != 100:
288
+ self._handle_error(code)
289
+
290
+ return FeeCalculation(
291
+ amount=data.get("amount"),
292
+ fee=data.get("fee"),
293
+ fee_type=data.get("fee_type"),
294
+ suggested_amount=data.get("suggested_amount"),
295
+ )
296
+
297
+
298
+ class AsyncZarinpalClient(BaseClient):
299
+ """Asynchronous client for Zarinpal Payment Gateway (fully compatible with sync version)."""
300
+ __type__ = 'ASYNC'
301
+
302
+ def __init__(
303
+ self,
304
+ merchant_id: str,
305
+ environment: Environment = Environment.PRODUCTION,
306
+ config: Optional[ZarinpalConfig] = None,
307
+ ):
308
+ """
309
+ Initialize asynchronous client.
310
+
311
+ Args:
312
+ merchant_id: Zarinpal merchant ID
313
+ environment: SANDBOX or PRODUCTION
314
+ config: Optional config (timeout, custom StartPay URL, etc.)
315
+
316
+ Raises:
317
+ ImportError: If aiohttp is not installed
318
+ """
319
+ if not AIOHTTP_AVAILABLE:
320
+ raise ImportError("Install 'aiohttp' to use async client: pip install aiohttp")
321
+
322
+ super().__init__(merchant_id, environment, config)
323
+ self._session: Optional[aiohttp.ClientSession] = None
324
+
325
+ async def _ensure_session(self):
326
+ if self._session is None or self._session.closed:
327
+ timeout = aiohttp.ClientTimeout(total=self.config.timeout)
328
+ self._session = aiohttp.ClientSession(
329
+ timeout=timeout,
330
+ headers={"Content-Type": "application/json", "Accept": "application/json"}
331
+ )
332
+
333
+ async def __aenter__(self):
334
+ await self._ensure_session()
335
+ return self
336
+
337
+ async def __aexit__(self, *args):
338
+ await self.close()
339
+
340
+ async def close(self):
341
+ """Close the underlying aiohttp session."""
342
+ if self._session and not self._session.closed:
343
+ await self._session.close()
344
+
345
+ async def request(
346
+ self,
347
+ amount: int,
348
+ callback_url: str,
349
+ description: str,
350
+ currency: Currency = Currency.RIAL,
351
+ metadata: Optional[PaymentMetadata] = None,
352
+ cart_data: Optional[PaymentCartData] = None,
353
+ wages: Optional[List[Wage]] = None,
354
+ ) -> PaymentRequest:
355
+ """
356
+ Create payment request (async) - identical behavior to sync version.
357
+ """
358
+ await self._ensure_session()
359
+
360
+ payload = {
361
+ "merchant_id": self.merchant_id,
362
+ "amount": amount,
363
+ "callback_url": callback_url,
364
+ "description": description,
365
+ "currency": currency.value,
366
+ }
367
+
368
+ if metadata:
369
+ payload["metadata"] = metadata.to_dict()
370
+ if cart_data:
371
+ payload["cart_data"] = cart_data.to_dict()
372
+ if wages:
373
+ payload["wages"] = [w.to_dict() for w in wages]
374
+
375
+ async with self._session.post(
376
+ self._get_endpoint("pg/v4/payment/request.json"),
377
+ json=payload
378
+ ) as resp:
379
+ result = await resp.json()
380
+ code, data = self._get_response(result)
381
+
382
+ if code != 100:
383
+ self._handle_error(code)
384
+
385
+ payment_url = self._get_custom_endpoint(data["authority"])
386
+
387
+ return PaymentRequest(
388
+ authority=data["authority"],
389
+ code=code,
390
+ payment_url=payment_url,
391
+ fee_type=data.get("fee_type"),
392
+ fee=data.get("fee")
393
+ )
394
+
395
+ async def verify(self, authority: str, amount: int) -> PaymentVerification:
396
+ """Verify payment after redirect (async)."""
397
+ await self._ensure_session()
398
+
399
+ payload = {
400
+ "merchant_id": self.merchant_id,
401
+ "authority": authority,
402
+ "amount": amount
403
+ }
404
+
405
+ async with self._session.post(
406
+ self._get_endpoint("pg/v4/payment/verify.json"),
407
+ json=payload
408
+ ) as resp:
409
+ result = await resp.json()
410
+ code, data = self._get_response(result)
411
+
412
+ if code != 100:
413
+ self._handle_error(code)
414
+
415
+ return PaymentVerification(
416
+ code=code,
417
+ ref_id=data.get("ref_id"),
418
+ card_pan=data.get("card_pan"),
419
+ card_hash=data.get("card_hash"),
420
+ fee_type=data.get("fee_type"),
421
+ fee=data.get("fee"),
422
+ wages=[Wage(**w) for w in data.get("wages", [])] if data.get("wages") else None,
423
+ )
424
+
425
+ async def unverified(self) -> UnverifiedTransactions:
426
+ """Get list of unverified transactions (async)."""
427
+ await self._ensure_session()
428
+
429
+ payload = {"merchant_id": self.merchant_id}
430
+
431
+ async with self._session.post(
432
+ self._get_endpoint("pg/v4/payment/unVerified.json"),
433
+ json=payload
434
+ ) as resp:
435
+ result = await resp.json()
436
+ code, data = self._get_response(result)
437
+
438
+ if code != 100:
439
+ self._handle_error(code)
440
+
441
+ return self._parse_unverified(data)
442
+
443
+ async def reverse(
444
+ self,
445
+ authority: str,
446
+ ) -> bool:
447
+ """
448
+ Request a full refund for a successful payment (reverse transaction).
449
+
450
+ Note:
451
+ - Requires "Refund Permission" enabled on your Zarinpal merchant panel
452
+ - Uses `reverse.json` endpoint (no access token needed)
453
+ - Only works on successfully verified payments
454
+
455
+ Args:
456
+ authority: The payment authority (from request or verify step)
457
+
458
+ Returns:
459
+ bool: True if refund was successfully requested
460
+
461
+ Raises:
462
+ ZarinpalException: With appropriate error code (e.g., -11: already refunded, etc.)
463
+ """
464
+ await self._ensure_session()
465
+
466
+ payload = {
467
+ "merchant_id": self.merchant_id,
468
+ "authority": authority
469
+ }
470
+
471
+ async with self._session.post(
472
+ self._get_endpoint("pg/v4/payment/reverse.json"),
473
+ json=payload
474
+ ) as resp:
475
+ result = await resp.json()
476
+ code, _ = self._get_response(result)
477
+ return code == 100
478
+
479
+ async def inquiry(self, authority: str) -> PaymentStatus:
480
+ """
481
+ Check the current status of a payment using its authority.
482
+
483
+ Useful for:
484
+ - Re-checking payment status after network issues
485
+ - Syncing offline payments
486
+ - Debugging callback failures
487
+
488
+ Args:
489
+ authority: Payment authority string
490
+
491
+ Returns:
492
+ PaymentStatus enum (e.g., PaymentStatus.SUCCESS, PaymentStatus.PENDING, etc.)
493
+ """
494
+ await self._ensure_session()
495
+
496
+ payload = {
497
+ "merchant_id": self.merchant_id,
498
+ "authority": authority
499
+ }
500
+
501
+ async with self._session.post(
502
+ self._get_endpoint("pg/v4/payment/inquiry.json"),
503
+ json=payload
504
+ ) as resp:
505
+ result = await resp.json()
506
+ code, data = self._get_response(result)
507
+
508
+ if code != 100:
509
+ self._handle_error(code)
510
+
511
+ status_code = data.get("status")
512
+ return PaymentStatus(status_code)
513
+
514
+ async def calculate_fee(
515
+ self,
516
+ amount: int,
517
+ currency: Currency = Currency.RIAL
518
+ ) -> FeeCalculation:
519
+ """
520
+ Calculate Zarinpal gateway fee for a given amount and authority.
521
+
522
+ This is useful when you want to:
523
+ - Show exact fee to user before payment
524
+ - Adjust final amount to cover fee (e.g., user pays X, you receive X)
525
+
526
+ Args:
527
+ amount: Amount to calculate fee for
528
+ currency: Currency.RIAL (Toman) or Currency.TOMAN (Rial)
529
+
530
+ Returns:
531
+ FeeCalculation object with fee, suggested_amount, etc.
532
+ """
533
+ await self._ensure_session()
534
+
535
+ payload = {
536
+ "merchant_id": self.merchant_id,
537
+ "amount": amount,
538
+ "currency": currency.value,
539
+ }
540
+
541
+ async with self._session.post(
542
+ self._get_endpoint("pg/v4/payment/feeCalculation.json"),
543
+ json=payload
544
+ ) as resp:
545
+ result = await resp.json()
546
+ code, data = self._get_response(result)
547
+
548
+ if code != 100:
549
+ self._handle_error(code)
550
+
551
+ return FeeCalculation(
552
+ amount=data.get("amount"),
553
+ fee=data.get("fee"),
554
+ fee_type=data.get("fee_type"),
555
+ suggested_amount=data.get("suggested_amount"),
556
+ )
@@ -0,0 +1,19 @@
1
+ from enum import Enum
2
+
3
+
4
+ class Environment(Enum):
5
+ SANDBOX = "https://sandbox.zarinpal.com/"
6
+ PRODUCTION = "https://payment.zarinpal.com/"
7
+
8
+
9
+ class PaymentStatus(Enum):
10
+ PENDING = "PENDING"
11
+ VERIFIED = "VERIFIED"
12
+ FAILED = "FAILED"
13
+ REVERSED = "REVERSED"
14
+ IN_BANK = "IN_BANK"
15
+
16
+
17
+ class Currency(Enum):
18
+ TOMAN = "IRT"
19
+ RIAL = "IRR"