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_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
@@ -0,0 +1,3 @@
1
+ """The single place the package version is written down."""
2
+
3
+ __version__ = "0.1.0"
@@ -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