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/requests.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""What you send to the gateway: the token request and its settlement lines."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from sep_sdk.constants import (
|
|
10
|
+
MAX_ALLOWED_CARDS,
|
|
11
|
+
MAX_EXTRA_DATA_ITEMS,
|
|
12
|
+
MAX_EXTRA_DATA_LENGTH,
|
|
13
|
+
MAX_REDIRECT_URL_LENGTH,
|
|
14
|
+
MAX_REDIRECT_URL_LENGTH_GET,
|
|
15
|
+
MAX_SETTLEMENT_ITEMS,
|
|
16
|
+
MAX_TOKEN_EXPIRY_MINUTES,
|
|
17
|
+
MIN_TOKEN_EXPIRY_MINUTES,
|
|
18
|
+
NO_PURCHASE_ID,
|
|
19
|
+
PURCHASE_ID_LENGTH,
|
|
20
|
+
)
|
|
21
|
+
from sep_sdk.errors import SepValidationError
|
|
22
|
+
from sep_sdk.validation import (
|
|
23
|
+
normalize_card_hash,
|
|
24
|
+
normalize_cell_number,
|
|
25
|
+
normalize_iban,
|
|
26
|
+
require_http_url,
|
|
27
|
+
require_int,
|
|
28
|
+
require_text,
|
|
29
|
+
to_ascii_digits,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
CallbackMethod = Literal["POST", "GET"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
36
|
+
class SettlementItem:
|
|
37
|
+
"""One destination of a multi-account (``MultiSettle``) or government payment.
|
|
38
|
+
|
|
39
|
+
``purchase_id`` is the 30-digit government identifier. Leave it ``None`` for an
|
|
40
|
+
ordinary line; in a government request it is filled with thirty zeros for you.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
iban: str
|
|
44
|
+
amount: int
|
|
45
|
+
purchase_id: str | None = None
|
|
46
|
+
|
|
47
|
+
def __post_init__(self) -> None:
|
|
48
|
+
object.__setattr__(self, "iban", normalize_iban(self.iban))
|
|
49
|
+
require_int("settlement amount", self.amount, minimum=1)
|
|
50
|
+
if self.purchase_id is not None:
|
|
51
|
+
digits = to_ascii_digits(require_text("purchase_id", self.purchase_id))
|
|
52
|
+
if len(digits) != PURCHASE_ID_LENGTH or not digits.isdigit():
|
|
53
|
+
raise SepValidationError(f"purchase_id must be {PURCHASE_ID_LENGTH} digits.")
|
|
54
|
+
object.__setattr__(self, "purchase_id", digits)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
58
|
+
class TokenRequest:
|
|
59
|
+
"""Everything the guide lets you send when asking for a payment token.
|
|
60
|
+
|
|
61
|
+
Required: ``amount`` (rials), ``res_num`` (your unique order id) and
|
|
62
|
+
``redirect_url`` (your callback). Everything else is optional.
|
|
63
|
+
|
|
64
|
+
``amount`` is in **rials** and an integer. ``res_num`` should be unique per
|
|
65
|
+
attempt: it is the only handle you will have if the customer paid and your
|
|
66
|
+
server never heard back.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
amount: int
|
|
70
|
+
res_num: str
|
|
71
|
+
redirect_url: str
|
|
72
|
+
wage: int | None = None
|
|
73
|
+
cell_number: str | int | None = None
|
|
74
|
+
token_expiry_minutes: int | None = None
|
|
75
|
+
#: MD5 digests (see :func:`sep_sdk.hash_card_number`) of the only cards allowed to pay.
|
|
76
|
+
allowed_cards: Sequence[str] = ()
|
|
77
|
+
#: Up to four free-form strings (50 chars each), visible only in Saman's report panel.
|
|
78
|
+
extra_data: Sequence[str] = ()
|
|
79
|
+
#: ``TxnRandomSessionKey``: ties the later verify call to this payment.
|
|
80
|
+
session_key: str | None = None
|
|
81
|
+
#: Multi-account settlement lines (at most nine).
|
|
82
|
+
settlement: Sequence[SettlementItem] = ()
|
|
83
|
+
#: A government purchase: the first settlement line must carry the government IBAN
|
|
84
|
+
#: and its 30-digit purchase id. Requires the terminal to be enabled for it.
|
|
85
|
+
government: bool = False
|
|
86
|
+
#: How Saman returns the customer: ``POST`` (default) or ``GET`` with a query string.
|
|
87
|
+
callback_method: CallbackMethod = "POST"
|
|
88
|
+
|
|
89
|
+
def __post_init__(self) -> None:
|
|
90
|
+
require_int("amount", self.amount, minimum=1)
|
|
91
|
+
require_text("res_num", self.res_num)
|
|
92
|
+
limit = (
|
|
93
|
+
MAX_REDIRECT_URL_LENGTH_GET
|
|
94
|
+
if self.callback_method == "GET"
|
|
95
|
+
else MAX_REDIRECT_URL_LENGTH
|
|
96
|
+
)
|
|
97
|
+
require_http_url("redirect_url", self.redirect_url, max_length=limit)
|
|
98
|
+
if self.callback_method not in {"POST", "GET"}:
|
|
99
|
+
raise SepValidationError("callback_method must be 'POST' or 'GET'.")
|
|
100
|
+
if self.wage is not None:
|
|
101
|
+
require_int("wage", self.wage, minimum=0)
|
|
102
|
+
if self.cell_number is not None:
|
|
103
|
+
object.__setattr__(self, "cell_number", normalize_cell_number(self.cell_number))
|
|
104
|
+
if self.token_expiry_minutes is not None:
|
|
105
|
+
require_int(
|
|
106
|
+
"token_expiry_minutes",
|
|
107
|
+
self.token_expiry_minutes,
|
|
108
|
+
minimum=MIN_TOKEN_EXPIRY_MINUTES,
|
|
109
|
+
maximum=MAX_TOKEN_EXPIRY_MINUTES,
|
|
110
|
+
)
|
|
111
|
+
if self.session_key is not None:
|
|
112
|
+
require_text("session_key", self.session_key)
|
|
113
|
+
self._normalize_cards()
|
|
114
|
+
self._normalize_extra_data()
|
|
115
|
+
self._normalize_settlement()
|
|
116
|
+
|
|
117
|
+
def _normalize_cards(self) -> None:
|
|
118
|
+
cards = tuple(
|
|
119
|
+
normalize_card_hash(card) for card in _as_sequence("allowed_cards", self.allowed_cards)
|
|
120
|
+
)
|
|
121
|
+
if len(cards) > MAX_ALLOWED_CARDS:
|
|
122
|
+
raise SepValidationError(f"allowed_cards accepts at most {MAX_ALLOWED_CARDS} cards.")
|
|
123
|
+
object.__setattr__(self, "allowed_cards", cards)
|
|
124
|
+
|
|
125
|
+
def _normalize_extra_data(self) -> None:
|
|
126
|
+
extra = tuple(
|
|
127
|
+
require_text("extra_data item", item, max_length=MAX_EXTRA_DATA_LENGTH)
|
|
128
|
+
for item in _as_sequence("extra_data", self.extra_data)
|
|
129
|
+
)
|
|
130
|
+
if len(extra) > MAX_EXTRA_DATA_ITEMS:
|
|
131
|
+
raise SepValidationError(f"extra_data accepts at most {MAX_EXTRA_DATA_ITEMS} items.")
|
|
132
|
+
object.__setattr__(self, "extra_data", extra)
|
|
133
|
+
|
|
134
|
+
def _normalize_settlement(self) -> None:
|
|
135
|
+
lines = tuple(_as_sequence("settlement", self.settlement))
|
|
136
|
+
if len(lines) > MAX_SETTLEMENT_ITEMS:
|
|
137
|
+
raise SepValidationError(f"settlement accepts at most {MAX_SETTLEMENT_ITEMS} lines.")
|
|
138
|
+
if not all(isinstance(line, SettlementItem) for line in lines):
|
|
139
|
+
raise SepValidationError("settlement items must be SettlementItem instances.")
|
|
140
|
+
if self.government:
|
|
141
|
+
if not lines:
|
|
142
|
+
raise SepValidationError("A government payment needs at least one settlement line.")
|
|
143
|
+
if lines[0].purchase_id is None or set(lines[0].purchase_id) == {"0"}:
|
|
144
|
+
raise SepValidationError(
|
|
145
|
+
"The first settlement line of a government payment needs its 30-digit "
|
|
146
|
+
"government purchase_id."
|
|
147
|
+
)
|
|
148
|
+
object.__setattr__(self, "settlement", lines)
|
|
149
|
+
|
|
150
|
+
def to_payload(self, terminal_id: str) -> dict[str, Any]:
|
|
151
|
+
"""The JSON body of the token request, exactly as the guide names the fields."""
|
|
152
|
+
payload: dict[str, Any] = {
|
|
153
|
+
"action": "token",
|
|
154
|
+
"TerminalId": terminal_id,
|
|
155
|
+
"Amount": self.amount,
|
|
156
|
+
"ResNum": self.res_num,
|
|
157
|
+
"RedirectUrl": self.redirect_url,
|
|
158
|
+
}
|
|
159
|
+
if self.wage is not None:
|
|
160
|
+
payload["Wage"] = self.wage
|
|
161
|
+
if self.cell_number is not None:
|
|
162
|
+
payload["CellNumber"] = self.cell_number
|
|
163
|
+
if self.token_expiry_minutes is not None:
|
|
164
|
+
payload["TokenExpiryInMin"] = self.token_expiry_minutes
|
|
165
|
+
if self.allowed_cards:
|
|
166
|
+
payload["HashedCardNumber"] = "|".join(self.allowed_cards)
|
|
167
|
+
for index, item in enumerate(self.extra_data, start=1):
|
|
168
|
+
payload[f"ResNum{index}"] = item
|
|
169
|
+
if self.session_key is not None:
|
|
170
|
+
payload["TxnRandomSessionKey"] = self.session_key
|
|
171
|
+
if self.government:
|
|
172
|
+
payload["TranType"] = "Government"
|
|
173
|
+
if self.settlement:
|
|
174
|
+
payload["SettlementIbanInfo"] = [
|
|
175
|
+
{
|
|
176
|
+
"IBAN": line.iban,
|
|
177
|
+
"Amount": line.amount,
|
|
178
|
+
"PurchaseId": line.purchase_id
|
|
179
|
+
if line.purchase_id is not None
|
|
180
|
+
else (NO_PURCHASE_ID if self.government else None),
|
|
181
|
+
}
|
|
182
|
+
for line in self.settlement
|
|
183
|
+
]
|
|
184
|
+
for entry in payload["SettlementIbanInfo"]:
|
|
185
|
+
if entry["PurchaseId"] is None:
|
|
186
|
+
del entry["PurchaseId"]
|
|
187
|
+
return payload
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _as_sequence(name: str, value: object) -> Sequence[Any]:
|
|
191
|
+
"""Refuse a bare ``str`` where a list of strings is meant (it would iterate characters)."""
|
|
192
|
+
if isinstance(value, str | bytes) or not isinstance(value, Sequence):
|
|
193
|
+
raise SepValidationError(f"{name} must be a list or tuple.")
|
|
194
|
+
return value
|
sep_sdk/responses.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""What comes back from the gateway, as immutable, typed values."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Mapping
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
|
|
9
|
+
from sep_sdk.enums import ResultCode, TransactionState, TransactionStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class Token:
|
|
14
|
+
"""A payment token and everything needed to send the customer to the bank.
|
|
15
|
+
|
|
16
|
+
Prefer :func:`sep_sdk.render_redirect_form` (a POST form) for the hand-off: it
|
|
17
|
+
works for every terminal. :attr:`get_url` is the simple link form and exists only
|
|
18
|
+
for classic terminals; terminals moved to ``neo-pg`` get :attr:`ipg_url` and must
|
|
19
|
+
use the form.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
token: str
|
|
23
|
+
res_num: str
|
|
24
|
+
#: Where the HTML form posts the token (the ``X-IPG-Url`` header on ``neo-pg``).
|
|
25
|
+
form_action: str
|
|
26
|
+
#: ``https://…/SendToken?token=…`` for classic terminals; ``None`` on ``neo-pg``.
|
|
27
|
+
get_url: str | None
|
|
28
|
+
#: The ``X-IPG-Url`` header Saman sent, when the terminal is on ``neo-pg``.
|
|
29
|
+
ipg_url: str | None
|
|
30
|
+
#: Whether Saman will return the customer by ``GET`` (query string) instead of ``POST``.
|
|
31
|
+
get_method: bool
|
|
32
|
+
expires_in_minutes: int
|
|
33
|
+
|
|
34
|
+
def __repr__(self) -> str:
|
|
35
|
+
# The token is a one-time payment handle; keep it out of logs and tracebacks.
|
|
36
|
+
return (
|
|
37
|
+
f"Token(res_num={self.res_num!r}, token='***', "
|
|
38
|
+
f"expires_in_minutes={self.expires_in_minutes})"
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class CallbackData:
|
|
44
|
+
"""The parameters Saman sends to your ``redirect_url``, parsed but **not trusted**.
|
|
45
|
+
|
|
46
|
+
Anyone can send your callback URL any parameters. Use this to learn *which* order
|
|
47
|
+
to check and whether the customer even tried; the only proof of payment is
|
|
48
|
+
:meth:`SepClient.verify_transaction` (or :meth:`SepClient.verify_callback`).
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
state: TransactionState
|
|
52
|
+
state_raw: str
|
|
53
|
+
status: TransactionStatus | None
|
|
54
|
+
ref_num: str
|
|
55
|
+
res_num: str
|
|
56
|
+
terminal_id: str
|
|
57
|
+
trace_no: str
|
|
58
|
+
rrn: str
|
|
59
|
+
amount: int | None
|
|
60
|
+
wage: int | None
|
|
61
|
+
affective_amount: int | None
|
|
62
|
+
token: str
|
|
63
|
+
#: Masked card number, e.g. ``621986****8080``. Hidden from ``repr``.
|
|
64
|
+
secure_pan: str = field(repr=False)
|
|
65
|
+
#: SHA-256 of the card number. Hidden from ``repr``.
|
|
66
|
+
hashed_card_number: str = field(repr=False)
|
|
67
|
+
raw: Mapping[str, str] = field(repr=False)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def is_ok(self) -> bool:
|
|
71
|
+
"""``State`` is ``OK``, a receipt exists, and ``Status`` (if sent) agrees."""
|
|
72
|
+
return (
|
|
73
|
+
self.state.is_ok and bool(self.ref_num) and self.status in (None, TransactionStatus.OK)
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True, slots=True)
|
|
78
|
+
class TransactionDetail:
|
|
79
|
+
"""The ``TransactionDetail`` block of a verify or reverse answer."""
|
|
80
|
+
|
|
81
|
+
ref_num: str
|
|
82
|
+
rrn: str
|
|
83
|
+
masked_pan: str = field(repr=False)
|
|
84
|
+
hashed_pan: str = field(repr=False)
|
|
85
|
+
terminal_number: int
|
|
86
|
+
#: The amount sent to the gateway (``OrginalAmount`` in Saman's spelling).
|
|
87
|
+
original_amount: int
|
|
88
|
+
#: The amount actually deducted from the card (differs on discount terminals).
|
|
89
|
+
affective_amount: int
|
|
90
|
+
#: ``StraceDate`` parsed; Saman does not state a timezone, so it is naive.
|
|
91
|
+
trace_date: datetime | None
|
|
92
|
+
trace_no: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class TransactionResult:
|
|
97
|
+
"""The outcome of a successful verify or reverse call."""
|
|
98
|
+
|
|
99
|
+
detail: TransactionDetail
|
|
100
|
+
result_code: ResultCode | int
|
|
101
|
+
description: str
|
|
102
|
+
#: ``True`` when Saman answered "duplicate request" (``2``): the same call had
|
|
103
|
+
#: already been processed, typically because an earlier reply was lost.
|
|
104
|
+
is_duplicate: bool
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def ref_num(self) -> str:
|
|
108
|
+
return self.detail.ref_num
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def amount(self) -> int:
|
|
112
|
+
"""The amount to compare with your order total (``original_amount``)."""
|
|
113
|
+
return self.detail.original_amount
|
sep_sdk/retry.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""When and how long to wait before asking the gateway again."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from sep_sdk.errors import SepConfigurationError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class RetryPolicy:
|
|
12
|
+
"""Exponential backoff for failed calls.
|
|
13
|
+
|
|
14
|
+
``verify`` and ``reverse`` are idempotent on Saman's side (a repeat is answered
|
|
15
|
+
"duplicate request"), so they are retried on timeouts, resets and 5xx answers.
|
|
16
|
+
Token creation is **not** retried after the request may have reached the bank,
|
|
17
|
+
because that could create two payment sessions for one order; it is retried only
|
|
18
|
+
when the connection could not even be opened.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
max_attempts: int = 3
|
|
22
|
+
backoff_factor: float = 0.5
|
|
23
|
+
max_backoff: float = 8.0
|
|
24
|
+
retry_statuses: frozenset[int] = frozenset({408, 425, 429, 500, 502, 503, 504})
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.max_attempts < 1:
|
|
28
|
+
raise SepConfigurationError("max_attempts must be at least 1.")
|
|
29
|
+
if self.backoff_factor < 0 or self.max_backoff < 0:
|
|
30
|
+
raise SepConfigurationError("backoff values must not be negative.")
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def none(cls) -> RetryPolicy:
|
|
34
|
+
"""Make every call exactly once."""
|
|
35
|
+
return cls(max_attempts=1)
|
|
36
|
+
|
|
37
|
+
def delay(self, failed_attempts: int) -> float:
|
|
38
|
+
"""Seconds to wait after the given number of failed attempts (``>= 1``)."""
|
|
39
|
+
return float(min(self.max_backoff, self.backoff_factor * (2 ** (failed_attempts - 1))))
|
sep_sdk/security.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Helpers for the two values the guide asks you to handle carefully."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import hmac
|
|
7
|
+
import re
|
|
8
|
+
import secrets
|
|
9
|
+
|
|
10
|
+
from sep_sdk.errors import SepValidationError
|
|
11
|
+
from sep_sdk.validation import require_text, to_ascii_digits
|
|
12
|
+
|
|
13
|
+
_CARD_PATTERN = re.compile(r"\d{16}")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def hash_card_number(card_number: str) -> str:
|
|
17
|
+
"""The MD5 digest (upper-case hex) Saman expects in ``allowed_cards``.
|
|
18
|
+
|
|
19
|
+
The guide requires an *irreversible* hash and forbids storing the card number.
|
|
20
|
+
This function never logs or stores its input; call it, keep only the digest.
|
|
21
|
+
MD5 is mandated by the protocol, not chosen here.
|
|
22
|
+
"""
|
|
23
|
+
digits = to_ascii_digits(re.sub(r"[\s-]", "", require_text("card_number", card_number)))
|
|
24
|
+
if _CARD_PATTERN.fullmatch(digits) is None:
|
|
25
|
+
raise SepValidationError("card_number must be 16 digits.")
|
|
26
|
+
return hashlib.md5(digits.encode("ascii"), usedforsecurity=False).hexdigest().upper()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def generate_session_key() -> str:
|
|
30
|
+
"""A fresh random ``TxnRandomSessionKey`` (32 hex characters).
|
|
31
|
+
|
|
32
|
+
Store it with the order, pass it as ``session_key`` when you create the token,
|
|
33
|
+
and again when you verify. Saman then refuses a receipt that was not issued for
|
|
34
|
+
that session, so one customer's receipt cannot be replayed on another order.
|
|
35
|
+
"""
|
|
36
|
+
return secrets.token_hex(16)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def derive_session_key(secret: str | bytes, res_num: str) -> str:
|
|
40
|
+
"""A ``TxnRandomSessionKey`` computed from a server secret and the order id.
|
|
41
|
+
|
|
42
|
+
Use this instead of :func:`generate_session_key` when you would rather not
|
|
43
|
+
store a key per order: recompute it at verify time from the same two inputs.
|
|
44
|
+
Rotating ``secret`` between token and verify makes that payment unverifiable.
|
|
45
|
+
"""
|
|
46
|
+
key = secret.encode("utf-8") if isinstance(secret, str) else secret
|
|
47
|
+
if not key:
|
|
48
|
+
raise SepValidationError("secret must not be empty.")
|
|
49
|
+
message = f"sep-session:{require_text('res_num', res_num)}".encode()
|
|
50
|
+
return hmac.new(key, message, hashlib.sha256).hexdigest()[:32]
|
sep_sdk/testing.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"""An in-memory Saman gateway for your own tests: no network, no terminal, no IP list.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
fake = FakeSepGateway(terminal_id="12345678")
|
|
6
|
+
sep = SepClient("12345678", endpoints=fake.endpoints, transport=fake.transport)
|
|
7
|
+
|
|
8
|
+
token = sep.create_token(TokenRequest(amount=100_000, res_num="order-1", redirect_url=...))
|
|
9
|
+
params = fake.complete_payment(token.token) # what the bank would POST back
|
|
10
|
+
callback = parse_callback(params)
|
|
11
|
+
result = sep.verify_callback(callback, expected_amount=100_000)
|
|
12
|
+
|
|
13
|
+
The fake follows the guide, not Saman's private behaviour, so treat it as a way to
|
|
14
|
+
exercise *your* code paths (success, cancellation, outages, amount tampering), and
|
|
15
|
+
still run one real small payment before going live.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import secrets
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Literal
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
from sep_sdk.endpoints import Endpoints
|
|
28
|
+
from sep_sdk.enums import ResultCode, TransactionState, TransactionStatus
|
|
29
|
+
|
|
30
|
+
Spelling = Literal["transaction", "transcation", "both"]
|
|
31
|
+
|
|
32
|
+
_STATUS_FOR_STATE = {
|
|
33
|
+
TransactionState.OK: TransactionStatus.OK,
|
|
34
|
+
TransactionState.CANCELED_BY_USER: TransactionStatus.CANCELED_BY_USER,
|
|
35
|
+
TransactionState.FAILED: TransactionStatus.FAILED,
|
|
36
|
+
TransactionState.SESSION_IS_NULL: TransactionStatus.SESSION_IS_NULL,
|
|
37
|
+
TransactionState.INVALID_PARAMETERS: TransactionStatus.INVALID_PARAMETERS,
|
|
38
|
+
TransactionState.MERCHANT_IP_ADDRESS_IS_INVALID: (
|
|
39
|
+
TransactionStatus.MERCHANT_IP_ADDRESS_IS_INVALID
|
|
40
|
+
),
|
|
41
|
+
TransactionState.TOKEN_NOT_FOUND: TransactionStatus.TOKEN_NOT_FOUND,
|
|
42
|
+
TransactionState.TOKEN_REQUIRED: TransactionStatus.TOKEN_REQUIRED,
|
|
43
|
+
TransactionState.TERMINAL_NOT_FOUND: TransactionStatus.TERMINAL_NOT_FOUND,
|
|
44
|
+
TransactionState.MULTISETTLE_POLICY_ERRORS: TransactionStatus.MULTISETTLE_POLICY_ERRORS,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(slots=True)
|
|
49
|
+
class _Session:
|
|
50
|
+
res_num: str
|
|
51
|
+
amount: int
|
|
52
|
+
redirect_url: str
|
|
53
|
+
session_key: str | None
|
|
54
|
+
ref_num: str = ""
|
|
55
|
+
paid_amount: int = 0
|
|
56
|
+
verified: bool = False
|
|
57
|
+
reversed: bool = False
|
|
58
|
+
expired: bool = False
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class RecordedCall:
|
|
63
|
+
"""One request the fake received, for assertions."""
|
|
64
|
+
|
|
65
|
+
operation: str
|
|
66
|
+
url: str
|
|
67
|
+
body: dict[str, Any]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(slots=True)
|
|
71
|
+
class _Failure:
|
|
72
|
+
operation: str | None
|
|
73
|
+
status_code: int | None
|
|
74
|
+
drop: bool
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(slots=True)
|
|
78
|
+
class FakeSepGateway:
|
|
79
|
+
"""Simulates token, verify and reverse, and the bank's redirect parameters."""
|
|
80
|
+
|
|
81
|
+
terminal_id: str = "12345678"
|
|
82
|
+
#: Which verify/reverse path spelling answers; the other one gets a 404.
|
|
83
|
+
spelling: Spelling = "transaction"
|
|
84
|
+
#: Send the ``X-IPG-Url`` header, like a terminal that moved to ``neo-pg``.
|
|
85
|
+
neo_pg_url: str | None = None
|
|
86
|
+
#: Refuse token requests as an unregistered server IP would (``MerchantIpAddressIsInvalid``).
|
|
87
|
+
reject_ip: bool = False
|
|
88
|
+
calls: list[RecordedCall] = field(default_factory=list)
|
|
89
|
+
_sessions: dict[str, _Session] = field(default_factory=dict)
|
|
90
|
+
_by_ref: dict[str, _Session] = field(default_factory=dict)
|
|
91
|
+
_failures: list[_Failure] = field(default_factory=list)
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def endpoints(self) -> Endpoints:
|
|
95
|
+
"""Endpoints that point at this fake. Pass them to the client."""
|
|
96
|
+
base = "https://fake.sep.test"
|
|
97
|
+
return Endpoints(
|
|
98
|
+
token_url=f"{base}/onlinepg/onlinepg",
|
|
99
|
+
form_action_url=f"{base}/OnlinePG/OnlinePG",
|
|
100
|
+
send_token_url=f"{base}/OnlinePG/SendToken",
|
|
101
|
+
verify_url=f"{base}/ipg/VerifyTransaction",
|
|
102
|
+
reverse_url=f"{base}/ipg/ReverseTransaction",
|
|
103
|
+
verify_fallback_url=f"{base}/ipg/VerifyTranscation",
|
|
104
|
+
reverse_fallback_url=f"{base}/ipg/ReverseTranscation",
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def transport(self) -> httpx.MockTransport:
|
|
109
|
+
"""A transport for :class:`SepClient` and :class:`AsyncSepClient` alike."""
|
|
110
|
+
return httpx.MockTransport(self._handle)
|
|
111
|
+
|
|
112
|
+
# --- scripting the world ---------------------------------------------
|
|
113
|
+
|
|
114
|
+
def complete_payment(self, token: str, *, paid_amount: int | None = None) -> dict[str, str]:
|
|
115
|
+
"""The customer pays. Returns the form fields the bank would POST to your callback.
|
|
116
|
+
|
|
117
|
+
``paid_amount`` lets a test simulate a tampered or partial payment.
|
|
118
|
+
"""
|
|
119
|
+
session = self._session(token)
|
|
120
|
+
session.ref_num = "REF" + secrets.token_hex(12)
|
|
121
|
+
session.paid_amount = session.amount if paid_amount is None else paid_amount
|
|
122
|
+
self._by_ref[session.ref_num] = session
|
|
123
|
+
return self._callback_fields(token, session, TransactionState.OK)
|
|
124
|
+
|
|
125
|
+
def end_payment(
|
|
126
|
+
self, token: str, state: TransactionState = TransactionState.CANCELED_BY_USER
|
|
127
|
+
) -> dict[str, str]:
|
|
128
|
+
"""The customer does not pay (cancel, failure, timeout). Returns the callback fields."""
|
|
129
|
+
session = self._session(token)
|
|
130
|
+
return self._callback_fields(token, session, state)
|
|
131
|
+
|
|
132
|
+
def expire(self, ref_num: str) -> None:
|
|
133
|
+
"""Pretend 30 minutes passed without a verify: the next verify answers ``-6``."""
|
|
134
|
+
self._by_ref[ref_num].expired = True
|
|
135
|
+
|
|
136
|
+
def fail_next(self, *, status_code: int = 503, operation: str | None = None) -> None:
|
|
137
|
+
"""Answer the next matching call with an HTTP error."""
|
|
138
|
+
self._failures.append(_Failure(operation, status_code, drop=False))
|
|
139
|
+
|
|
140
|
+
def drop_next(self, *, operation: str | None = None) -> None:
|
|
141
|
+
"""Lose the next matching call: the client sees a connection error."""
|
|
142
|
+
self._failures.append(_Failure(operation, None, drop=True))
|
|
143
|
+
|
|
144
|
+
# --- the fake server --------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def _handle(self, request: httpx.Request) -> httpx.Response:
|
|
147
|
+
operation = self._operation(request.url.path)
|
|
148
|
+
body: dict[str, Any] = json.loads(request.content or b"{}")
|
|
149
|
+
self.calls.append(RecordedCall(operation, str(request.url), body))
|
|
150
|
+
|
|
151
|
+
failure = self._take_failure(operation)
|
|
152
|
+
if failure is not None:
|
|
153
|
+
if failure.drop:
|
|
154
|
+
raise httpx.ConnectError("fake: connection dropped", request=request)
|
|
155
|
+
return httpx.Response(failure.status_code or 503, json={"error": "injected"})
|
|
156
|
+
if operation == "unknown" or not self._path_allowed(operation, request.url.path):
|
|
157
|
+
return httpx.Response(404, json={"error": "not found"})
|
|
158
|
+
if operation == "token":
|
|
159
|
+
return self._token(body)
|
|
160
|
+
return self._transaction(operation, body)
|
|
161
|
+
|
|
162
|
+
def _operation(self, path: str) -> str:
|
|
163
|
+
lowered = path.lower()
|
|
164
|
+
if lowered.endswith("/onlinepg"):
|
|
165
|
+
return "token"
|
|
166
|
+
for name in ("verify", "reverse"):
|
|
167
|
+
if lowered.endswith((f"/{name}transaction", f"/{name}transcation")):
|
|
168
|
+
return name
|
|
169
|
+
return "unknown"
|
|
170
|
+
|
|
171
|
+
def _path_allowed(self, operation: str, path: str) -> bool:
|
|
172
|
+
if operation == "token" or self.spelling == "both":
|
|
173
|
+
return True
|
|
174
|
+
legacy = path.lower().endswith("transcation")
|
|
175
|
+
return legacy == (self.spelling == "transcation")
|
|
176
|
+
|
|
177
|
+
def _take_failure(self, operation: str) -> _Failure | None:
|
|
178
|
+
for index, failure in enumerate(self._failures):
|
|
179
|
+
if failure.operation in (None, operation):
|
|
180
|
+
return self._failures.pop(index)
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
def _token(self, body: dict[str, Any]) -> httpx.Response:
|
|
184
|
+
if self.reject_ip:
|
|
185
|
+
return httpx.Response(
|
|
186
|
+
200, json={"status": -1, "errorCode": "8", "errorDesc": "merchant ip is invalid"}
|
|
187
|
+
)
|
|
188
|
+
if str(body.get("TerminalId")) != self.terminal_id:
|
|
189
|
+
return httpx.Response(
|
|
190
|
+
200, json={"status": -1, "errorCode": "12", "errorDesc": "terminal not found"}
|
|
191
|
+
)
|
|
192
|
+
token = secrets.token_hex(16)
|
|
193
|
+
self._sessions[token] = _Session(
|
|
194
|
+
res_num=str(body.get("ResNum", "")),
|
|
195
|
+
amount=int(body.get("Amount", 0)),
|
|
196
|
+
redirect_url=str(body.get("RedirectUrl", "")),
|
|
197
|
+
session_key=body.get("TxnRandomSessionKey"),
|
|
198
|
+
)
|
|
199
|
+
headers = {"X-IPG-Url": self.neo_pg_url} if self.neo_pg_url else {}
|
|
200
|
+
return httpx.Response(200, json={"status": 1, "token": token}, headers=headers)
|
|
201
|
+
|
|
202
|
+
def _transaction(self, operation: str, body: dict[str, Any]) -> httpx.Response:
|
|
203
|
+
session = self._by_ref.get(str(body.get("RefNum")))
|
|
204
|
+
if str(body.get("TerminalNumber")) != self.terminal_id:
|
|
205
|
+
return self._result(ResultCode.TERMINAL_NOT_FOUND)
|
|
206
|
+
if session is None or (
|
|
207
|
+
session.session_key is not None
|
|
208
|
+
and body.get("TxnRandomSessionKey") != session.session_key
|
|
209
|
+
):
|
|
210
|
+
return self._result(ResultCode.TRANSACTION_NOT_FOUND)
|
|
211
|
+
return self._reverse(session) if operation == "reverse" else self._verify(session)
|
|
212
|
+
|
|
213
|
+
def _reverse(self, session: _Session) -> httpx.Response:
|
|
214
|
+
if session.reversed:
|
|
215
|
+
return self._result(ResultCode.DUPLICATE_REQUEST, session)
|
|
216
|
+
session.reversed = True
|
|
217
|
+
return self._result(ResultCode.SUCCESS, session)
|
|
218
|
+
|
|
219
|
+
def _verify(self, session: _Session) -> httpx.Response:
|
|
220
|
+
if session.reversed:
|
|
221
|
+
return self._result(ResultCode.TRANSACTION_REVERSED)
|
|
222
|
+
if session.expired and not session.verified:
|
|
223
|
+
return self._result(ResultCode.VERIFY_WINDOW_EXPIRED)
|
|
224
|
+
duplicate = session.verified
|
|
225
|
+
session.verified = True
|
|
226
|
+
return self._result(
|
|
227
|
+
ResultCode.DUPLICATE_REQUEST if duplicate else ResultCode.SUCCESS, session
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
def _result(self, code: ResultCode, session: _Session | None = None) -> httpx.Response:
|
|
231
|
+
body: dict[str, Any] = {
|
|
232
|
+
"ResultCode": int(code),
|
|
233
|
+
"ResultDescription": code.message("en"),
|
|
234
|
+
"Success": code is ResultCode.SUCCESS,
|
|
235
|
+
}
|
|
236
|
+
if session is not None:
|
|
237
|
+
body["TransactionDetail"] = {
|
|
238
|
+
"RRN": "14226761817",
|
|
239
|
+
"RefNum": session.ref_num,
|
|
240
|
+
"MaskedPan": "621986****8080",
|
|
241
|
+
"HashedPan": "b96a14400c3a59249e87c300ecc06e5920327e70220213b5bbb7d7b2410f7e0d",
|
|
242
|
+
"TerminalNumber": int(self.terminal_id),
|
|
243
|
+
"OrginalAmount": session.paid_amount,
|
|
244
|
+
"AffectiveAmount": session.paid_amount,
|
|
245
|
+
"StraceDate": "2026-01-01 12:00:00",
|
|
246
|
+
"StraceNo": "100428",
|
|
247
|
+
}
|
|
248
|
+
return httpx.Response(200, json=body)
|
|
249
|
+
|
|
250
|
+
def _session(self, token: str) -> _Session:
|
|
251
|
+
try:
|
|
252
|
+
return self._sessions[token]
|
|
253
|
+
except KeyError:
|
|
254
|
+
raise KeyError("Unknown token: create it with the client first.") from None
|
|
255
|
+
|
|
256
|
+
def _callback_fields(
|
|
257
|
+
self, token: str, session: _Session, state: TransactionState
|
|
258
|
+
) -> dict[str, str]:
|
|
259
|
+
ok = state is TransactionState.OK
|
|
260
|
+
return {
|
|
261
|
+
"MID": self.terminal_id,
|
|
262
|
+
"TerminalId": self.terminal_id,
|
|
263
|
+
"State": state.value,
|
|
264
|
+
"Status": str(int(_STATUS_FOR_STATE[state])),
|
|
265
|
+
"RRN": "14226761817" if ok else "",
|
|
266
|
+
"RefNum": session.ref_num if ok else "",
|
|
267
|
+
"ResNum": session.res_num,
|
|
268
|
+
"TraceNo": "100428" if ok else "",
|
|
269
|
+
"Amount": str(session.paid_amount if ok else session.amount),
|
|
270
|
+
"SecurePan": "621986****8080" if ok else "",
|
|
271
|
+
"Token": token,
|
|
272
|
+
}
|