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 +7 -0
- irgateway/_core.py +169 -0
- irgateway/_models.py +47 -0
- irgateway/zarinpal/__init__.py +5 -0
- irgateway/zarinpal/client.py +394 -0
- irgateway/zarinpal/enums.py +15 -0
- irgateway/zarinpal/errors.py +348 -0
- irgateway/zarinpal/types.py +435 -0
- irgateway/zibal/__init__.py +7 -0
- irgateway/zibal/client.py +223 -0
- irgateway/zibal/errors.py +138 -0
- irgateway/zibal/fees.py +45 -0
- irgateway/zibal/types.py +113 -0
- irgateway-0.3.2.dist-info/METADATA +336 -0
- irgateway-0.3.2.dist-info/RECORD +17 -0
- irgateway-0.3.2.dist-info/WHEEL +4 -0
- irgateway-0.3.2.dist-info/licenses/LICENSE +21 -0
irgateway/__init__.py
ADDED
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,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"
|