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