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/__init__.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""sep-python-sdk: an unofficial, typed Python SDK for the Saman Electronic Payment gateway.
|
|
2
|
+
|
|
3
|
+
Not affiliated with or endorsed by Saman Electronic Payment (SEP).
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
|
|
10
|
+
from sep_sdk._version import __version__
|
|
11
|
+
from sep_sdk.async_client import AsyncSepClient
|
|
12
|
+
from sep_sdk.callback import parse_callback
|
|
13
|
+
from sep_sdk.client import SepClient
|
|
14
|
+
from sep_sdk.constants import NO_PURCHASE_ID
|
|
15
|
+
from sep_sdk.endpoints import Endpoints
|
|
16
|
+
from sep_sdk.enums import Language, ResultCode, TransactionState, TransactionStatus
|
|
17
|
+
from sep_sdk.errors import (
|
|
18
|
+
AmountMismatchError,
|
|
19
|
+
IPNotAllowedError,
|
|
20
|
+
PaymentNotCompletedError,
|
|
21
|
+
SepAPIError,
|
|
22
|
+
SepConfigurationError,
|
|
23
|
+
SepConnectionError,
|
|
24
|
+
SepError,
|
|
25
|
+
SepHTTPError,
|
|
26
|
+
SepResponseError,
|
|
27
|
+
SepTimeoutError,
|
|
28
|
+
SepTokenError,
|
|
29
|
+
SepTransactionError,
|
|
30
|
+
SepTransportError,
|
|
31
|
+
SepValidationError,
|
|
32
|
+
TerminalError,
|
|
33
|
+
TransactionNotFoundError,
|
|
34
|
+
TransactionReversedError,
|
|
35
|
+
VerifyWindowExpiredError,
|
|
36
|
+
)
|
|
37
|
+
from sep_sdk.redirect import build_send_token_url, redirect_form_fields, render_redirect_form
|
|
38
|
+
from sep_sdk.requests import CallbackMethod, SettlementItem, TokenRequest
|
|
39
|
+
from sep_sdk.responses import CallbackData, Token, TransactionDetail, TransactionResult
|
|
40
|
+
from sep_sdk.retry import RetryPolicy
|
|
41
|
+
from sep_sdk.security import derive_session_key, generate_session_key, hash_card_number
|
|
42
|
+
from sep_sdk.validation import iban_checksum_ok
|
|
43
|
+
|
|
44
|
+
logging.getLogger("sep_sdk").addHandler(logging.NullHandler())
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"NO_PURCHASE_ID",
|
|
48
|
+
"AmountMismatchError",
|
|
49
|
+
"AsyncSepClient",
|
|
50
|
+
"CallbackData",
|
|
51
|
+
"CallbackMethod",
|
|
52
|
+
"Endpoints",
|
|
53
|
+
"IPNotAllowedError",
|
|
54
|
+
"Language",
|
|
55
|
+
"PaymentNotCompletedError",
|
|
56
|
+
"ResultCode",
|
|
57
|
+
"RetryPolicy",
|
|
58
|
+
"SepAPIError",
|
|
59
|
+
"SepClient",
|
|
60
|
+
"SepConfigurationError",
|
|
61
|
+
"SepConnectionError",
|
|
62
|
+
"SepError",
|
|
63
|
+
"SepHTTPError",
|
|
64
|
+
"SepResponseError",
|
|
65
|
+
"SepTimeoutError",
|
|
66
|
+
"SepTokenError",
|
|
67
|
+
"SepTransactionError",
|
|
68
|
+
"SepTransportError",
|
|
69
|
+
"SepValidationError",
|
|
70
|
+
"SettlementItem",
|
|
71
|
+
"TerminalError",
|
|
72
|
+
"Token",
|
|
73
|
+
"TokenRequest",
|
|
74
|
+
"TransactionDetail",
|
|
75
|
+
"TransactionNotFoundError",
|
|
76
|
+
"TransactionResult",
|
|
77
|
+
"TransactionReversedError",
|
|
78
|
+
"TransactionState",
|
|
79
|
+
"TransactionStatus",
|
|
80
|
+
"VerifyWindowExpiredError",
|
|
81
|
+
"__version__",
|
|
82
|
+
"build_send_token_url",
|
|
83
|
+
"derive_session_key",
|
|
84
|
+
"generate_session_key",
|
|
85
|
+
"hash_card_number",
|
|
86
|
+
"iban_checksum_ok",
|
|
87
|
+
"parse_callback",
|
|
88
|
+
"redirect_form_fields",
|
|
89
|
+
"render_redirect_form",
|
|
90
|
+
]
|
sep_sdk/_base.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Everything the sync and async clients share: configuration and pure decisions.
|
|
2
|
+
|
|
3
|
+
Nothing in this module performs I/O. The two client classes add only "send this
|
|
4
|
+
request and give me the response", so the rules live in exactly one place.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
from typing import Any, ClassVar, Final
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
|
|
14
|
+
from sep_sdk._parsing import (
|
|
15
|
+
error_for_http_status,
|
|
16
|
+
json_object,
|
|
17
|
+
parse_token,
|
|
18
|
+
parse_transaction,
|
|
19
|
+
)
|
|
20
|
+
from sep_sdk._version import __version__
|
|
21
|
+
from sep_sdk.endpoints import Endpoints
|
|
22
|
+
from sep_sdk.enums import Language
|
|
23
|
+
from sep_sdk.errors import (
|
|
24
|
+
AmountMismatchError,
|
|
25
|
+
PaymentNotCompletedError,
|
|
26
|
+
SepConfigurationError,
|
|
27
|
+
SepConnectionError,
|
|
28
|
+
SepResponseError,
|
|
29
|
+
SepTimeoutError,
|
|
30
|
+
SepTransportError,
|
|
31
|
+
SepValidationError,
|
|
32
|
+
)
|
|
33
|
+
from sep_sdk.redirect import render_redirect_form
|
|
34
|
+
from sep_sdk.requests import TokenRequest
|
|
35
|
+
from sep_sdk.responses import CallbackData, Token, TransactionResult
|
|
36
|
+
from sep_sdk.retry import RetryPolicy
|
|
37
|
+
from sep_sdk.validation import require_int, to_ascii_digits
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("sep_sdk")
|
|
40
|
+
|
|
41
|
+
DEFAULT_TIMEOUT: Final = httpx.Timeout(20.0, connect=5.0)
|
|
42
|
+
#: Operations that are safe to repeat: Saman answers a repeat with "duplicate request".
|
|
43
|
+
IDEMPOTENT_OPERATIONS: Final = frozenset({"verify", "reverse"})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ClientBase:
|
|
47
|
+
"""Configuration, request building and response interpretation."""
|
|
48
|
+
|
|
49
|
+
USER_AGENT: ClassVar[str] = f"sep-python-sdk/{__version__}"
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
terminal_id: str | int,
|
|
54
|
+
*,
|
|
55
|
+
endpoints: Endpoints | None,
|
|
56
|
+
retry: RetryPolicy | None,
|
|
57
|
+
endpoint_fallback: bool,
|
|
58
|
+
) -> None:
|
|
59
|
+
digits = to_ascii_digits(str(terminal_id).strip())
|
|
60
|
+
if not digits.isascii() or not digits.isdigit():
|
|
61
|
+
raise SepConfigurationError("terminal_id must be the numeric terminal number.")
|
|
62
|
+
self.terminal_id: Final = digits
|
|
63
|
+
self.endpoints = endpoints or Endpoints()
|
|
64
|
+
self.retry = retry or RetryPolicy()
|
|
65
|
+
self._urls = {
|
|
66
|
+
"token": self.endpoints.token_url,
|
|
67
|
+
"verify": self.endpoints.verify_url,
|
|
68
|
+
"reverse": self.endpoints.reverse_url,
|
|
69
|
+
}
|
|
70
|
+
self._fallbacks: dict[str, str | None] = {
|
|
71
|
+
"verify": self.endpoints.verify_fallback_url if endpoint_fallback else None,
|
|
72
|
+
"reverse": self.endpoints.reverse_fallback_url if endpoint_fallback else None,
|
|
73
|
+
}
|
|
74
|
+
self._headers = {"Accept": "application/json", "User-Agent": self.USER_AGENT}
|
|
75
|
+
|
|
76
|
+
def __repr__(self) -> str:
|
|
77
|
+
return f"{type(self).__name__}(terminal_id={self.terminal_id!r})"
|
|
78
|
+
|
|
79
|
+
# --- request building -------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _token_payload(self, request: TokenRequest) -> dict[str, Any]:
|
|
82
|
+
if not isinstance(request, TokenRequest):
|
|
83
|
+
raise SepValidationError("create_token() needs a TokenRequest.")
|
|
84
|
+
return request.to_payload(self.terminal_id)
|
|
85
|
+
|
|
86
|
+
def _reference_payload(self, ref_num: str, session_key: str | None) -> dict[str, Any]:
|
|
87
|
+
if not isinstance(ref_num, str) or not ref_num.strip():
|
|
88
|
+
raise SepValidationError("ref_num must be the receipt string from the callback.")
|
|
89
|
+
payload: dict[str, Any] = {"RefNum": ref_num, "TerminalNumber": int(self.terminal_id)}
|
|
90
|
+
if session_key is not None:
|
|
91
|
+
payload["TxnRandomSessionKey"] = session_key
|
|
92
|
+
return payload
|
|
93
|
+
|
|
94
|
+
# --- endpoint selection ----------------------------------------------
|
|
95
|
+
|
|
96
|
+
def _url(self, operation: str) -> str:
|
|
97
|
+
return self._urls[operation]
|
|
98
|
+
|
|
99
|
+
def _switch_to_fallback(self, operation: str) -> bool:
|
|
100
|
+
"""Swap to the alternate spelling once; ``True`` if there was one to try."""
|
|
101
|
+
fallback = self._fallbacks.get(operation)
|
|
102
|
+
if fallback is None:
|
|
103
|
+
return False
|
|
104
|
+
logger.warning("sep_sdk: %s answered 404; trying %s", operation, fallback)
|
|
105
|
+
self._urls[operation] = fallback
|
|
106
|
+
self._fallbacks[operation] = None
|
|
107
|
+
return True
|
|
108
|
+
|
|
109
|
+
# --- retry decisions --------------------------------------------------
|
|
110
|
+
|
|
111
|
+
def _may_retry_exception(self, error: httpx.TransportError, operation: str) -> bool:
|
|
112
|
+
"""A connection that never opened is always safe to retry; the rest only if idempotent."""
|
|
113
|
+
if isinstance(error, httpx.ConnectError | httpx.ConnectTimeout):
|
|
114
|
+
return True
|
|
115
|
+
return operation in IDEMPOTENT_OPERATIONS
|
|
116
|
+
|
|
117
|
+
def _may_retry_status(self, status_code: int, operation: str) -> bool:
|
|
118
|
+
return operation in IDEMPOTENT_OPERATIONS and status_code in self.retry.retry_statuses
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def _transport_error(error: httpx.TransportError, operation: str) -> SepTransportError:
|
|
122
|
+
if isinstance(error, httpx.TimeoutException):
|
|
123
|
+
return SepTimeoutError(f"{operation}: the gateway did not answer in time.")
|
|
124
|
+
return SepConnectionError(
|
|
125
|
+
f"{operation}: could not talk to the gateway ({type(error).__name__})."
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
# --- response interpretation -----------------------------------------
|
|
129
|
+
|
|
130
|
+
def _body(self, response: httpx.Response, operation: str) -> dict[str, Any]:
|
|
131
|
+
if not response.is_success:
|
|
132
|
+
raise error_for_http_status(response, operation)
|
|
133
|
+
return json_object(response, operation)
|
|
134
|
+
|
|
135
|
+
def _finish_token(self, request: TokenRequest, response: httpx.Response) -> Token:
|
|
136
|
+
body = self._body(response, "token")
|
|
137
|
+
return parse_token(request, body, response.headers, self.endpoints)
|
|
138
|
+
|
|
139
|
+
def _finish_transaction(self, response: httpx.Response, operation: str) -> TransactionResult:
|
|
140
|
+
return parse_transaction(self._body(response, operation), operation)
|
|
141
|
+
|
|
142
|
+
# --- callback verification -------------------------------------------
|
|
143
|
+
|
|
144
|
+
def _ready_to_verify(self, callback: CallbackData, expected_amount: int) -> None:
|
|
145
|
+
require_int("expected_amount", expected_amount, minimum=1)
|
|
146
|
+
if not callback.is_ok:
|
|
147
|
+
raise PaymentNotCompletedError(
|
|
148
|
+
f"The payment was not completed: {callback.state.message('en')}",
|
|
149
|
+
state=callback.state,
|
|
150
|
+
)
|
|
151
|
+
if callback.terminal_id and callback.terminal_id != self.terminal_id:
|
|
152
|
+
raise SepValidationError("The callback belongs to a different terminal.")
|
|
153
|
+
|
|
154
|
+
def _check_result(self, callback: CallbackData, result: TransactionResult) -> None:
|
|
155
|
+
if result.ref_num != callback.ref_num:
|
|
156
|
+
raise SepResponseError("verify: the gateway answered for a different receipt.")
|
|
157
|
+
if result.detail.terminal_number != int(self.terminal_id):
|
|
158
|
+
raise SepResponseError("verify: the gateway answered for a different terminal.")
|
|
159
|
+
|
|
160
|
+
@staticmethod
|
|
161
|
+
def _amount_mismatch(expected: int, actual: int, *, was_reversed: bool) -> AmountMismatchError:
|
|
162
|
+
return AmountMismatchError(
|
|
163
|
+
f"The gateway took {actual} rials but the order is {expected}.",
|
|
164
|
+
expected=expected,
|
|
165
|
+
actual=actual,
|
|
166
|
+
was_reversed=was_reversed,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def render_redirect_form(
|
|
171
|
+
token: Token, *, language: Language = "en", nonce: str | None = None
|
|
172
|
+
) -> str:
|
|
173
|
+
"""See :func:`sep_sdk.render_redirect_form`."""
|
|
174
|
+
return render_redirect_form(token, language=language, nonce=nonce)
|
sep_sdk/_parsing.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Turning the gateway's JSON into typed values or precise exceptions.
|
|
2
|
+
|
|
3
|
+
Pure functions only: no I/O, so the sync and async clients share them and they can
|
|
4
|
+
be tested exhaustively without a network.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from sep_sdk.constants import DEFAULT_TOKEN_EXPIRY_MINUTES, IPG_URL_HEADER
|
|
18
|
+
from sep_sdk.endpoints import Endpoints
|
|
19
|
+
from sep_sdk.enums import ResultCode
|
|
20
|
+
from sep_sdk.errors import (
|
|
21
|
+
IPNotAllowedError,
|
|
22
|
+
SepHTTPError,
|
|
23
|
+
SepResponseError,
|
|
24
|
+
SepTokenError,
|
|
25
|
+
SepTransactionError,
|
|
26
|
+
TerminalError,
|
|
27
|
+
TransactionNotFoundError,
|
|
28
|
+
TransactionReversedError,
|
|
29
|
+
VerifyWindowExpiredError,
|
|
30
|
+
)
|
|
31
|
+
from sep_sdk.redirect import build_send_token_url
|
|
32
|
+
from sep_sdk.requests import TokenRequest
|
|
33
|
+
from sep_sdk.responses import Token, TransactionDetail, TransactionResult
|
|
34
|
+
|
|
35
|
+
_TOKEN_PATTERN = re.compile(r"[A-Za-z0-9._~+/=-]{8,512}")
|
|
36
|
+
_TRANSACTION_ERRORS: dict[ResultCode, type[SepTransactionError]] = {
|
|
37
|
+
ResultCode.TRANSACTION_NOT_FOUND: TransactionNotFoundError,
|
|
38
|
+
ResultCode.VERIFY_WINDOW_EXPIRED: VerifyWindowExpiredError,
|
|
39
|
+
ResultCode.TRANSACTION_REVERSED: TransactionReversedError,
|
|
40
|
+
ResultCode.TERMINAL_INACTIVE: TerminalError,
|
|
41
|
+
ResultCode.TERMINAL_NOT_FOUND: TerminalError,
|
|
42
|
+
ResultCode.IP_NOT_ALLOWED: IPNotAllowedError,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def json_object(response: httpx.Response, operation: str) -> dict[str, Any]:
|
|
47
|
+
"""The JSON body as a dict, or :class:`SepResponseError`."""
|
|
48
|
+
try:
|
|
49
|
+
body = response.json()
|
|
50
|
+
except (json.JSONDecodeError, UnicodeDecodeError):
|
|
51
|
+
raise SepResponseError(f"{operation}: the gateway did not answer with JSON.") from None
|
|
52
|
+
if not isinstance(body, dict):
|
|
53
|
+
raise SepResponseError(f"{operation}: expected a JSON object.")
|
|
54
|
+
return {str(key).strip(): value for key, value in body.items()}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def error_for_http_status(
|
|
58
|
+
response: httpx.Response, operation: str
|
|
59
|
+
) -> SepHTTPError | SepTransactionError:
|
|
60
|
+
"""The best exception for a non-2xx answer.
|
|
61
|
+
|
|
62
|
+
A body that carries a ``ResultCode`` (Saman sometimes does this with a 4xx) is
|
|
63
|
+
mapped to the same exceptions as a 200 answer; anything else is :class:`SepHTTPError`.
|
|
64
|
+
"""
|
|
65
|
+
if operation != "token":
|
|
66
|
+
try:
|
|
67
|
+
body = json_object(response, operation)
|
|
68
|
+
except SepResponseError:
|
|
69
|
+
body = {}
|
|
70
|
+
code = _integer(body.get("ResultCode"))
|
|
71
|
+
if code is not None:
|
|
72
|
+
return transaction_error(code, str(body.get("ResultDescription", "")), operation)
|
|
73
|
+
return SepHTTPError(
|
|
74
|
+
f"{operation}: the gateway answered HTTP {response.status_code}.",
|
|
75
|
+
status_code=response.status_code,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def parse_token(
|
|
80
|
+
request: TokenRequest, body: Mapping[str, Any], headers: Mapping[str, str], endpoints: Endpoints
|
|
81
|
+
) -> Token:
|
|
82
|
+
"""Interpret a token response (``status`` 1 with a ``token``, or -1 with an error)."""
|
|
83
|
+
if _integer(body.get("status")) != 1:
|
|
84
|
+
code = _integer(body.get("errorCode"))
|
|
85
|
+
description = str(body.get("errorDesc", ""))
|
|
86
|
+
raise SepTokenError(
|
|
87
|
+
f"token: the gateway refused the request (errorCode={code}).",
|
|
88
|
+
code=code,
|
|
89
|
+
description=description,
|
|
90
|
+
operation="token",
|
|
91
|
+
)
|
|
92
|
+
token = body.get("token")
|
|
93
|
+
if not isinstance(token, str) or _TOKEN_PATTERN.fullmatch(token) is None:
|
|
94
|
+
raise SepResponseError("token: the gateway returned an unusable token.")
|
|
95
|
+
|
|
96
|
+
ipg_url = headers.get(IPG_URL_HEADER) or None
|
|
97
|
+
if ipg_url is not None and not ipg_url.startswith("https://"):
|
|
98
|
+
raise SepResponseError(f"token: the {IPG_URL_HEADER} header is not an https URL.")
|
|
99
|
+
return Token(
|
|
100
|
+
token=token,
|
|
101
|
+
res_num=request.res_num,
|
|
102
|
+
form_action=ipg_url or endpoints.form_action_url,
|
|
103
|
+
get_url=None if ipg_url else build_send_token_url(token, base_url=endpoints.send_token_url),
|
|
104
|
+
ipg_url=ipg_url,
|
|
105
|
+
get_method=request.callback_method == "GET",
|
|
106
|
+
expires_in_minutes=request.token_expiry_minutes or DEFAULT_TOKEN_EXPIRY_MINUTES,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_transaction(body: Mapping[str, Any], operation: str) -> TransactionResult:
|
|
111
|
+
"""Interpret a verify or reverse answer, raising on any non-success code."""
|
|
112
|
+
code = _integer(body.get("ResultCode"))
|
|
113
|
+
if code is None:
|
|
114
|
+
raise SepResponseError(f"{operation}: the answer has no ResultCode.")
|
|
115
|
+
if code not in {ResultCode.SUCCESS, ResultCode.DUPLICATE_REQUEST}:
|
|
116
|
+
raise transaction_error(code, str(body.get("ResultDescription", "")), operation)
|
|
117
|
+
|
|
118
|
+
detail = body.get("TransactionDetail")
|
|
119
|
+
if not isinstance(detail, Mapping):
|
|
120
|
+
raise SepResponseError(f"{operation}: the answer has no TransactionDetail.")
|
|
121
|
+
return TransactionResult(
|
|
122
|
+
detail=_detail(detail, operation),
|
|
123
|
+
result_code=ResultCode.from_wire(code),
|
|
124
|
+
description=str(body.get("ResultDescription", "")),
|
|
125
|
+
is_duplicate=code == ResultCode.DUPLICATE_REQUEST,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def transaction_error(code: int, description: str, operation: str) -> SepTransactionError:
|
|
130
|
+
"""Map a failing ``ResultCode`` to the most specific exception."""
|
|
131
|
+
known = ResultCode.from_wire(code)
|
|
132
|
+
error_type = (
|
|
133
|
+
_TRANSACTION_ERRORS.get(known, SepTransactionError)
|
|
134
|
+
if isinstance(known, ResultCode)
|
|
135
|
+
else SepTransactionError
|
|
136
|
+
)
|
|
137
|
+
message = f"{operation}: the gateway refused the request (ResultCode={code})."
|
|
138
|
+
return error_type(message, result_code=known, description=description, operation=operation)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _detail(detail: Mapping[str, Any], operation: str) -> TransactionDetail:
|
|
142
|
+
fields = {str(key).strip(): value for key, value in detail.items()}
|
|
143
|
+
original = _integer(fields.get("OrginalAmount", fields.get("OriginalAmount")))
|
|
144
|
+
terminal = _integer(fields.get("TerminalNumber"))
|
|
145
|
+
ref_num = fields.get("RefNum")
|
|
146
|
+
if (
|
|
147
|
+
original is None
|
|
148
|
+
or original <= 0
|
|
149
|
+
or terminal is None
|
|
150
|
+
or not isinstance(ref_num, str)
|
|
151
|
+
or not ref_num
|
|
152
|
+
):
|
|
153
|
+
raise SepResponseError(f"{operation}: TransactionDetail is incomplete.")
|
|
154
|
+
affective = _integer(fields.get("AffectiveAmount"))
|
|
155
|
+
return TransactionDetail(
|
|
156
|
+
ref_num=ref_num,
|
|
157
|
+
rrn=str(fields.get("RRN", "")),
|
|
158
|
+
masked_pan=str(fields.get("MaskedPan", "")),
|
|
159
|
+
hashed_pan=str(fields.get("HashedPan", "")),
|
|
160
|
+
terminal_number=terminal,
|
|
161
|
+
original_amount=original,
|
|
162
|
+
affective_amount=original if affective is None else affective,
|
|
163
|
+
trace_date=_trace_date(fields.get("StraceDate")),
|
|
164
|
+
trace_no=str(fields.get("StraceNo", "")),
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _trace_date(value: object) -> datetime | None:
|
|
169
|
+
if not isinstance(value, str):
|
|
170
|
+
return None
|
|
171
|
+
try:
|
|
172
|
+
return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S")
|
|
173
|
+
except ValueError:
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _integer(value: object) -> int | None:
|
|
178
|
+
if isinstance(value, bool):
|
|
179
|
+
return None
|
|
180
|
+
if isinstance(value, int):
|
|
181
|
+
return value
|
|
182
|
+
if isinstance(value, str) and re.fullmatch(r"-?\d{1,18}", value.strip()):
|
|
183
|
+
return int(value.strip())
|
|
184
|
+
return None
|
sep_sdk/_version.py
ADDED
sep_sdk/async_client.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The asyncio client. Same behaviour as :class:`SepClient`, with ``await``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
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 AsyncSepClient(ClientBase):
|
|
23
|
+
"""Talk to the Saman online gateway from ``async`` code (FastAPI, aiohttp, Django async).
|
|
24
|
+
|
|
25
|
+
::
|
|
26
|
+
|
|
27
|
+
async with AsyncSepClient(terminal_id="12345678") as sep:
|
|
28
|
+
token = await sep.create_token(TokenRequest(amount=100_000, res_num="order-1", ...))
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
terminal_id: str | int,
|
|
34
|
+
*,
|
|
35
|
+
endpoints: Endpoints | None = None,
|
|
36
|
+
timeout: float | httpx.Timeout = DEFAULT_TIMEOUT,
|
|
37
|
+
retry: RetryPolicy | None = None,
|
|
38
|
+
endpoint_fallback: bool = True,
|
|
39
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
40
|
+
http_client: httpx.AsyncClient | None = None,
|
|
41
|
+
) -> None:
|
|
42
|
+
super().__init__(
|
|
43
|
+
terminal_id, endpoints=endpoints, retry=retry, endpoint_fallback=endpoint_fallback
|
|
44
|
+
)
|
|
45
|
+
self._owns_client = http_client is None
|
|
46
|
+
self._http = http_client or httpx.AsyncClient(timeout=timeout, transport=transport)
|
|
47
|
+
|
|
48
|
+
async def __aenter__(self) -> AsyncSepClient:
|
|
49
|
+
return self
|
|
50
|
+
|
|
51
|
+
async def __aexit__(
|
|
52
|
+
self,
|
|
53
|
+
exc_type: type[BaseException] | None,
|
|
54
|
+
exc: BaseException | None,
|
|
55
|
+
traceback: TracebackType | None,
|
|
56
|
+
) -> None:
|
|
57
|
+
await self.aclose()
|
|
58
|
+
|
|
59
|
+
async def aclose(self) -> None:
|
|
60
|
+
"""Release the connection pool (a client you passed in is left open)."""
|
|
61
|
+
if self._owns_client:
|
|
62
|
+
await self._http.aclose()
|
|
63
|
+
|
|
64
|
+
async def create_token(self, request: TokenRequest) -> Token:
|
|
65
|
+
"""Ask for a payment token for one order (guide step 1)."""
|
|
66
|
+
response = await self._post("token", self._token_payload(request))
|
|
67
|
+
return self._finish_token(request, response)
|
|
68
|
+
|
|
69
|
+
async def verify_transaction(
|
|
70
|
+
self, ref_num: str, *, session_key: str | None = None
|
|
71
|
+
) -> TransactionResult:
|
|
72
|
+
"""Confirm a receipt with the gateway (guide step 9). Do this within 30 minutes."""
|
|
73
|
+
response = await self._post("verify", self._reference_payload(ref_num, session_key))
|
|
74
|
+
return self._finish_transaction(response, "verify")
|
|
75
|
+
|
|
76
|
+
async def reverse_transaction(
|
|
77
|
+
self, ref_num: str, *, session_key: str | None = None
|
|
78
|
+
) -> TransactionResult:
|
|
79
|
+
"""Ask for the money back, up to 50 minutes after a verified payment (guide step 10)."""
|
|
80
|
+
response = await self._post("reverse", self._reference_payload(ref_num, session_key))
|
|
81
|
+
return self._finish_transaction(response, "reverse")
|
|
82
|
+
|
|
83
|
+
async def verify_callback(
|
|
84
|
+
self,
|
|
85
|
+
callback: CallbackData,
|
|
86
|
+
*,
|
|
87
|
+
expected_amount: int,
|
|
88
|
+
session_key: str | None = None,
|
|
89
|
+
reverse_on_mismatch: bool = False,
|
|
90
|
+
) -> TransactionResult:
|
|
91
|
+
"""The whole safe check in one call. See :meth:`SepClient.verify_callback`."""
|
|
92
|
+
self._ready_to_verify(callback, expected_amount)
|
|
93
|
+
result = await self.verify_transaction(callback.ref_num, session_key=session_key)
|
|
94
|
+
self._check_result(callback, result)
|
|
95
|
+
if result.amount != expected_amount:
|
|
96
|
+
reversed_ = False
|
|
97
|
+
if reverse_on_mismatch:
|
|
98
|
+
try:
|
|
99
|
+
await self.reverse_transaction(callback.ref_num, session_key=session_key)
|
|
100
|
+
reversed_ = True
|
|
101
|
+
except SepError:
|
|
102
|
+
logger.exception("sep_sdk: could not reverse a mismatched payment")
|
|
103
|
+
raise self._amount_mismatch(expected_amount, result.amount, was_reversed=reversed_)
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
async def _post(self, operation: str, payload: dict[str, Any]) -> httpx.Response:
|
|
107
|
+
attempt = 0
|
|
108
|
+
while True:
|
|
109
|
+
attempt += 1
|
|
110
|
+
url = self._url(operation)
|
|
111
|
+
logger.debug("sep_sdk: %s attempt %d", operation, attempt)
|
|
112
|
+
try:
|
|
113
|
+
response = await self._http.post(url, json=payload, headers=self._headers)
|
|
114
|
+
except httpx.TransportError as error:
|
|
115
|
+
if attempt < self.retry.max_attempts and self._may_retry_exception(
|
|
116
|
+
error, operation
|
|
117
|
+
):
|
|
118
|
+
await self._pause(attempt, operation)
|
|
119
|
+
continue
|
|
120
|
+
raise self._transport_error(error, operation) from error
|
|
121
|
+
if response.status_code == 404 and self._switch_to_fallback(operation):
|
|
122
|
+
attempt -= 1
|
|
123
|
+
continue
|
|
124
|
+
if attempt < self.retry.max_attempts and self._may_retry_status(
|
|
125
|
+
response.status_code, operation
|
|
126
|
+
):
|
|
127
|
+
await self._pause(attempt, operation)
|
|
128
|
+
continue
|
|
129
|
+
return response
|
|
130
|
+
|
|
131
|
+
async def _pause(self, attempt: int, operation: str) -> None:
|
|
132
|
+
delay = self.retry.delay(attempt)
|
|
133
|
+
logger.warning(
|
|
134
|
+
"sep_sdk: %s failed (attempt %d); retrying in %.1fs", operation, attempt, delay
|
|
135
|
+
)
|
|
136
|
+
if delay:
|
|
137
|
+
await asyncio.sleep(delay)
|
sep_sdk/callback.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Reading the request Saman sends to your ``redirect_url``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
from sep_sdk.enums import TransactionState, TransactionStatus
|
|
8
|
+
from sep_sdk.responses import CallbackData
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def parse_callback(params: Mapping[str, object]) -> CallbackData:
|
|
12
|
+
"""Turn the callback's form fields or query string into :class:`CallbackData`.
|
|
13
|
+
|
|
14
|
+
``params`` can be anything mapping-like: ``request.POST``, ``request.args``,
|
|
15
|
+
``await request.form()``, a plain dict, or ``parse_qs`` output (lists are
|
|
16
|
+
flattened to their first value). Parameter names are matched case-insensitively
|
|
17
|
+
because web frameworks differ; values are never executed or evaluated.
|
|
18
|
+
|
|
19
|
+
This function **does not trust** its input and makes no network call. See
|
|
20
|
+
:meth:`SepClient.verify_callback` for the step that actually proves payment.
|
|
21
|
+
"""
|
|
22
|
+
folded = {str(key).casefold(): value for key, value in params.items()}
|
|
23
|
+
|
|
24
|
+
def text(name: str) -> str:
|
|
25
|
+
value = folded.get(name.casefold())
|
|
26
|
+
if isinstance(value, Sequence) and not isinstance(value, str | bytes):
|
|
27
|
+
value = value[0] if value else ""
|
|
28
|
+
return "" if value is None else str(value).strip()
|
|
29
|
+
|
|
30
|
+
state_raw = text("State")
|
|
31
|
+
return CallbackData(
|
|
32
|
+
state=TransactionState.from_wire(state_raw),
|
|
33
|
+
state_raw=state_raw,
|
|
34
|
+
status=TransactionStatus.from_wire(text("Status")),
|
|
35
|
+
ref_num=text("RefNum"),
|
|
36
|
+
res_num=text("ResNum"),
|
|
37
|
+
terminal_id=text("TerminalId") or text("MID"),
|
|
38
|
+
trace_no=text("TraceNo"),
|
|
39
|
+
rrn=text("RRN"),
|
|
40
|
+
amount=_integer(text("Amount")),
|
|
41
|
+
wage=_integer(text("Wage")),
|
|
42
|
+
affective_amount=_integer(text("AffectiveAmount")),
|
|
43
|
+
token=text("Token"),
|
|
44
|
+
secure_pan=text("SecurePan"),
|
|
45
|
+
hashed_card_number=text("HashedCardNumber"),
|
|
46
|
+
raw={str(key): str(value) for key, value in params.items()},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _integer(value: str) -> int | None:
|
|
51
|
+
cleaned = value.replace(",", "")
|
|
52
|
+
return int(cleaned) if cleaned.isascii() and cleaned.isdigit() else None
|