irgateway 0.3.2__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,7 @@
1
+ """Iranian payment gateway SDK: Zarinpal and Zibal, sync and async."""
2
+
3
+ __version__ = "0.3.2"
4
+
5
+ from ._core import (
6
+ GatewayTransportError, GatewayHTTPError, GatewayProtocolError, PaymentAmountMismatch,
7
+ )
irgateway/_core.py ADDED
@@ -0,0 +1,169 @@
1
+ """Shared transport, money and validation utilities for payment gateways.
2
+
3
+ All amounts are integral; HTTP failures never cause an implicit retry.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import asyncio
8
+ from dataclasses import fields, is_dataclass
9
+ from datetime import datetime
10
+ from typing import Any, TypeVar, get_args, get_origin, get_type_hints, Union
11
+ from urllib.parse import urlsplit
12
+
13
+
14
+ class GatewayTransportError(Exception):
15
+ """Request outcome is unknown. Reconcile via inquiry before retrying a mutation."""
16
+
17
+
18
+ class GatewayProtocolError(Exception):
19
+ """The gateway returned a malformed or unrecognized response."""
20
+
21
+
22
+ class GatewayHTTPError(GatewayTransportError):
23
+ """An unexpected HTTP status was returned by the gateway."""
24
+
25
+ def __init__(self, status: int):
26
+ self.status = status
27
+ super().__init__(f"Gateway HTTP status {status}")
28
+
29
+
30
+ class PaymentAmountMismatch(Exception):
31
+ """The verified amount does not match the locally recorded payable amount."""
32
+
33
+ def __init__(self, expected_rials: int, actual_rials: int):
34
+ self.expected_rials = expected_rials
35
+ self.actual_rials = actual_rials
36
+ super().__init__(f"Payment amount mismatch: expected {expected_rials} IRR, received {actual_rials} IRR")
37
+
38
+
39
+ def money(value: int, *, name: str = 'amount', zero: bool = False) -> int:
40
+ if isinstance(value, bool) or not isinstance(value, int) or value < (0 if zero else 1):
41
+ raise ValueError(f"{name} must be a {'non-negative' if zero else 'positive'} integer")
42
+ return value
43
+
44
+
45
+ def rial(amount: int, currency: str = 'IRT') -> int:
46
+ money(amount)
47
+ if currency not in ('IRR', 'IRT'):
48
+ raise ValueError('currency must be IRR or IRT')
49
+ return amount * (10 if currency == 'IRT' else 1)
50
+
51
+
52
+ def url(value: str, *, name: str = 'callback_url') -> str:
53
+ if not isinstance(value, str) or not value.strip() or any(c.isspace() for c in value):
54
+ raise ValueError(f'{name} must be a valid http(s) URL')
55
+ parts = urlsplit(value)
56
+ if parts.scheme not in ('https', 'http') or not parts.hostname or parts.username or parts.password:
57
+ raise ValueError(f'{name} must be a valid http(s) URL')
58
+ return value
59
+
60
+
61
+ def required_string(value: str, *, name: str) -> str:
62
+ if not isinstance(value, str) or not value.strip():
63
+ raise ValueError(f'{name} must not be blank')
64
+ return value
65
+
66
+
67
+ def code_int(value: Any) -> int | None:
68
+ if type(value) is int:
69
+ return value
70
+ if isinstance(value, str) and value.strip().lstrip('-').isdigit():
71
+ return int(value)
72
+ return None
73
+
74
+
75
+ def decode_json(data: Any) -> dict:
76
+ if not isinstance(data, dict):
77
+ raise GatewayProtocolError('Gateway response must be a JSON object')
78
+ return data
79
+
80
+
81
+ def sync_post(session: Any, endpoint: str, payload: dict, timeout: int) -> dict:
82
+ import requests
83
+ try:
84
+ response = session.post(endpoint, json=payload, timeout=timeout)
85
+ except requests.RequestException as exc:
86
+ raise GatewayTransportError('Network error; payment outcome is unknown') from exc
87
+ try:
88
+ result = decode_json(response.json())
89
+ except (ValueError, TypeError) as exc:
90
+ if not 200 <= response.status_code < 300:
91
+ raise GatewayHTTPError(response.status_code) from exc
92
+ raise GatewayProtocolError('Gateway returned invalid JSON') from exc
93
+ if not 200 <= response.status_code < 300:
94
+ # Return valid gateway error payloads to the gateway-specific parser.
95
+ if not _has_gateway_error(result):
96
+ raise GatewayHTTPError(response.status_code)
97
+ return result
98
+
99
+
100
+ async def async_post(session: Any, endpoint: str, payload: dict) -> dict:
101
+ import aiohttp
102
+ try:
103
+ async with session.post(endpoint, json=payload) as response:
104
+ try:
105
+ result = decode_json(await response.json(content_type=None))
106
+ except (ValueError, TypeError, UnicodeError) as exc:
107
+ if not 200 <= response.status < 300:
108
+ raise GatewayHTTPError(response.status) from exc
109
+ raise GatewayProtocolError('Gateway returned invalid JSON') from exc
110
+ if not 200 <= response.status < 300 and not _has_gateway_error(result):
111
+ raise GatewayHTTPError(response.status)
112
+ return result
113
+ except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as exc:
114
+ raise GatewayTransportError('Network error; payment outcome is unknown') from exc
115
+
116
+
117
+ def _has_gateway_error(result: dict) -> bool:
118
+ errors = result.get('errors')
119
+ return ((isinstance(errors, dict) and bool(errors))
120
+ or code_int(result.get('result')) not in (None, 100)
121
+ or (isinstance(result.get('data'), dict)
122
+ and code_int(result['data'].get('code')) not in (None, 100)))
123
+
124
+
125
+ T = TypeVar('T')
126
+
127
+
128
+ def dataclass_from_dict(cls: type[T], data: dict) -> T:
129
+ """Recursively deserialize supported nested dataclass/Optional/List fields."""
130
+ if not isinstance(data, dict):
131
+ raise TypeError('data must be a mapping')
132
+ hints = get_type_hints(cls)
133
+ kwargs = {}
134
+ for field in fields(cls):
135
+ if field.name in data:
136
+ kwargs[field.name] = _coerce(hints.get(field.name), data[field.name])
137
+ return cls(**kwargs)
138
+
139
+
140
+ def _coerce(hint: Any, value: Any) -> Any:
141
+ if value is None or hint is None:
142
+ return value
143
+ origin = get_origin(hint)
144
+ if origin in (Union, __import__('types').UnionType):
145
+ choices = [t for t in get_args(hint) if t is not type(None)]
146
+ return _coerce(choices[0], value) if len(choices) == 1 else value
147
+ if origin is list and isinstance(value, list):
148
+ (item_type,) = get_args(hint)
149
+ return [_coerce(item_type, item) for item in value]
150
+ if hint is datetime and isinstance(value, str):
151
+ return datetime.fromisoformat(value.replace('Z', '+00:00'))
152
+ if isinstance(hint, type) and is_dataclass(hint) and isinstance(value, dict):
153
+ return dataclass_from_dict(hint, value)
154
+ return value
155
+
156
+
157
+ def legacy_buyer_fee(amount: int, *, gateway: str) -> int:
158
+ """Deterministic reproduction of the v0.2.11 default surcharge.
159
+
160
+ Kept ONLY for backwards-compatible omission of ``buyer_fee``; unlike an
161
+ authoritative gateway quote this is not a guarantee of the actual fee.
162
+ These rational integer operations avoid floating point drift.
163
+ """
164
+ money(amount)
165
+ if gateway == 'zarinpal':
166
+ return ((min(amount, 3_200_000) + 100_000) * 28) // 5_000
167
+ if gateway == 'zibal':
168
+ return (max(200_000, min(amount, 2_000_000)) * 28) // 2_500
169
+ raise ValueError('Unsupported gateway for legacy fee calculation')
irgateway/_models.py ADDED
@@ -0,0 +1,47 @@
1
+ """Lightweight serialization helpers shared by the gateway response models."""
2
+ from __future__ import annotations
3
+ from dataclasses import asdict, is_dataclass
4
+ from datetime import datetime
5
+ import json
6
+ from ._core import dataclass_from_dict
7
+
8
+
9
+ class DictMixin:
10
+ def to_dict(self, *, include_extended: bool = False) -> dict:
11
+ def convert(value):
12
+ if isinstance(value, datetime):
13
+ return value.isoformat()
14
+ if isinstance(value, dict):
15
+ return {k: convert(v) for k, v in value.items()}
16
+ if isinstance(value, list):
17
+ return [convert(v) for v in value]
18
+ return value
19
+ if not is_dataclass(self):
20
+ raise TypeError('to_dict only works with dataclasses')
21
+ result = convert(asdict(self))
22
+ if not include_extended:
23
+ model_name = type(self).__name__
24
+ if model_name == 'PaymentRequest':
25
+ for key in ('amount', 'payable_amount', 'amount_rials', 'buyer_fee'):
26
+ result.pop(key, None)
27
+ elif model_name == 'PaymentVerification' and 'track_id' not in result and 'status' in result:
28
+ # Zibal's original PaymentVerification dictionary format.
29
+ result.pop('status', None)
30
+ result.pop('paid_at', None)
31
+ elif model_name == 'ZarinpalConfig':
32
+ result.pop('currency', None)
33
+ elif model_name == 'ZibalConfig':
34
+ for key in ('currency', 'fee_policy'):
35
+ result.pop(key, None)
36
+ return result
37
+
38
+ @classmethod
39
+ def from_dict(cls, data: dict):
40
+ return dataclass_from_dict(cls, data)
41
+
42
+ def to_json(self, indent: int | None = None, ensure_ascii: bool = False, *,
43
+ include_extended: bool = False) -> str:
44
+ return json.dumps(self.to_dict(include_extended=include_extended), indent=indent, ensure_ascii=ensure_ascii)
45
+
46
+ def __str__(self) -> str:
47
+ return self.to_json(indent=4)
@@ -0,0 +1,5 @@
1
+ """Python SDK for Zarinpal Payment Gateway with sync and async support."""
2
+
3
+ from .client import ZarinpalClient, AsyncZarinpalClient
4
+ from .types import ZarinpalConfig
5
+ from .errors import ZarinpalException
@@ -0,0 +1,394 @@
1
+ """Zarinpal v4 clients. Monetary inputs use config.currency (default IRT)."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ import re
6
+ from typing import Optional, List, Literal
7
+ from urllib.parse import urljoin
8
+
9
+ from irgateway._core import (
10
+ GatewayProtocolError, code_int, decode_json, money, required_string, rial,
11
+ sync_post, async_post, url, legacy_buyer_fee, PaymentAmountMismatch,
12
+ )
13
+ from .errors import ERROR_MAP, ZarinpalException
14
+ from .types import (
15
+ PaymentRequest, PaymentVerification, UnverifiedTransactions, UnverifiedTransaction,
16
+ PaymentMetadata, ZarinpalConfig, PaymentCartData, Wage, FeeCalculation,
17
+ )
18
+ from .enums import Environment, PaymentStatus
19
+
20
+
21
+ class BaseClient:
22
+ def __init__(self, merchant_id: str, environment: Environment = Environment.PRODUCTION,
23
+ config: Optional[ZarinpalConfig] = None):
24
+ self.merchant_id = required_string(merchant_id, name='merchant_id')
25
+ if not isinstance(environment, Environment):
26
+ raise ValueError('environment must be an Environment enum')
27
+ self.environment = environment
28
+ self.config = config if config is not None else ZarinpalConfig()
29
+ self._base_url = environment.value
30
+ self._recent_payments = {} # best-effort convenience, NOT durable transaction storage
31
+ if self.config.startpay_endpoint:
32
+ url(self.config.startpay_endpoint, name='startpay_endpoint')
33
+
34
+ def _get_endpoint(self, path: str) -> str:
35
+ return urljoin(self._base_url, path)
36
+
37
+ def _get_custom_endpoint(self, authority: str) -> str:
38
+ if not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', authority):
39
+ raise GatewayProtocolError('Invalid payment authority')
40
+ if self.config.startpay_endpoint:
41
+ return urljoin(self.config.startpay_endpoint.rstrip('/') + '/', authority)
42
+ return self._get_endpoint(f'pg/StartPay/{authority}')
43
+
44
+ @staticmethod
45
+ def _get_response(response: dict) -> tuple[int, dict]:
46
+ response = decode_json(response)
47
+ data = response.get('data')
48
+ if isinstance(data, dict) and 'code' in data:
49
+ code = code_int(data['code'])
50
+ if code is None:
51
+ raise GatewayProtocolError('Invalid Zarinpal response code')
52
+ return code, data
53
+ errors = response.get('errors')
54
+ if isinstance(errors, dict) and errors:
55
+ code = code_int(errors.get('code'))
56
+ if code is None:
57
+ # Example: {"errors": {"authority": ["Invalid authority", "-54"]}}
58
+ for value in errors.values():
59
+ if isinstance(value, list):
60
+ for item in reversed(value):
61
+ code = code_int(item)
62
+ if code is not None:
63
+ break
64
+ if code is not None:
65
+ break
66
+ if code is None:
67
+ raise GatewayProtocolError(f"Zarinpal returned validation errors: {str(errors)[:250]}")
68
+ return code, errors
69
+ raise GatewayProtocolError('Zarinpal response missing data.code or errors.code')
70
+
71
+ @staticmethod
72
+ def _handle_error(code: int, data: dict | None = None):
73
+ exception = ERROR_MAP.get(code)
74
+ if exception:
75
+ raise exception()
76
+ message = data.get('message') if isinstance(data, dict) else None
77
+ raise ZarinpalException(code, message or 'Zarinpal returned an unknown error', '')
78
+
79
+ def _check(self, response: dict, *, allow_codes: tuple[int, ...] = (100,)) -> tuple[int, dict]:
80
+ code, data = self._get_response(response)
81
+ if code not in allow_codes:
82
+ self._handle_error(code, data)
83
+ return code, data
84
+
85
+ @staticmethod
86
+ def _add_fee(amount: int) -> int:
87
+ """Legacy v0.2.11 helper; returns gross amount in caller units."""
88
+ return money(amount) + legacy_buyer_fee(amount, gateway='zarinpal')
89
+
90
+ def _fee_amount(self, buyer_fee: int | Literal['auto'] | None) -> int:
91
+ """Validate a resolved fee. Auto quotes are fetched before building payload."""
92
+ if self.config.fee_type == 'BUYER':
93
+ if buyer_fee is None:
94
+ raise ValueError('Internal error: buyer_fee must be resolved before building the payment')
95
+ return money(buyer_fee, name='buyer_fee', zero=True)
96
+ if buyer_fee is not None:
97
+ raise ValueError('buyer_fee requires config.fee_type=BUYER')
98
+ return 0
99
+
100
+ def _auto_fee_from_quote(self, amount: int, quote: FeeCalculation) -> int:
101
+ """Prefer gateway's proposed gross amount; never guess missing quotes.
102
+
103
+ A Payer fee is already billed by Zarinpal, so adding a *second* local
104
+ surcharge would double charge the customer.
105
+ """
106
+ base_rials = rial(amount, self.config.currency)
107
+ quoted_base = code_int(quote.amount)
108
+ if quoted_base != base_rials:
109
+ raise GatewayProtocolError('Zarinpal fee quote amount does not match the order')
110
+ fee_type = quote.fee_type
111
+ if not isinstance(fee_type, str):
112
+ raise GatewayProtocolError('Zarinpal fee quote missing fee_type')
113
+ normalized = fee_type.strip().lower()
114
+ if normalized == 'payer':
115
+ return 0
116
+ if normalized != 'merchant':
117
+ raise GatewayProtocolError('Unexpected Zarinpal fee_type in fee quote')
118
+ suggested = code_int(quote.suggested_amount)
119
+ if suggested is None or suggested < base_rials:
120
+ raise GatewayProtocolError('Zarinpal fee quote missing valid suggested_amount')
121
+ # Round UP to avoid undercharging when the quote is not whole toman.
122
+ unit = 10 if self.config.currency == 'IRT' else 1
123
+ payable = (suggested + unit - 1) // unit
124
+ return payable - amount
125
+
126
+ def _request_payload(self, amount: int, callback_url: str, description: str,
127
+ metadata: Optional[PaymentMetadata], cart_data: Optional[PaymentCartData],
128
+ wages: Optional[List[Wage]], buyer_fee: int | None, referrer_id: Optional[str]) -> tuple[dict, int]:
129
+ money(amount)
130
+ url(callback_url)
131
+ required_string(description, name='description')
132
+ total = amount + self._fee_amount(buyer_fee)
133
+ gateway_amount = rial(total, self.config.currency)
134
+ # Send IRR explicitly. Zarinpal verify() always accepts amount in IRR.
135
+ payload = {'merchant_id': self.merchant_id, 'amount': gateway_amount,
136
+ 'currency': 'IRR', 'callback_url': callback_url, 'description': description}
137
+ if referrer_id is not None:
138
+ payload['referrer_id'] = required_string(referrer_id, name='referrer_id')
139
+ if metadata:
140
+ payload['metadata'] = {k: v for k, v in metadata.to_dict().items() if v is not None}
141
+ if cart_data:
142
+ cart = cart_data.to_dict()
143
+ factor = 10 if self.config.currency == 'IRT' else 1
144
+ for item in cart.get('items', []):
145
+ for key in ('item_amount', 'item_amount_sum'):
146
+ if item.get(key) is not None:
147
+ money(item[key], name=key, zero=True)
148
+ item[key] *= factor
149
+ for section in ('added_costs', 'deductions'):
150
+ if cart.get(section):
151
+ for k, val in cart[section].items():
152
+ if val is not None:
153
+ money(val, name=k, zero=True)
154
+ cart[section][k] = val * factor
155
+ payload['cart_data'] = cart
156
+ if wages:
157
+ total_wages = sum(money(w.amount, name='wage.amount') for w in wages)
158
+ if total_wages > total:
159
+ raise ValueError('Sum of wages exceeds total payable amount')
160
+ payload['wages'] = [dict(w.to_dict(), amount=rial(w.amount, self.config.currency)) for w in wages]
161
+ return payload, total
162
+
163
+ def _request_result(self, response: dict, amount: int, total: int) -> PaymentRequest:
164
+ code, data = self._check(response)
165
+ authority = data.get('authority')
166
+ if not isinstance(authority, str) or not re.fullmatch(r'[A-Za-z0-9_-]{1,128}', authority):
167
+ raise GatewayProtocolError('Zarinpal response has invalid authority')
168
+ return PaymentRequest(authority=authority, code=code,
169
+ payment_url=self._get_custom_endpoint(authority),
170
+ fee_type=data.get('fee_type'), fee=data.get('fee'),
171
+ amount=amount, payable_amount=total,
172
+ amount_rials=rial(total, self.config.currency),
173
+ buyer_fee=total - amount)
174
+
175
+ def _remember_payment(self, result: PaymentRequest) -> PaymentRequest:
176
+ self._recent_payments[result.authority] = (
177
+ result.amount, result.payable_amount, result.amount_rials)
178
+ if len(self._recent_payments) > 256:
179
+ self._recent_payments.pop(next(iter(self._recent_payments)))
180
+ return result
181
+
182
+ def _verify_payload(self, authority: str, amount: int, *, amount_is_rials: bool = False) -> dict:
183
+ authority = required_string(authority, name='authority')
184
+ money(amount)
185
+ if amount_is_rials:
186
+ if authority in self._recent_payments:
187
+ expected = self._recent_payments[authority][2]
188
+ if expected != amount:
189
+ raise PaymentAmountMismatch(expected, amount)
190
+ gateway_amount = amount
191
+ elif authority in self._recent_payments:
192
+ original, payable, actual_rials = self._recent_payments[authority]
193
+ if amount not in (original, payable, actual_rials):
194
+ raise PaymentAmountMismatch(actual_rials, rial(amount, self.config.currency))
195
+ gateway_amount = actual_rials
196
+ else:
197
+ # No reliable way to infer an older order's original currency or
198
+ # surcharge without persistent order data. Retain the v0.3.x IRT
199
+ # contract, with a public explicit-IRR switch for legacy workarounds.
200
+ gateway_amount = rial(amount, self.config.currency)
201
+ return {'merchant_id': self.merchant_id,
202
+ 'authority': authority, 'amount': gateway_amount}
203
+
204
+ def _verify_result(self, response: dict, allow_already_verified: bool) -> PaymentVerification:
205
+ code, data = self._check(response, allow_codes=(100, 101) if allow_already_verified else (100,))
206
+ wages = data.get('wages')
207
+ return PaymentVerification(code=code, ref_id=data.get('ref_id'),
208
+ card_pan=data.get('card_pan'), card_hash=data.get('card_hash'),
209
+ fee_type=data.get('fee_type'), fee=data.get('fee'),
210
+ wages=[Wage.from_dict(w) for w in wages] if isinstance(wages, list) else None)
211
+
212
+ def _unverified_result(self, response: dict) -> UnverifiedTransactions:
213
+ code, data = self._check(response)
214
+ authorities = []
215
+ for item in data.get('authorities', []):
216
+ if not isinstance(item, dict):
217
+ raise GatewayProtocolError('Invalid unverified transaction')
218
+ date = item.get('date')
219
+ if isinstance(date, str):
220
+ try:
221
+ date = datetime.fromisoformat(date.replace('Z', '+00:00'))
222
+ except ValueError as exc:
223
+ raise GatewayProtocolError('Invalid transaction date') from exc
224
+ authorities.append(UnverifiedTransaction(authority=item.get('authority'),
225
+ amount=item.get('amount'),
226
+ callback_url=item.get('callback_url'),
227
+ referer=item.get('referer'), date=date))
228
+ return UnverifiedTransactions(code=code, authorities=authorities)
229
+
230
+ def _inquiry_result(self, response: dict) -> PaymentStatus:
231
+ _, data = self._check(response)
232
+ try:
233
+ return PaymentStatus(data['status'])
234
+ except (KeyError, ValueError) as exc:
235
+ raise GatewayProtocolError('Unknown Zarinpal transaction status') from exc
236
+
237
+ def _fee_result(self, response: dict) -> FeeCalculation:
238
+ _, data = self._check(response)
239
+ return FeeCalculation(amount=data.get('amount'), fee=data.get('fee'),
240
+ fee_type=data.get('fee_type'), suggested_amount=data.get('suggested_amount'))
241
+
242
+ def _payload(self, authority: str) -> dict:
243
+ return {'merchant_id': self.merchant_id, 'authority': required_string(authority, name='authority')}
244
+
245
+
246
+ class ZarinpalClient(BaseClient):
247
+ __type__ = 'SYNC'
248
+
249
+ def __init__(self, merchant_id: str, environment: Environment = Environment.PRODUCTION,
250
+ config: Optional[ZarinpalConfig] = None):
251
+ import requests
252
+ super().__init__(merchant_id, environment, config)
253
+ self._session = requests.Session()
254
+ self._session.headers.update({'Content-Type': 'application/json', 'Accept': 'application/json'})
255
+
256
+ def __enter__(self):
257
+ return self
258
+
259
+ def __exit__(self, *args):
260
+ self.close()
261
+
262
+ def close(self):
263
+ self._session.close()
264
+
265
+ def _post(self, path: str, payload: dict) -> dict:
266
+ return sync_post(self._session, self._get_endpoint(path), payload, self.config.timeout)
267
+
268
+ def request(self, amount: int, callback_url: str, description: str,
269
+ metadata: Optional[PaymentMetadata] = None, cart_data: Optional[PaymentCartData] = None,
270
+ wages: Optional[List[Wage]] = None, *, buyer_fee: int | Literal['auto'] | None = None,
271
+ referrer_id: Optional[str] = None) -> PaymentRequest:
272
+ money(amount)
273
+ url(callback_url)
274
+ required_string(description, name='description')
275
+ if self.config.fee_type == 'BUYER' and buyer_fee is None:
276
+ buyer_fee = legacy_buyer_fee(amount, gateway='zarinpal')
277
+ if buyer_fee == 'auto':
278
+ if self.config.fee_type != 'BUYER':
279
+ raise ValueError('buyer_fee requires config.fee_type=BUYER')
280
+ buyer_fee = self.calculate_buyer_fee(amount)
281
+ payload, total = self._request_payload(amount, callback_url, description, metadata, cart_data, wages, buyer_fee, referrer_id)
282
+ return self._remember_payment(self._request_result(self._post('pg/v4/payment/request.json', payload), amount, total))
283
+
284
+ def verify(self, authority: str, amount: int, *, allow_already_verified: bool = False,
285
+ amount_is_rials: bool = False) -> PaymentVerification:
286
+ return self._verify_result(self._post('pg/v4/payment/verify.json',
287
+ self._verify_payload(authority, amount,
288
+ amount_is_rials=amount_is_rials)),
289
+ allow_already_verified)
290
+
291
+ def unverified(self) -> UnverifiedTransactions:
292
+ return self._unverified_result(self._post('pg/v4/payment/unVerified.json', {'merchant_id': self.merchant_id}))
293
+
294
+ def reverse(self, authority: str) -> bool:
295
+ # Legacy contract: gateway business rejection -> False; transport errors still raise.
296
+ code, _ = self._get_response(self._post('pg/v4/payment/reverse.json', self._payload(authority)))
297
+ return code == 100
298
+
299
+ def inquiry(self, authority: str) -> PaymentStatus:
300
+ return self._inquiry_result(self._post('pg/v4/payment/inquiry.json', self._payload(authority)))
301
+
302
+ def calculate_fee(self, amount: int, *, amount_currency: str | None = None) -> FeeCalculation:
303
+ """Fee quote. Legacy direct callers supply rials by default.
304
+
305
+ Set ``amount_currency='IRT'`` to supply tomans instead.
306
+ """
307
+ payload = {'merchant_id': self.merchant_id, 'amount': rial(amount, amount_currency or 'IRR')}
308
+ if amount_currency is not None:
309
+ payload['currency'] = 'IRR'
310
+ return self._fee_result(self._post('pg/v4/payment/feeCalculation.json', payload))
311
+
312
+ def calculate_buyer_fee(self, amount: int) -> int:
313
+ """Fetch authoritative fee quote and return the needed extra amount."""
314
+ money(amount)
315
+ return self._auto_fee_from_quote(amount, self.calculate_fee(amount, amount_currency=self.config.currency))
316
+
317
+
318
+ class AsyncZarinpalClient(BaseClient):
319
+ __type__ = 'ASYNC'
320
+
321
+ def __init__(self, merchant_id: str, environment: Environment = Environment.PRODUCTION,
322
+ config: Optional[ZarinpalConfig] = None):
323
+ try:
324
+ import aiohttp # noqa: F401
325
+ except ImportError as exc:
326
+ raise ImportError('Install irgateway[async] for asynchronous clients') from exc
327
+ super().__init__(merchant_id, environment, config)
328
+ self._session = None
329
+
330
+ async def _ensure_session(self):
331
+ import aiohttp
332
+ if self._session is None or self._session.closed:
333
+ self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=self.config.timeout),
334
+ headers={'Content-Type': 'application/json', 'Accept': 'application/json'})
335
+
336
+ async def __aenter__(self):
337
+ await self._ensure_session()
338
+ return self
339
+
340
+ async def __aexit__(self, *args):
341
+ await self.close()
342
+
343
+ async def close(self):
344
+ if self._session is not None and not self._session.closed:
345
+ await self._session.close()
346
+
347
+ async def _post(self, path: str, payload: dict) -> dict:
348
+ await self._ensure_session()
349
+ return await async_post(self._session, self._get_endpoint(path), payload)
350
+
351
+ async def request(self, amount: int, callback_url: str, description: str,
352
+ metadata: Optional[PaymentMetadata] = None, cart_data: Optional[PaymentCartData] = None,
353
+ wages: Optional[List[Wage]] = None, *, buyer_fee: int | Literal['auto'] | None = None,
354
+ referrer_id: Optional[str] = None) -> PaymentRequest:
355
+ money(amount)
356
+ url(callback_url)
357
+ required_string(description, name='description')
358
+ if self.config.fee_type == 'BUYER' and buyer_fee is None:
359
+ buyer_fee = legacy_buyer_fee(amount, gateway='zarinpal')
360
+ if buyer_fee == 'auto':
361
+ if self.config.fee_type != 'BUYER':
362
+ raise ValueError('buyer_fee requires config.fee_type=BUYER')
363
+ buyer_fee = await self.calculate_buyer_fee(amount)
364
+ payload, total = self._request_payload(amount, callback_url, description, metadata, cart_data, wages, buyer_fee, referrer_id)
365
+ return self._remember_payment(self._request_result(await self._post('pg/v4/payment/request.json', payload), amount, total))
366
+
367
+ async def verify(self, authority: str, amount: int, *, allow_already_verified: bool = False,
368
+ amount_is_rials: bool = False) -> PaymentVerification:
369
+ return self._verify_result(await self._post('pg/v4/payment/verify.json',
370
+ self._verify_payload(authority, amount,
371
+ amount_is_rials=amount_is_rials)),
372
+ allow_already_verified)
373
+
374
+ async def unverified(self) -> UnverifiedTransactions:
375
+ return self._unverified_result(await self._post('pg/v4/payment/unVerified.json', {'merchant_id': self.merchant_id}))
376
+
377
+ async def reverse(self, authority: str) -> bool:
378
+ code, _ = self._get_response(await self._post('pg/v4/payment/reverse.json', self._payload(authority)))
379
+ return code == 100
380
+
381
+ async def inquiry(self, authority: str) -> PaymentStatus:
382
+ return self._inquiry_result(await self._post('pg/v4/payment/inquiry.json', self._payload(authority)))
383
+
384
+ async def calculate_fee(self, amount: int, *, amount_currency: str | None = None) -> FeeCalculation:
385
+ """Legacy direct callers supply rials by default; use amount_currency='IRT' for tomans."""
386
+ payload = {'merchant_id': self.merchant_id, 'amount': rial(amount, amount_currency or 'IRR')}
387
+ if amount_currency is not None:
388
+ payload['currency'] = 'IRR'
389
+ return self._fee_result(await self._post('pg/v4/payment/feeCalculation.json', payload))
390
+
391
+ async def calculate_buyer_fee(self, amount: int) -> int:
392
+ """Fetch authoritative fee quote and return the needed extra amount."""
393
+ money(amount)
394
+ return self._auto_fee_from_quote(amount, await self.calculate_fee(amount, amount_currency=self.config.currency))
@@ -0,0 +1,15 @@
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
+ PAID = "PAID"