sep-python-sdk 0.1.0__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.
- sep_python_sdk-0.1.0.dist-info/METADATA +604 -0
- sep_python_sdk-0.1.0.dist-info/RECORD +24 -0
- sep_python_sdk-0.1.0.dist-info/WHEEL +5 -0
- sep_python_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
- sep_python_sdk-0.1.0.dist-info/top_level.txt +1 -0
- sep_sdk/__init__.py +90 -0
- sep_sdk/_base.py +174 -0
- sep_sdk/_parsing.py +184 -0
- sep_sdk/_version.py +3 -0
- sep_sdk/async_client.py +137 -0
- sep_sdk/callback.py +52 -0
- sep_sdk/client.py +155 -0
- sep_sdk/constants.py +43 -0
- sep_sdk/endpoints.py +65 -0
- sep_sdk/enums.py +173 -0
- sep_sdk/errors.py +134 -0
- sep_sdk/py.typed +0 -0
- sep_sdk/redirect.py +82 -0
- sep_sdk/requests.py +194 -0
- sep_sdk/responses.py +113 -0
- sep_sdk/retry.py +39 -0
- sep_sdk/security.py +50 -0
- sep_sdk/testing.py +272 -0
- sep_sdk/validation.py +99 -0
sep_sdk/client.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""The synchronous client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
from sep_sdk._base import DEFAULT_TIMEOUT, ClientBase
|
|
13
|
+
from sep_sdk.endpoints import Endpoints
|
|
14
|
+
from sep_sdk.errors import SepError
|
|
15
|
+
from sep_sdk.requests import TokenRequest
|
|
16
|
+
from sep_sdk.responses import CallbackData, Token, TransactionResult
|
|
17
|
+
from sep_sdk.retry import RetryPolicy
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("sep_sdk")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class SepClient(ClientBase):
|
|
23
|
+
"""Talk to the Saman online gateway from synchronous code.
|
|
24
|
+
|
|
25
|
+
::
|
|
26
|
+
|
|
27
|
+
with SepClient(terminal_id="12345678") as sep:
|
|
28
|
+
token = sep.create_token(TokenRequest(amount=100_000, res_num="order-1", ...))
|
|
29
|
+
|
|
30
|
+
The client owns a connection pool; use it as a context manager or call
|
|
31
|
+
:meth:`close`. Pass ``http_client`` to supply your own :class:`httpx.Client`
|
|
32
|
+
(proxies, mTLS, instrumentation); it is then yours to close.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
terminal_id: str | int,
|
|
38
|
+
*,
|
|
39
|
+
endpoints: Endpoints | None = None,
|
|
40
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
41
|
+
retry: RetryPolicy | None = None,
|
|
42
|
+
endpoint_fallback: bool = True,
|
|
43
|
+
transport: httpx.BaseTransport | None = None,
|
|
44
|
+
http_client: httpx.Client | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(
|
|
47
|
+
terminal_id, endpoints=endpoints, retry=retry, endpoint_fallback=endpoint_fallback
|
|
48
|
+
)
|
|
49
|
+
self._owns_client = http_client is None
|
|
50
|
+
self._http = http_client or httpx.Client(timeout=timeout, transport=transport)
|
|
51
|
+
|
|
52
|
+
def __enter__(self) -> SepClient:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __exit__(
|
|
56
|
+
self,
|
|
57
|
+
exc_type: type[BaseException] | None,
|
|
58
|
+
exc: BaseException | None,
|
|
59
|
+
traceback: TracebackType | None,
|
|
60
|
+
) -> None:
|
|
61
|
+
self.close()
|
|
62
|
+
|
|
63
|
+
def close(self) -> None:
|
|
64
|
+
"""Release the connection pool (a client you passed in is left open)."""
|
|
65
|
+
if self._owns_client:
|
|
66
|
+
self._http.close()
|
|
67
|
+
|
|
68
|
+
# --- the four calls of the guide -------------------------------------
|
|
69
|
+
|
|
70
|
+
def create_token(self, request: TokenRequest) -> Token:
|
|
71
|
+
"""Ask for a payment token for one order (guide step 1)."""
|
|
72
|
+
response = self._post("token", self._token_payload(request))
|
|
73
|
+
return self._finish_token(request, response)
|
|
74
|
+
|
|
75
|
+
def verify_transaction(
|
|
76
|
+
self, ref_num: str, *, session_key: str | None = None
|
|
77
|
+
) -> TransactionResult:
|
|
78
|
+
"""Confirm a receipt with the gateway (guide step 9). Do this within 30 minutes."""
|
|
79
|
+
response = self._post("verify", self._reference_payload(ref_num, session_key))
|
|
80
|
+
return self._finish_transaction(response, "verify")
|
|
81
|
+
|
|
82
|
+
def reverse_transaction(
|
|
83
|
+
self, ref_num: str, *, session_key: str | None = None
|
|
84
|
+
) -> TransactionResult:
|
|
85
|
+
"""Ask for the money back, up to 50 minutes after a verified payment (guide step 10)."""
|
|
86
|
+
response = self._post("reverse", self._reference_payload(ref_num, session_key))
|
|
87
|
+
return self._finish_transaction(response, "reverse")
|
|
88
|
+
|
|
89
|
+
def verify_callback(
|
|
90
|
+
self,
|
|
91
|
+
callback: CallbackData,
|
|
92
|
+
*,
|
|
93
|
+
expected_amount: int,
|
|
94
|
+
session_key: str | None = None,
|
|
95
|
+
reverse_on_mismatch: bool = False,
|
|
96
|
+
) -> TransactionResult:
|
|
97
|
+
"""The whole safe check in one call; returns only if the money is really there.
|
|
98
|
+
|
|
99
|
+
1. refuses a callback whose ``State`` is not ``OK`` (``PaymentNotCompletedError``);
|
|
100
|
+
2. verifies the receipt with the gateway;
|
|
101
|
+
3. checks the receipt, the terminal and that the gateway took ``expected_amount``
|
|
102
|
+
rials (``AmountMismatchError`` otherwise, after an optional reversal).
|
|
103
|
+
|
|
104
|
+
``expected_amount`` must come from *your* order record, never from the request.
|
|
105
|
+
Marking the order paid, and refusing a receipt you have already used, is up to
|
|
106
|
+
you: record ``result.ref_num`` under a unique constraint.
|
|
107
|
+
"""
|
|
108
|
+
self._ready_to_verify(callback, expected_amount)
|
|
109
|
+
result = self.verify_transaction(callback.ref_num, session_key=session_key)
|
|
110
|
+
self._check_result(callback, result)
|
|
111
|
+
if result.amount != expected_amount:
|
|
112
|
+
reversed_ = False
|
|
113
|
+
if reverse_on_mismatch:
|
|
114
|
+
try:
|
|
115
|
+
self.reverse_transaction(callback.ref_num, session_key=session_key)
|
|
116
|
+
reversed_ = True
|
|
117
|
+
except SepError:
|
|
118
|
+
logger.exception("sep_sdk: could not reverse a mismatched payment")
|
|
119
|
+
raise self._amount_mismatch(expected_amount, result.amount, was_reversed=reversed_)
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
# --- transport --------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def _post(self, operation: str, payload: dict[str, Any]) -> httpx.Response:
|
|
125
|
+
attempt = 0
|
|
126
|
+
while True:
|
|
127
|
+
attempt += 1
|
|
128
|
+
url = self._url(operation)
|
|
129
|
+
logger.debug("sep_sdk: %s attempt %d", operation, attempt)
|
|
130
|
+
try:
|
|
131
|
+
response = self._http.post(url, json=payload, headers=self._headers)
|
|
132
|
+
except httpx.TransportError as error:
|
|
133
|
+
if attempt < self.retry.max_attempts and self._may_retry_exception(
|
|
134
|
+
error, operation
|
|
135
|
+
):
|
|
136
|
+
self._pause(attempt, operation)
|
|
137
|
+
continue
|
|
138
|
+
raise self._transport_error(error, operation) from error
|
|
139
|
+
if response.status_code == 404 and self._switch_to_fallback(operation):
|
|
140
|
+
attempt -= 1
|
|
141
|
+
continue
|
|
142
|
+
if attempt < self.retry.max_attempts and self._may_retry_status(
|
|
143
|
+
response.status_code, operation
|
|
144
|
+
):
|
|
145
|
+
self._pause(attempt, operation)
|
|
146
|
+
continue
|
|
147
|
+
return response
|
|
148
|
+
|
|
149
|
+
def _pause(self, attempt: int, operation: str) -> None:
|
|
150
|
+
delay = self.retry.delay(attempt)
|
|
151
|
+
logger.warning(
|
|
152
|
+
"sep_sdk: %s failed (attempt %d); retrying in %.1fs", operation, attempt, delay
|
|
153
|
+
)
|
|
154
|
+
if delay:
|
|
155
|
+
time.sleep(delay)
|
sep_sdk/constants.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Protocol constants taken from the SEP online gateway guide (v3.6).
|
|
2
|
+
|
|
3
|
+
Nothing here is secret. Anything that may change on Saman's side (hosts, paths)
|
|
4
|
+
lives in :class:`sep_sdk.Endpoints`, so it can be overridden without patching.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import Final
|
|
10
|
+
|
|
11
|
+
TOKEN_URL: Final = "https://sep.shaparak.ir/onlinepg/onlinepg"
|
|
12
|
+
FORM_ACTION_URL: Final = "https://sep.shaparak.ir/OnlinePG/OnlinePG"
|
|
13
|
+
SEND_TOKEN_URL: Final = "https://sep.shaparak.ir/OnlinePG/SendToken"
|
|
14
|
+
VERIFY_URL: Final = "https://sep.shaparak.ir/verifyTxnRandomSessionkey/ipg/VerifyTransaction"
|
|
15
|
+
REVERSE_URL: Final = "https://sep.shaparak.ir/verifyTxnRandomSessionkey/ipg/ReverseTransaction"
|
|
16
|
+
# Saman's own Postman collections spell these "Transcation". The PDF guide does not.
|
|
17
|
+
# The client tries the other spelling once if the first answers 404.
|
|
18
|
+
LEGACY_VERIFY_URL: Final = "https://sep.shaparak.ir/verifyTxnRandomSessionkey/ipg/VerifyTranscation"
|
|
19
|
+
LEGACY_REVERSE_URL: Final = (
|
|
20
|
+
"https://sep.shaparak.ir/verifyTxnRandomSessionkey/ipg/ReverseTranscation"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
DEFAULT_TOKEN_EXPIRY_MINUTES: Final = 20
|
|
24
|
+
MIN_TOKEN_EXPIRY_MINUTES: Final = 20
|
|
25
|
+
MAX_TOKEN_EXPIRY_MINUTES: Final = 3600
|
|
26
|
+
|
|
27
|
+
#: A transaction must be verified within this many minutes, or Saman reverses it.
|
|
28
|
+
VERIFY_WINDOW_MINUTES: Final = 30
|
|
29
|
+
#: A verified transaction can be reversed up to this many minutes after it happened.
|
|
30
|
+
REVERSE_WINDOW_MINUTES: Final = 50
|
|
31
|
+
|
|
32
|
+
MAX_REDIRECT_URL_LENGTH: Final = 2083
|
|
33
|
+
MAX_REDIRECT_URL_LENGTH_GET: Final = 1538
|
|
34
|
+
MAX_EXTRA_DATA_ITEMS: Final = 4
|
|
35
|
+
MAX_EXTRA_DATA_LENGTH: Final = 50
|
|
36
|
+
MAX_ALLOWED_CARDS: Final = 10
|
|
37
|
+
MAX_SETTLEMENT_ITEMS: Final = 9
|
|
38
|
+
PURCHASE_ID_LENGTH: Final = 30
|
|
39
|
+
#: The purchase id of an ordinary (non-government) settlement line in a mixed request.
|
|
40
|
+
NO_PURCHASE_ID: Final = "0" * PURCHASE_ID_LENGTH
|
|
41
|
+
|
|
42
|
+
#: Header Saman sends back on token requests for terminals moved to ``neo-pg``.
|
|
43
|
+
IPG_URL_HEADER: Final = "X-IPG-Url"
|
sep_sdk/endpoints.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Where the gateway lives. Override any URL if Saman moves it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sep_sdk import constants
|
|
8
|
+
from sep_sdk.errors import SepConfigurationError
|
|
9
|
+
from sep_sdk.validation import require_http_url
|
|
10
|
+
|
|
11
|
+
_MAX_URL_LENGTH = 2048
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True, slots=True)
|
|
15
|
+
class Endpoints:
|
|
16
|
+
"""The five URLs of the gateway, plus optional fallbacks for verify and reverse.
|
|
17
|
+
|
|
18
|
+
The defaults follow the v3.6 guide. Saman's Postman collections spell the
|
|
19
|
+
verify/reverse paths ``…Transcation``; the client therefore retries the
|
|
20
|
+
``*_fallback_url`` once when the primary answers HTTP 404, and remembers the one
|
|
21
|
+
that worked. Use :meth:`legacy` to make the old spelling the primary.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
token_url: str = constants.TOKEN_URL
|
|
25
|
+
form_action_url: str = constants.FORM_ACTION_URL
|
|
26
|
+
send_token_url: str = constants.SEND_TOKEN_URL
|
|
27
|
+
verify_url: str = constants.VERIFY_URL
|
|
28
|
+
reverse_url: str = constants.REVERSE_URL
|
|
29
|
+
verify_fallback_url: str | None = constants.LEGACY_VERIFY_URL
|
|
30
|
+
reverse_fallback_url: str | None = constants.LEGACY_REVERSE_URL
|
|
31
|
+
|
|
32
|
+
def __post_init__(self) -> None:
|
|
33
|
+
for name in ("token_url", "form_action_url", "send_token_url", "verify_url", "reverse_url"):
|
|
34
|
+
self._check(name, getattr(self, name))
|
|
35
|
+
for name in ("verify_fallback_url", "reverse_fallback_url"):
|
|
36
|
+
value = getattr(self, name)
|
|
37
|
+
if value is not None:
|
|
38
|
+
self._check(name, value)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def _check(name: str, value: object) -> None:
|
|
42
|
+
try:
|
|
43
|
+
require_http_url(name, value, max_length=_MAX_URL_LENGTH)
|
|
44
|
+
except ValueError as error:
|
|
45
|
+
raise SepConfigurationError(str(error)) from None
|
|
46
|
+
|
|
47
|
+
@classmethod
|
|
48
|
+
def production(cls) -> Endpoints:
|
|
49
|
+
"""The documented production endpoints (the default)."""
|
|
50
|
+
return cls()
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def legacy(cls) -> Endpoints:
|
|
54
|
+
"""Use the ``…Transcation`` spelling first and the documented one as fallback."""
|
|
55
|
+
return cls(
|
|
56
|
+
verify_url=constants.LEGACY_VERIFY_URL,
|
|
57
|
+
reverse_url=constants.LEGACY_REVERSE_URL,
|
|
58
|
+
verify_fallback_url=constants.VERIFY_URL,
|
|
59
|
+
reverse_fallback_url=constants.REVERSE_URL,
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def without_fallback(cls) -> Endpoints:
|
|
64
|
+
"""Production endpoints and never try another spelling."""
|
|
65
|
+
return cls(verify_fallback_url=None, reverse_fallback_url=None)
|
sep_sdk/enums.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""The vocabulary of the gateway: transaction states, status codes and result codes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum, IntEnum
|
|
6
|
+
from typing import Literal
|
|
7
|
+
|
|
8
|
+
Language = Literal["en", "fa"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TransactionState(str, Enum):
|
|
12
|
+
"""The ``State`` field Saman posts back to your ``RedirectUrl`` (guide, table 1).
|
|
13
|
+
|
|
14
|
+
Only :attr:`OK` means the bank reports a completed payment, and even that is
|
|
15
|
+
a *claim made by the user's browser*: always confirm with
|
|
16
|
+
:meth:`SepClient.verify_transaction`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
OK = "OK"
|
|
20
|
+
CANCELED_BY_USER = "CanceledByUser"
|
|
21
|
+
FAILED = "Failed"
|
|
22
|
+
SESSION_IS_NULL = "SessionIsNull"
|
|
23
|
+
INVALID_PARAMETERS = "InvalidParameters"
|
|
24
|
+
MERCHANT_IP_ADDRESS_IS_INVALID = "MerchantIpAddressIsInvalid"
|
|
25
|
+
TOKEN_NOT_FOUND = "TokenNotFound"
|
|
26
|
+
TOKEN_REQUIRED = "TokenRequired"
|
|
27
|
+
TERMINAL_NOT_FOUND = "TerminalNotFound"
|
|
28
|
+
MULTISETTLE_POLICY_ERRORS = "MultisettlePolicyErrors"
|
|
29
|
+
#: A value this version of the SDK does not know. Treated as "not paid".
|
|
30
|
+
UNKNOWN = "Unknown"
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_wire(cls, value: str | None) -> TransactionState:
|
|
34
|
+
"""Parse the wire value exactly; anything unrecognised becomes :attr:`UNKNOWN`."""
|
|
35
|
+
for member in cls:
|
|
36
|
+
if member is not cls.UNKNOWN and member.value == value:
|
|
37
|
+
return member
|
|
38
|
+
return cls.UNKNOWN
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def is_ok(self) -> bool:
|
|
42
|
+
return self is TransactionState.OK
|
|
43
|
+
|
|
44
|
+
def message(self, language: Language = "en") -> str:
|
|
45
|
+
"""A short, user-presentable explanation in English or Persian."""
|
|
46
|
+
return _STATE_MESSAGES[self][language]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class TransactionStatus(IntEnum):
|
|
50
|
+
"""The numeric ``Status`` field that accompanies :class:`TransactionState`."""
|
|
51
|
+
|
|
52
|
+
CANCELED_BY_USER = 1
|
|
53
|
+
OK = 2
|
|
54
|
+
FAILED = 3
|
|
55
|
+
SESSION_IS_NULL = 4
|
|
56
|
+
INVALID_PARAMETERS = 5
|
|
57
|
+
MERCHANT_IP_ADDRESS_IS_INVALID = 8
|
|
58
|
+
TOKEN_NOT_FOUND = 10
|
|
59
|
+
TOKEN_REQUIRED = 11
|
|
60
|
+
TERMINAL_NOT_FOUND = 12
|
|
61
|
+
MULTISETTLE_POLICY_ERRORS = 21
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def from_wire(cls, value: object) -> TransactionStatus | None:
|
|
65
|
+
"""Parse an int or numeric string; ``None`` if absent or unrecognised."""
|
|
66
|
+
try:
|
|
67
|
+
return cls(int(str(value).strip()))
|
|
68
|
+
except ValueError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ResultCode(IntEnum):
|
|
73
|
+
"""``ResultCode`` of the verify and reverse services (guide, error table)."""
|
|
74
|
+
|
|
75
|
+
SUCCESS = 0
|
|
76
|
+
DUPLICATE_REQUEST = 2
|
|
77
|
+
TRANSACTION_REVERSED = 5
|
|
78
|
+
TRANSACTION_NOT_FOUND = -2
|
|
79
|
+
VERIFY_WINDOW_EXPIRED = -6
|
|
80
|
+
TERMINAL_INACTIVE = -104
|
|
81
|
+
TERMINAL_NOT_FOUND = -105
|
|
82
|
+
IP_NOT_ALLOWED = -106
|
|
83
|
+
|
|
84
|
+
@classmethod
|
|
85
|
+
def from_wire(cls, value: int) -> ResultCode | int:
|
|
86
|
+
"""The enum member when known, otherwise the raw integer."""
|
|
87
|
+
try:
|
|
88
|
+
return cls(value)
|
|
89
|
+
except ValueError:
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
def message(self, language: Language = "en") -> str:
|
|
93
|
+
return _RESULT_MESSAGES[self][language]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_STATE_MESSAGES: dict[TransactionState, dict[Language, str]] = {
|
|
97
|
+
TransactionState.OK: {
|
|
98
|
+
"en": "The payment was completed.",
|
|
99
|
+
"fa": "پرداخت با موفقیت انجام شد.",
|
|
100
|
+
},
|
|
101
|
+
TransactionState.CANCELED_BY_USER: {
|
|
102
|
+
"en": "The customer cancelled the payment.",
|
|
103
|
+
"fa": "کاربر از پرداخت انصراف داد.",
|
|
104
|
+
},
|
|
105
|
+
TransactionState.FAILED: {
|
|
106
|
+
"en": "The payment was not completed.",
|
|
107
|
+
"fa": "پرداخت انجام نشد.",
|
|
108
|
+
},
|
|
109
|
+
TransactionState.SESSION_IS_NULL: {
|
|
110
|
+
"en": "The customer did not respond within the allowed time.",
|
|
111
|
+
"fa": "کاربر در بازهٔ زمانی تعیینشده پاسخی ارسال نکرد.",
|
|
112
|
+
},
|
|
113
|
+
TransactionState.INVALID_PARAMETERS: {
|
|
114
|
+
"en": "The parameters sent to the gateway are invalid.",
|
|
115
|
+
"fa": "پارامترهای ارسالی نامعتبر است.",
|
|
116
|
+
},
|
|
117
|
+
TransactionState.MERCHANT_IP_ADDRESS_IS_INVALID: {
|
|
118
|
+
"en": "The merchant server address is not registered with the gateway.",
|
|
119
|
+
"fa": "آدرس سرور پذیرنده نزد درگاه ثبت نشده است.",
|
|
120
|
+
},
|
|
121
|
+
TransactionState.TOKEN_NOT_FOUND: {
|
|
122
|
+
"en": "The token was not found.",
|
|
123
|
+
"fa": "توکن ارسالشده پیدا نشد.",
|
|
124
|
+
},
|
|
125
|
+
TransactionState.TOKEN_REQUIRED: {
|
|
126
|
+
"en": "This terminal accepts token-based payments only.",
|
|
127
|
+
"fa": "با این ترمینال فقط تراکنشهای توکنی قابل پرداخت هستند.",
|
|
128
|
+
},
|
|
129
|
+
TransactionState.TERMINAL_NOT_FOUND: {
|
|
130
|
+
"en": "The terminal number was not found.",
|
|
131
|
+
"fa": "شمارهٔ ترمینال ارسالشده پیدا نشد.",
|
|
132
|
+
},
|
|
133
|
+
TransactionState.MULTISETTLE_POLICY_ERRORS: {
|
|
134
|
+
"en": "The multi-account settlement rules were not respected.",
|
|
135
|
+
"fa": "محدودیتهای مدل چندحسابی رعایت نشده است.",
|
|
136
|
+
},
|
|
137
|
+
TransactionState.UNKNOWN: {
|
|
138
|
+
"en": "The gateway reported a state this SDK does not recognise.",
|
|
139
|
+
"fa": "درگاه وضعیتی گزارش کرد که این SDK نمیشناسد.",
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
_RESULT_MESSAGES: dict[ResultCode, dict[Language, str]] = {
|
|
144
|
+
ResultCode.SUCCESS: {"en": "The operation succeeded.", "fa": "عملیات با موفقیت انجام شد."},
|
|
145
|
+
ResultCode.DUPLICATE_REQUEST: {
|
|
146
|
+
"en": "This request was already processed.",
|
|
147
|
+
"fa": "این درخواست قبلاً انجام شده است.",
|
|
148
|
+
},
|
|
149
|
+
ResultCode.TRANSACTION_REVERSED: {
|
|
150
|
+
"en": "The transaction has been reversed.",
|
|
151
|
+
"fa": "تراکنش برگشت خورده است.",
|
|
152
|
+
},
|
|
153
|
+
ResultCode.TRANSACTION_NOT_FOUND: {
|
|
154
|
+
"en": "The transaction was not found.",
|
|
155
|
+
"fa": "تراکنش پیدا نشد.",
|
|
156
|
+
},
|
|
157
|
+
ResultCode.VERIFY_WINDOW_EXPIRED: {
|
|
158
|
+
"en": "More than 30 minutes have passed since the transaction.",
|
|
159
|
+
"fa": "بیش از نیم ساعت از زمان تراکنش گذشته است.",
|
|
160
|
+
},
|
|
161
|
+
ResultCode.TERMINAL_INACTIVE: {
|
|
162
|
+
"en": "The terminal is inactive.",
|
|
163
|
+
"fa": "ترمینال غیرفعال است.",
|
|
164
|
+
},
|
|
165
|
+
ResultCode.TERMINAL_NOT_FOUND: {
|
|
166
|
+
"en": "The terminal does not exist.",
|
|
167
|
+
"fa": "ترمینال در سیستم موجود نیست.",
|
|
168
|
+
},
|
|
169
|
+
ResultCode.IP_NOT_ALLOWED: {
|
|
170
|
+
"en": "The server address is not allowed to call this service.",
|
|
171
|
+
"fa": "آدرس IP درخواستدهنده مجاز نیست.",
|
|
172
|
+
},
|
|
173
|
+
}
|
sep_sdk/errors.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Every exception this SDK raises, in one hierarchy rooted at :class:`SepError`.
|
|
2
|
+
|
|
3
|
+
Catch :class:`SepError` to handle anything; catch the narrower classes to react
|
|
4
|
+
differently to "the network is down" (retry later), "the bank refused" (tell the
|
|
5
|
+
customer) and "your request was wrong" (fix your code).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from sep_sdk.enums import ResultCode, TransactionState
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SepError(Exception):
|
|
17
|
+
"""Base class of every error raised by this package."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SepConfigurationError(SepError, ValueError):
|
|
21
|
+
"""The client was configured with something unusable (terminal id, endpoints, retry)."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class SepValidationError(SepError, ValueError):
|
|
25
|
+
"""A request was rejected locally, before anything was sent to the gateway."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SepTransportError(SepError):
|
|
29
|
+
"""The request did not complete, so the gateway's answer is unknown."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SepConnectionError(SepTransportError):
|
|
33
|
+
"""The gateway could not be reached (DNS, TLS, refused or reset connection)."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class SepTimeoutError(SepTransportError):
|
|
37
|
+
"""The gateway did not answer in time."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SepHTTPError(SepError):
|
|
41
|
+
"""The gateway answered with an HTTP status this SDK cannot interpret further."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, message: str, *, status_code: int) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status_code = status_code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SepResponseError(SepError):
|
|
49
|
+
"""The gateway answered 2xx but the body was not what the guide describes."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SepAPIError(SepError):
|
|
53
|
+
"""The gateway understood the request and refused it.
|
|
54
|
+
|
|
55
|
+
``code`` is the vendor's numeric code, ``description`` its free text. The text
|
|
56
|
+
is written by the gateway: log it, but do not show it to customers verbatim.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
def __init__(
|
|
60
|
+
self,
|
|
61
|
+
message: str,
|
|
62
|
+
*,
|
|
63
|
+
code: int | None = None,
|
|
64
|
+
description: str = "",
|
|
65
|
+
operation: str = "",
|
|
66
|
+
) -> None:
|
|
67
|
+
super().__init__(message)
|
|
68
|
+
self.code = code
|
|
69
|
+
self.description = description
|
|
70
|
+
self.operation = operation
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class SepTokenError(SepAPIError):
|
|
74
|
+
"""The token request was refused (``status = -1``). See ``code`` / ``description``."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class SepTransactionError(SepAPIError):
|
|
78
|
+
"""Verify or reverse was refused. ``result_code`` is the vendor's ``ResultCode``."""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
message: str,
|
|
83
|
+
*,
|
|
84
|
+
result_code: ResultCode | int,
|
|
85
|
+
description: str = "",
|
|
86
|
+
operation: str = "",
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__(
|
|
89
|
+
message, code=int(result_code), description=description, operation=operation
|
|
90
|
+
)
|
|
91
|
+
self.result_code = result_code
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class TransactionNotFoundError(SepTransactionError):
|
|
95
|
+
"""No such receipt (``-2``): wrong ``RefNum``, or a ``TxnRandomSessionKey`` mismatch."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class VerifyWindowExpiredError(SepTransactionError):
|
|
99
|
+
"""More than 30 minutes passed (``-6``); Saman has reversed or will reverse the payment."""
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TransactionReversedError(SepTransactionError):
|
|
103
|
+
"""The transaction was reversed (``5``): the customer has been refunded."""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class TerminalError(SepTransactionError):
|
|
107
|
+
"""The terminal is unknown (``-105``) or inactive (``-104``)."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class IPNotAllowedError(SepTransactionError):
|
|
111
|
+
"""This server's IP address is not registered with Saman (``-106``)."""
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class PaymentNotCompletedError(SepError):
|
|
115
|
+
"""The callback says the customer did not pay, so there is nothing to verify."""
|
|
116
|
+
|
|
117
|
+
def __init__(self, message: str, *, state: TransactionState) -> None:
|
|
118
|
+
super().__init__(message)
|
|
119
|
+
self.state = state
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class AmountMismatchError(SepError):
|
|
123
|
+
"""The gateway took a different sum than the order was priced at.
|
|
124
|
+
|
|
125
|
+
Saman's guide: the whole amount must go back to the customer and the goods
|
|
126
|
+
must not be delivered. ``reversed`` tells you whether the SDK already did the
|
|
127
|
+
reversal (``reverse_on_mismatch=True``) and it succeeded.
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __init__(self, message: str, *, expected: int, actual: int, was_reversed: bool) -> None:
|
|
131
|
+
super().__init__(message)
|
|
132
|
+
self.expected = expected
|
|
133
|
+
self.actual = actual
|
|
134
|
+
self.reversed = was_reversed
|
sep_sdk/py.typed
ADDED
|
File without changes
|
sep_sdk/redirect.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Sending the customer to the bank.
|
|
2
|
+
|
|
3
|
+
Saman refuses a visitor whose browser does not send a ``Referer`` from the
|
|
4
|
+
merchant's own site. The form below is meant to be *served by your site*. Do not
|
|
5
|
+
send ``Referrer-Policy: no-referrer`` on that page; the form sets
|
|
6
|
+
``<meta name="referrer" content="origin">`` itself so that ``origin`` is used.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from html import escape
|
|
12
|
+
from urllib.parse import urlencode
|
|
13
|
+
|
|
14
|
+
from sep_sdk.constants import SEND_TOKEN_URL
|
|
15
|
+
from sep_sdk.enums import Language
|
|
16
|
+
from sep_sdk.responses import Token
|
|
17
|
+
|
|
18
|
+
_TEXT: dict[Language, dict[str, str]] = {
|
|
19
|
+
"en": {
|
|
20
|
+
"title": "Redirecting to the payment gateway",
|
|
21
|
+
"message": "Please wait while we take you to the bank…",
|
|
22
|
+
"button": "Continue to the bank",
|
|
23
|
+
"dir": "ltr",
|
|
24
|
+
},
|
|
25
|
+
"fa": {
|
|
26
|
+
"title": "انتقال به درگاه پرداخت",
|
|
27
|
+
"message": "لطفاً چند لحظه صبر کنید؛ در حال انتقال به درگاه بانک هستیم…",
|
|
28
|
+
"button": "ادامه به درگاه بانک",
|
|
29
|
+
"dir": "rtl",
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build_send_token_url(token: str, *, base_url: str = SEND_TOKEN_URL) -> str:
|
|
35
|
+
"""The classic ``GET`` redirect link. Not available on ``neo-pg`` terminals."""
|
|
36
|
+
return f"{base_url}?{urlencode({'token': token})}"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def redirect_form_fields(token: Token) -> dict[str, str]:
|
|
40
|
+
"""The hidden inputs of the hand-off form, for use in your own template."""
|
|
41
|
+
fields = {"Token": token.token}
|
|
42
|
+
if token.get_method:
|
|
43
|
+
fields["GetMethod"] = "true"
|
|
44
|
+
return fields
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_redirect_form(
|
|
48
|
+
token: Token,
|
|
49
|
+
*,
|
|
50
|
+
language: Language = "en",
|
|
51
|
+
nonce: str | None = None,
|
|
52
|
+
auto_submit: bool = True,
|
|
53
|
+
) -> str:
|
|
54
|
+
"""A complete HTML page that POSTs the customer to the bank.
|
|
55
|
+
|
|
56
|
+
``nonce`` is added to the inline script when your Content-Security-Policy uses
|
|
57
|
+
one. With ``auto_submit=False`` (or JavaScript disabled) the customer gets a
|
|
58
|
+
button instead. Every value is HTML-escaped.
|
|
59
|
+
"""
|
|
60
|
+
text = _TEXT[language]
|
|
61
|
+
inputs = "".join(
|
|
62
|
+
f'<input type="hidden" name="{escape(name)}" value="{escape(value)}">'
|
|
63
|
+
for name, value in redirect_form_fields(token).items()
|
|
64
|
+
)
|
|
65
|
+
script = ""
|
|
66
|
+
if auto_submit:
|
|
67
|
+
nonce_attribute = f' nonce="{escape(nonce, quote=True)}"' if nonce else ""
|
|
68
|
+
script = (
|
|
69
|
+
f'<script{nonce_attribute}>document.getElementById("sep-redirect").submit();</script>'
|
|
70
|
+
)
|
|
71
|
+
return (
|
|
72
|
+
f'<!doctype html><html lang="{language}" dir="{text["dir"]}"><head>'
|
|
73
|
+
'<meta charset="utf-8">'
|
|
74
|
+
'<meta name="viewport" content="width=device-width, initial-scale=1">'
|
|
75
|
+
'<meta name="referrer" content="origin">'
|
|
76
|
+
'<meta name="robots" content="noindex, nofollow">'
|
|
77
|
+
f"<title>{escape(text['title'])}</title></head><body>"
|
|
78
|
+
f"<p>{escape(text['message'])}</p>"
|
|
79
|
+
f'<form id="sep-redirect" method="post" action="{escape(token.form_action, quote=True)}">'
|
|
80
|
+
f'{inputs}<noscript><button type="submit">{escape(text["button"])}</button></noscript>'
|
|
81
|
+
f"</form>{script}</body></html>"
|
|
82
|
+
)
|