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/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
+ )