sep-python-sdk 0.1.0__tar.gz

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.
Files changed (50) hide show
  1. sep_python_sdk-0.1.0/CHANGELOG.md +24 -0
  2. sep_python_sdk-0.1.0/CONTRIBUTING.md +21 -0
  3. sep_python_sdk-0.1.0/LICENSE +21 -0
  4. sep_python_sdk-0.1.0/MANIFEST.in +4 -0
  5. sep_python_sdk-0.1.0/PKG-INFO +604 -0
  6. sep_python_sdk-0.1.0/README.fa.md +569 -0
  7. sep_python_sdk-0.1.0/README.md +565 -0
  8. sep_python_sdk-0.1.0/SECURITY.md +19 -0
  9. sep_python_sdk-0.1.0/examples/django_views.py +57 -0
  10. sep_python_sdk-0.1.0/examples/fastapi_app.py +89 -0
  11. sep_python_sdk-0.1.0/examples/flask_app.py +54 -0
  12. sep_python_sdk-0.1.0/examples/offline_demo.py +41 -0
  13. sep_python_sdk-0.1.0/pyproject.toml +109 -0
  14. sep_python_sdk-0.1.0/setup.cfg +4 -0
  15. sep_python_sdk-0.1.0/src/sep_python_sdk.egg-info/PKG-INFO +604 -0
  16. sep_python_sdk-0.1.0/src/sep_python_sdk.egg-info/SOURCES.txt +48 -0
  17. sep_python_sdk-0.1.0/src/sep_python_sdk.egg-info/dependency_links.txt +1 -0
  18. sep_python_sdk-0.1.0/src/sep_python_sdk.egg-info/requires.txt +10 -0
  19. sep_python_sdk-0.1.0/src/sep_python_sdk.egg-info/top_level.txt +1 -0
  20. sep_python_sdk-0.1.0/src/sep_sdk/__init__.py +90 -0
  21. sep_python_sdk-0.1.0/src/sep_sdk/_base.py +174 -0
  22. sep_python_sdk-0.1.0/src/sep_sdk/_parsing.py +184 -0
  23. sep_python_sdk-0.1.0/src/sep_sdk/_version.py +3 -0
  24. sep_python_sdk-0.1.0/src/sep_sdk/async_client.py +137 -0
  25. sep_python_sdk-0.1.0/src/sep_sdk/callback.py +52 -0
  26. sep_python_sdk-0.1.0/src/sep_sdk/client.py +155 -0
  27. sep_python_sdk-0.1.0/src/sep_sdk/constants.py +43 -0
  28. sep_python_sdk-0.1.0/src/sep_sdk/endpoints.py +65 -0
  29. sep_python_sdk-0.1.0/src/sep_sdk/enums.py +173 -0
  30. sep_python_sdk-0.1.0/src/sep_sdk/errors.py +134 -0
  31. sep_python_sdk-0.1.0/src/sep_sdk/py.typed +0 -0
  32. sep_python_sdk-0.1.0/src/sep_sdk/redirect.py +82 -0
  33. sep_python_sdk-0.1.0/src/sep_sdk/requests.py +194 -0
  34. sep_python_sdk-0.1.0/src/sep_sdk/responses.py +113 -0
  35. sep_python_sdk-0.1.0/src/sep_sdk/retry.py +39 -0
  36. sep_python_sdk-0.1.0/src/sep_sdk/security.py +50 -0
  37. sep_python_sdk-0.1.0/src/sep_sdk/testing.py +272 -0
  38. sep_python_sdk-0.1.0/src/sep_sdk/validation.py +99 -0
  39. sep_python_sdk-0.1.0/tests/__init__.py +0 -0
  40. sep_python_sdk-0.1.0/tests/conftest.py +33 -0
  41. sep_python_sdk-0.1.0/tests/helpers.py +66 -0
  42. sep_python_sdk-0.1.0/tests/test_callback_and_redirect.py +141 -0
  43. sep_python_sdk-0.1.0/tests/test_client_async.py +142 -0
  44. sep_python_sdk-0.1.0/tests/test_client_sync.py +519 -0
  45. sep_python_sdk-0.1.0/tests/test_endpoints_retry.py +47 -0
  46. sep_python_sdk-0.1.0/tests/test_enums.py +46 -0
  47. sep_python_sdk-0.1.0/tests/test_fake_and_package.py +115 -0
  48. sep_python_sdk-0.1.0/tests/test_requests.py +157 -0
  49. sep_python_sdk-0.1.0/tests/test_security.py +37 -0
  50. sep_python_sdk-0.1.0/tests/test_validation.py +99 -0
@@ -0,0 +1,24 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows
5
+ [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.1.0] - 2026-11-10
10
+
11
+ ### Added
12
+
13
+ - `SepClient` and `AsyncSepClient`: token creation, verify, reverse and the one-call
14
+ `verify_callback` (state check, receipt check, terminal check, amount check, optional
15
+ automatic reversal on a mismatch).
16
+ - `TokenRequest` with every field of the v3.6 guide: wage, cell number, token expiry,
17
+ allowed cards (MD5), four extra-data slots, `TxnRandomSessionKey`, multi-account
18
+ settlement, government payments and `GET` callbacks.
19
+ - `parse_callback`, `render_redirect_form`, `build_send_token_url` and support for terminals
20
+ on `neo-pg` (`X-IPG-Url`).
21
+ - Typed exceptions for every documented result code, `RetryPolicy` with safe-by-default
22
+ retries, and a one-time fallback between the `…Transaction` / `…Transcation` spellings.
23
+ - `sep_sdk.testing.FakeSepGateway`: an in-memory gateway for offline tests.
24
+ - Persian and English documentation.
@@ -0,0 +1,21 @@
1
+ # Contributing
2
+
3
+ Thanks for helping! This project is small on purpose: every change needs a test, and the
4
+ suite must stay at 100% line and branch coverage.
5
+
6
+ ```bash
7
+ python -m venv .venv && source .venv/bin/activate
8
+ pip install -e ".[dev]"
9
+
10
+ ruff format . && ruff check . # style
11
+ mypy # strict typing
12
+ pytest # tests + coverage gate (100%)
13
+ ```
14
+
15
+ Guidelines:
16
+
17
+ - No network in tests. Use `sep_sdk.testing.FakeSepGateway` or `httpx.MockTransport`.
18
+ - Public behaviour changes go in `CHANGELOG.md` under *Unreleased*.
19
+ - Never paste real terminal numbers, tokens, receipts or card data into issues or tests.
20
+ - Match the wording of the vendor's guide for field names; document any guess as a guess.
21
+ - Keep the English and Persian READMEs in sync.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mojtaba Sohrabi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ include LICENSE README.md README.fa.md CHANGELOG.md CONTRIBUTING.md SECURITY.md
2
+ recursive-include examples *.py
3
+ recursive-include tests *.py
4
+ recursive-include src/sep_sdk py.typed
@@ -0,0 +1,604 @@
1
+ Metadata-Version: 2.4
2
+ Name: sep-python-sdk
3
+ Version: 0.1.0
4
+ Summary: Unofficial, fully typed Python SDK for the Saman Electronic Payment (SEP) online payment gateway: token, redirect, callback, verify and reverse.
5
+ Author: Mojtaba Sohrabi
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/mojtabasohrabi/sep-python-sdk
8
+ Project-URL: Documentation, https://github.com/mojtabasohrabi/sep-python-sdk#readme
9
+ Project-URL: Source, https://github.com/mojtabasohrabi/sep-python-sdk
10
+ Project-URL: Issues, https://github.com/mojtabasohrabi/sep-python-sdk/issues
11
+ Project-URL: Changelog, https://github.com/mojtabasohrabi/sep-python-sdk/blob/main/CHANGELOG.md
12
+ Keywords: saman,sep,samanepay,shaparak,payment,payment-gateway,ipg,iran,sdk,httpx,asyncio,typed
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Classifier: Typing :: Typed
26
+ Requires-Python: >=3.10
27
+ Description-Content-Type: text/markdown
28
+ License-File: LICENSE
29
+ Requires-Dist: httpx<1,>=0.25
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest>=8; extra == "dev"
32
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
33
+ Requires-Dist: pytest-cov>=5; extra == "dev"
34
+ Requires-Dist: mypy>=1.10; extra == "dev"
35
+ Requires-Dist: ruff>=0.5; extra == "dev"
36
+ Requires-Dist: build>=1.2; extra == "dev"
37
+ Requires-Dist: twine>=5; extra == "dev"
38
+ Dynamic: license-file
39
+
40
+ # sep-python-sdk
41
+
42
+ **English** · [فارسی](README.fa.md)
43
+
44
+ [![CI](https://github.com/mojtabasohrabi/sep-python-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/mojtabasohrabi/sep-python-sdk/actions/workflows/ci.yml)
45
+ [![PyPI](https://img.shields.io/pypi/v/sep-python-sdk.svg)](https://pypi.org/project/sep-python-sdk/)
46
+ [![Python](https://img.shields.io/pypi/pyversions/sep-python-sdk.svg)](https://pypi.org/project/sep-python-sdk/)
47
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)
48
+ [![Typed](https://img.shields.io/badge/typing-strict-blue.svg)](#typing)
49
+
50
+ A typed, tested Python SDK for the **Saman Electronic Payment (SEP) online payment gateway**
51
+ (`sep.shaparak.ir`): request a token, send the customer to the bank, read the callback, verify
52
+ the money and reverse it if you must. Synchronous and `asyncio` clients, no framework required.
53
+
54
+ > **Unofficial.** This project is not affiliated with, endorsed by, or supported by Saman
55
+ > Electronic Payment. It implements the public integration guide (version 3.6). "SEP" and
56
+ > "Saman" belong to their owners.
57
+
58
+ ## Contents
59
+
60
+ - [Features](#features)
61
+ - [Install](#install)
62
+ - [How a payment works](#how-a-payment-works)
63
+ - [Quick start](#quick-start)
64
+ - [Guide](#guide)
65
+ - [1. The client](#1-the-client)
66
+ - [2. Create a token](#2-create-a-token)
67
+ - [3. Send the customer to the bank](#3-send-the-customer-to-the-bank)
68
+ - [4. Read the callback](#4-read-the-callback)
69
+ - [5. Verify the payment](#5-verify-the-payment)
70
+ - [6. Reverse a payment](#6-reverse-a-payment)
71
+ - [7. Bind the receipt to the order (session key)](#7-bind-the-receipt-to-the-order-session-key)
72
+ - [8. Multi-account and government payments](#8-multi-account-and-government-payments)
73
+ - [9. Async](#9-async)
74
+ - [10. Errors](#10-errors)
75
+ - [11. Timeouts and retries](#11-timeouts-and-retries)
76
+ - [12. Logging](#12-logging)
77
+ - [13. Testing your integration offline](#13-testing-your-integration-offline)
78
+ - [14. Go-live checklist](#14-go-live-checklist)
79
+ - [Framework examples](#framework-examples)
80
+ - [API reference](#api-reference)
81
+ - [Troubleshooting](#troubleshooting)
82
+ - [Typing](#typing)
83
+ - [Compatibility and versioning](#compatibility-and-versioning)
84
+ - [Contributing](#contributing) · [Security](#security) · [License](#license)
85
+
86
+ ## Features
87
+
88
+ - **Complete protocol coverage** — token, redirect (POST form and GET link), callback,
89
+ `VerifyTransaction`, `ReverseTransaction`, multi-account (`MultiSettle`) and government
90
+ (`Government`) payments, allowed-card lists, wage, extra data, token expiry, `GetMethod`,
91
+ `TxnRandomSessionKey`, and `neo-pg` terminals (`X-IPG-Url`).
92
+ - **Safe by construction** — `verify_callback()` refuses unpaid callbacks, checks the receipt,
93
+ the terminal **and the amount**, and can reverse a mismatched payment for you.
94
+ - **Sync and async** — `SepClient` and `AsyncSepClient` share one implementation of every rule.
95
+ - **Strictly typed** — `py.typed`, frozen dataclasses, enums, `mypy --strict` clean.
96
+ - **Honest errors** — one exception hierarchy; every documented `ResultCode` has its own class.
97
+ - **Resilient** — exponential backoff that never repeats a call that could double-charge,
98
+ and a one-time fallback between the two path spellings Saman uses in the wild.
99
+ - **Offline tests included** — `FakeSepGateway` simulates the bank so your tests never need a
100
+ terminal, a whitelisted IP or a network.
101
+ - **Tiny** — one runtime dependency: [`httpx`](https://www.python-httpx.org/). Python 3.10+.
102
+ - **Bilingual** — error and state messages in English and Persian; docs in both languages.
103
+
104
+ ## Install
105
+
106
+ ```bash
107
+ pip install sep-python-sdk
108
+ ```
109
+
110
+ You need a **terminal number** from Saman, and your server's public IP must be registered
111
+ with Saman (token and verify calls are refused otherwise).
112
+
113
+ ## How a payment works
114
+
115
+ ```
116
+ your server customer's browser Saman (SEP)
117
+ │ 1. create_token(amount, order id, callback URL) ───────────────────────►│
118
+ │◄────────────────────────────────────────────── token ────────────────────│
119
+ │ 2. page with a POST form (render_redirect_form) ─►│ │
120
+ │ │ 3. form POST ─────►│ customer pays
121
+ │ │◄── 4. redirect ─────│
122
+ │◄─ 5. POST/GET to your callback: State, RefNum, ResNum, … ────────────────│
123
+ │ 6. verify_transaction(RefNum) ──────────────────────────────────────────►│
124
+ │◄──────────────────── amount actually taken ───────────────────────────────│
125
+ │ 7. compare with YOUR order total → deliver the goods │
126
+ ```
127
+
128
+ Two rules the whole SDK is built around:
129
+
130
+ 1. **The callback is not proof.** Anyone can send your callback URL any parameters. Only the
131
+ server-to-server verify call proves the money is there.
132
+ 2. **Verify within 30 minutes**, or Saman reverses the payment by itself.
133
+
134
+ ## Quick start
135
+
136
+ ```python
137
+ from sep_sdk import SepClient, TokenRequest, parse_callback, render_redirect_form
138
+
139
+ sep = SepClient(terminal_id="12345678")
140
+
141
+ # 1. Start: one token per payment attempt. Amounts are in RIALS.
142
+ token = sep.create_token(
143
+ TokenRequest(
144
+ amount=150_000,
145
+ res_num="order-1001", # your unique order / attempt id
146
+ redirect_url="https://shop.example/payments/callback",
147
+ )
148
+ )
149
+
150
+ # 2. Send the customer to the bank: return this HTML from a page on your own site.
151
+ html = render_redirect_form(token, language="fa")
152
+
153
+ # 3. In your callback view, the bank posts the result. Parse it, then VERIFY it.
154
+ callback = parse_callback(request_form_or_query) # a dict-like object
155
+ result = sep.verify_callback(callback, expected_amount=150_000) # raises unless truly paid
156
+
157
+ # 4. Only now: mark the order paid and store result.ref_num (it must be unique).
158
+ print(result.ref_num, result.amount)
159
+ ```
160
+
161
+ Try the whole flow offline, without a terminal: `python examples/offline_demo.py`.
162
+
163
+ ---
164
+
165
+ ## Guide
166
+
167
+ ### 1. The client
168
+
169
+ ```python
170
+ from sep_sdk import SepClient
171
+
172
+ with SepClient(terminal_id="12345678") as sep: # closes the connection pool on exit
173
+ ...
174
+ ```
175
+
176
+ | Argument | Default | Meaning |
177
+ |---|---|---|
178
+ | `terminal_id` | – | Your numeric terminal number (`str` or `int`). |
179
+ | `endpoints` | production | An [`Endpoints`](#endpoints) object, to override or fake any URL. |
180
+ | `timeout` | 20 s (5 s connect) | A `float` or `httpx.Timeout`. |
181
+ | `retry` | 3 attempts | A [`RetryPolicy`](#11-timeouts-and-retries). |
182
+ | `endpoint_fallback` | `True` | Try the alternative path spelling once on HTTP 404. |
183
+ | `transport` | – | An `httpx` transport (tests, proxies). |
184
+ | `http_client` | – | Your own `httpx.Client`; you then close it. |
185
+
186
+ Create **one client per process** and reuse it; it keeps connections alive.
187
+
188
+ #### Endpoints
189
+
190
+ ```python
191
+ from sep_sdk import Endpoints, SepClient
192
+
193
+ SepClient("12345678", endpoints=Endpoints.production()) # the default
194
+ SepClient("12345678", endpoints=Endpoints.legacy()) # "…Transcation" first
195
+ SepClient("12345678", endpoints=Endpoints.without_fallback())
196
+ ```
197
+
198
+ ### 2. Create a token
199
+
200
+ ```python
201
+ from sep_sdk import TokenRequest
202
+
203
+ token = sep.create_token(
204
+ TokenRequest(
205
+ amount=150_000, # rials, integer, > 0
206
+ res_num="order-1001", # unique per attempt
207
+ redirect_url="https://shop.example/payments/callback",
208
+ )
209
+ )
210
+ token.token # the payment token (hidden from repr())
211
+ token.expires_in_minutes # 20 unless you changed it
212
+ ```
213
+
214
+ All `TokenRequest` fields (everything but the first three is optional). Invalid values raise
215
+ `SepValidationError` **before** anything is sent.
216
+
217
+ | Field | Guide name | Notes |
218
+ |---|---|---|
219
+ | `amount` | `Amount` | Rials, integer. |
220
+ | `res_num` | `ResNum` | Your order / attempt id. Make it unique: it is your only handle if the customer paid but your server never heard back. |
221
+ | `redirect_url` | `RedirectUrl` | Your callback. `http(s)`, max 2083 characters (1538 with `callback_method="GET"`). |
222
+ | `wage` | `Wage` | Fee, for merchants who split payments. |
223
+ | `cell_number` | `CellNumber` | Customer mobile, so Saman can offer saved cards. Accepts `0912…`, `+98912…`, `912…`, Persian digits. |
224
+ | `token_expiry_minutes` | `TokenExpiryInMin` | 20 – 3600. |
225
+ | `allowed_cards` | `HashedCardNumber` | Up to 10 MD5 hashes; only those cards may pay. Use `hash_card_number()`. |
226
+ | `extra_data` | `ResNum1…4` | Up to 4 strings of ≤ 50 chars, visible only in Saman's reports. |
227
+ | `session_key` | `TxnRandomSessionKey` | See [section 7](#7-bind-the-receipt-to-the-order-session-key). |
228
+ | `settlement` | `SettlementIbanInfo` | See [section 8](#8-multi-account-and-government-payments). |
229
+ | `government` | `TranType=Government` | See section 8. |
230
+ | `callback_method` | `GetMethod` | `"POST"` (default) or `"GET"`. |
231
+
232
+ ```python
233
+ from sep_sdk import hash_card_number
234
+
235
+ TokenRequest(
236
+ amount=150_000, res_num="order-1002", redirect_url="https://shop.example/cb",
237
+ cell_number="09123456789",
238
+ token_expiry_minutes=60,
239
+ allowed_cards=[hash_card_number("6037 9912 3456 7890")], # only this card may pay
240
+ extra_data=["invoice 2026-17", "campaign spring"],
241
+ )
242
+ ```
243
+
244
+ > `hash_card_number` never stores or logs the number. The guide forbids storing card numbers:
245
+ > keep only the hash.
246
+
247
+ ### 3. Send the customer to the bank
248
+
249
+ The customer's **browser** must go to Saman. Use the form, which works for every terminal:
250
+
251
+ ```python
252
+ from sep_sdk import render_redirect_form
253
+
254
+ html = render_redirect_form(token, language="fa", nonce=csp_nonce_or_None)
255
+ # return it as an HTML response from a page on YOUR site
256
+ ```
257
+
258
+ It renders a page that POSTs `Token` to the bank at once (and shows a button if JavaScript is
259
+ off). Every value is HTML-escaped; pass `nonce=` if your Content-Security-Policy needs one.
260
+
261
+ **Important:** Saman refuses visitors who arrive without a `Referer` from your site. Serve the
262
+ form from your own domain, don't send `Referrer-Policy: no-referrer` on that page (the form
263
+ already includes `<meta name="referrer" content="origin">`), and don't open the bank URL from a
264
+ mobile app directly: open your own page in a browser instead.
265
+
266
+ Prefer to template it yourself?
267
+
268
+ ```python
269
+ from sep_sdk import redirect_form_fields
270
+
271
+ redirect_form_fields(token) # {"Token": "…"} (+ "GetMethod": "true" for GET callbacks)
272
+ token.form_action # where to POST it
273
+ ```
274
+
275
+ A plain link also exists for classic terminals: `token.get_url`
276
+ (`https://sep.shaparak.ir/OnlinePG/SendToken?token=…`). Terminals moved to **neo-pg** return an
277
+ `X-IPG-Url` header; then `token.ipg_url` is set, `token.get_url` is `None`, and the form's
278
+ `action` already points at the new address.
279
+
280
+ ### 4. Read the callback
281
+
282
+ After the payment Saman redirects the customer to your `redirect_url` carrying the result
283
+ (`POST` form fields, or a query string with `callback_method="GET"`).
284
+
285
+ ```python
286
+ from sep_sdk import parse_callback
287
+
288
+ callback = parse_callback(request.POST) # Django QueryDict, Flask request.values,
289
+ # a plain dict, parse_qs(...) output…
290
+ callback.res_num # your order id (ResNum)
291
+ callback.ref_num # the bank's receipt (RefNum)
292
+ callback.state # TransactionState.OK, CANCELED_BY_USER, FAILED, …
293
+ callback.is_ok # State == OK, a receipt exists, and Status (if sent) agrees
294
+ callback.amount # what the bank *says* it took: NOT trustworthy
295
+ callback.state.message("fa") # a customer-friendly explanation (also "en")
296
+ ```
297
+
298
+ `parse_callback` makes **no network call** and trusts nothing. Names are matched
299
+ case-insensitively; list values are flattened; the card-related fields are hidden from `repr`.
300
+
301
+ | `TransactionState` | Meaning |
302
+ |---|---|
303
+ | `OK` | The bank reports a completed payment (still: verify!). |
304
+ | `CANCELED_BY_USER` | The customer cancelled. |
305
+ | `FAILED` | Payment failed. |
306
+ | `SESSION_IS_NULL` | The customer did not respond in time. |
307
+ | `INVALID_PARAMETERS` | Parameters sent to the bank were invalid. |
308
+ | `MERCHANT_IP_ADDRESS_IS_INVALID` | Your server IP is not registered with Saman. |
309
+ | `TOKEN_NOT_FOUND` / `TOKEN_REQUIRED` | Unknown token / terminal accepts token payments only. |
310
+ | `TERMINAL_NOT_FOUND` | Unknown terminal number. |
311
+ | `MULTISETTLE_POLICY_ERRORS` | Multi-account rules were violated. |
312
+ | `UNKNOWN` | A value this SDK version does not know — treated as *not paid*. |
313
+
314
+ ### 5. Verify the payment
315
+
316
+ The one call you should use:
317
+
318
+ ```python
319
+ from sep_sdk import AmountMismatchError, PaymentNotCompletedError, SepError
320
+
321
+ order = orders.get(callback.res_num) # YOUR record, found by your order id
322
+ try:
323
+ result = sep.verify_callback(
324
+ callback,
325
+ expected_amount=order.total_rials, # from your database, NEVER from the request
326
+ session_key=order.session_key, # if you used one
327
+ reverse_on_mismatch=True, # refund automatically if the amount differs
328
+ )
329
+ except PaymentNotCompletedError as error: # cancelled / failed: nothing to verify
330
+ show(error.state.message("fa"))
331
+ except AmountMismatchError as error: # the bank took another sum
332
+ log.error("tampered payment: %s vs %s, reversed=%s", error.expected, error.actual, error.reversed)
333
+ except SepError: # network, gateway refusal, bad answer…
334
+ schedule_retry_within_30_minutes(callback.ref_num)
335
+ else:
336
+ # Receipts may be used ONCE. Store ref_num in a UNIQUE column; a duplicate = replay.
337
+ order.mark_paid(receipt=result.ref_num)
338
+ ```
339
+
340
+ `verify_callback` does, in order:
341
+
342
+ 1. refuses a callback whose `State` isn't a clean `OK` (`PaymentNotCompletedError`, no network);
343
+ 2. refuses a callback for another terminal;
344
+ 3. calls `VerifyTransaction`;
345
+ 4. checks the answer is for the **same receipt and terminal**;
346
+ 5. checks the bank took exactly `expected_amount` rials (`AmountMismatchError`; with
347
+ `reverse_on_mismatch=True` it also reverses, and `error.reversed` tells you the outcome).
348
+
349
+ Need finer control? Call the primitive yourself:
350
+
351
+ ```python
352
+ result = sep.verify_transaction(callback.ref_num, session_key=order.session_key)
353
+ result.amount # OrginalAmount: compare with YOUR total
354
+ result.detail.affective_amount # what left the card (differs on discount terminals)
355
+ result.detail.trace_no, result.detail.rrn, result.detail.trace_date
356
+ result.is_duplicate # True when Saman says "this request was already processed"
357
+ ```
358
+
359
+ If the verify call fails because of the network, **retry**: Saman keeps the receipt valid and
360
+ answers a repeat with "duplicate request". The SDK already retries (see section 11); if it still
361
+ fails, try again within the 30-minute window — a background job that re-verifies unfinished
362
+ payments is a good idea.
363
+
364
+ ### 6. Reverse a payment
365
+
366
+ ```python
367
+ reversal = sep.reverse_transaction(ref_num) # up to 50 minutes after the payment
368
+ reversal.is_duplicate # True if it was already reversed
369
+ ```
370
+
371
+ ### 7. Bind the receipt to the order (session key)
372
+
373
+ `TxnRandomSessionKey` ties the later verify call to the exact payment, so a receipt from one
374
+ (cheap) order cannot be replayed on another. Two ways to get a key:
375
+
376
+ ```python
377
+ from sep_sdk import derive_session_key, generate_session_key
378
+
379
+ key = generate_session_key() # random; store it with the order
380
+ key = derive_session_key(SERVER_SECRET, order_id) # or recompute it later, nothing to store
381
+ ```
382
+
383
+ Pass it as `TokenRequest(session_key=key)` and again as
384
+ `verify_callback(..., session_key=key)`. If Saman rejects the extra field for your terminal,
385
+ simply don't send it. (The in-memory fake treats a missing or wrong key as "transaction not
386
+ found"; the real gateway's wording is not in the guide.)
387
+
388
+ ### 8. Multi-account and government payments
389
+
390
+ Needs a terminal enabled for it by Saman.
391
+
392
+ ```python
393
+ from sep_sdk import SettlementItem, TokenRequest
394
+
395
+ # Split one payment between several IBANs (at most 9 lines).
396
+ TokenRequest(
397
+ amount=12_000, res_num="order-7", redirect_url="https://shop.example/cb",
398
+ settlement=[
399
+ SettlementItem(iban="IR062960000000100324200001", amount=7_000),
400
+ SettlementItem(iban="IR000000000000000000000001", amount=5_000),
401
+ ],
402
+ )
403
+
404
+ # Government payment: the FIRST line carries the government IBAN and its 30-digit id.
405
+ TokenRequest(
406
+ amount=12_000, res_num="order-8", redirect_url="https://shop.example/cb",
407
+ government=True,
408
+ settlement=[
409
+ SettlementItem(iban="IR062960000000100324200001", amount=7_000, purchase_id="1" * 30),
410
+ SettlementItem(iban="IR000000000000000000000001", amount=5_000), # padded with 30 zeros
411
+ ],
412
+ )
413
+ ```
414
+
415
+ IBANs are checked for shape (`IR` + 24 digits); `iban_checksum_ok(iban)` is available if you
416
+ also want the mod-97 check.
417
+
418
+ ### 9. Async
419
+
420
+ `AsyncSepClient` has the same methods, awaited:
421
+
422
+ ```python
423
+ from sep_sdk import AsyncSepClient
424
+
425
+ async with AsyncSepClient(terminal_id="12345678") as sep:
426
+ token = await sep.create_token(request)
427
+ result = await sep.verify_callback(callback, expected_amount=150_000)
428
+ ```
429
+
430
+ ### 10. Errors
431
+
432
+ Everything derives from `SepError`.
433
+
434
+ ```
435
+ SepError
436
+ ├── SepConfigurationError bad terminal id / endpoints / retry policy (also ValueError)
437
+ ├── SepValidationError request rejected locally, nothing was sent (also ValueError)
438
+ ├── SepTransportError the outcome is unknown
439
+ │ ├── SepConnectionError
440
+ │ └── SepTimeoutError
441
+ ├── SepHTTPError unexpected HTTP status (.status_code)
442
+ ├── SepResponseError 2xx but not what the guide describes
443
+ ├── SepAPIError the gateway refused (.code, .description, .operation)
444
+ │ ├── SepTokenError token request refused (status = -1)
445
+ │ └── SepTransactionError verify/reverse refused (.result_code)
446
+ │ ├── TransactionNotFoundError -2
447
+ │ ├── VerifyWindowExpiredError -6 (more than 30 minutes)
448
+ │ ├── TransactionReversedError 5
449
+ │ ├── TerminalError -104 / -105
450
+ │ └── IPNotAllowedError -106
451
+ ├── PaymentNotCompletedError callback says the customer did not pay (.state)
452
+ └── AmountMismatchError the bank took another sum (.expected, .actual, .reversed)
453
+ ```
454
+
455
+ `description` is text written by the gateway: log it, don't show it to customers verbatim.
456
+ For customer-facing text use `state.message("fa" | "en")` and `ResultCode.message(...)`.
457
+
458
+ ### 11. Timeouts and retries
459
+
460
+ ```python
461
+ import httpx
462
+ from sep_sdk import RetryPolicy, SepClient
463
+
464
+ SepClient(
465
+ "12345678",
466
+ timeout=httpx.Timeout(15.0, connect=3.0),
467
+ retry=RetryPolicy(max_attempts=4, backoff_factor=0.5, max_backoff=8.0),
468
+ )
469
+ SepClient("12345678", retry=RetryPolicy.none()) # exactly one attempt
470
+ ```
471
+
472
+ | Operation | Connection failed to open | Timeout / reset / 5xx / 429 |
473
+ |---|---|---|
474
+ | `verify`, `reverse` | retried | retried (Saman answers a repeat with "duplicate request") |
475
+ | `create_token` | retried | **not retried** — the bank may already have created a session |
476
+
477
+ HTTP 404 on verify/reverse triggers a one-time switch to the alternative path spelling
478
+ (`VerifyTransaction` ↔ `VerifyTranscation`), remembered for the client's lifetime.
479
+
480
+ ### 12. Logging
481
+
482
+ The SDK logs under the `sep_sdk` logger (silent unless you configure it): debug for attempts,
483
+ warnings for retries and fallbacks, an error if an automatic reversal fails. Tokens, receipts,
484
+ card data and request bodies are **never** logged.
485
+
486
+ ```python
487
+ import logging
488
+ logging.getLogger("sep_sdk").setLevel(logging.INFO)
489
+ ```
490
+
491
+ ### 13. Testing your integration offline
492
+
493
+ `FakeSepGateway` is an in-memory bank:
494
+
495
+ ```python
496
+ from sep_sdk import SepClient, TokenRequest, parse_callback
497
+ from sep_sdk.testing import FakeSepGateway
498
+
499
+ fake = FakeSepGateway(terminal_id="12345678")
500
+ sep = SepClient("12345678", endpoints=fake.endpoints, transport=fake.transport)
501
+
502
+ token = sep.create_token(TokenRequest(amount=100_000, res_num="o-1", redirect_url="https://x.test/cb"))
503
+
504
+ fields = fake.complete_payment(token.token) # what the bank would POST to you
505
+ fields = fake.complete_payment(token.token, paid_amount=1_000) # a tampered payment
506
+ fields = fake.end_payment(token.token) # customer cancelled
507
+ fake.expire(ref_num) # 30 minutes passed: verify → -6
508
+ fake.fail_next(status_code=503, operation="verify") # HTTP error on the next verify
509
+ fake.drop_next() # lose the next call (connection error)
510
+ FakeSepGateway(reject_ip=True) # server IP not registered
511
+ FakeSepGateway(neo_pg_url="https://neo-pg.sep.test/transaction/init")
512
+ FakeSepGateway(spelling="transcation") # exercise the endpoint fallback
513
+ fake.calls # every request, for assertions
514
+ ```
515
+
516
+ It follows the **guide**, not Saman's private behaviour. Treat it as a way to exercise your own
517
+ code paths, and still run one small real payment before launch. `AsyncSepClient` accepts
518
+ `transport=fake.transport` too.
519
+
520
+ ### 14. Go-live checklist
521
+
522
+ - [ ] Your server's public IP is registered with Saman.
523
+ - [ ] `expected_amount` comes from your database; the callback's `Amount` is never used.
524
+ - [ ] Receipts (`RefNum`) are stored under a unique constraint; a repeat is a replay.
525
+ - [ ] Verification also runs from a background job for payments whose callback never arrived
526
+ (look up pending orders by `res_num` and ask the customer/Saman's report panel if needed).
527
+ - [ ] The callback endpoint is reachable by the customer's browser, accepts both `POST` and
528
+ (if you use `GET`) `GET`, is exempt from CSRF protection, and answers quickly.
529
+ - [ ] The redirect page is served from your domain without `Referrer-Policy: no-referrer`.
530
+ - [ ] A real payment of a small amount worked end to end, including a cancelled one.
531
+ - [ ] Logs contain no tokens, receipts or card data.
532
+
533
+ ---
534
+
535
+ ## Framework examples
536
+
537
+ Runnable-in-spirit, complete examples live in [`examples/`](examples):
538
+
539
+ - [`offline_demo.py`](examples/offline_demo.py) — the whole flow with the fake gateway (runs as is).
540
+ - [`fastapi_app.py`](examples/fastapi_app.py) — async, with `lifespan`.
541
+ - [`flask_app.py`](examples/flask_app.py) — synchronous.
542
+ - [`django_views.py`](examples/django_views.py) — with a unique receipt column.
543
+
544
+ ## API reference
545
+
546
+ **Clients** — `SepClient`, `AsyncSepClient`
547
+
548
+ | Method | Returns | Purpose |
549
+ |---|---|---|
550
+ | `create_token(request)` | `Token` | Start a payment. |
551
+ | `verify_transaction(ref_num, *, session_key=None)` | `TransactionResult` | Confirm a receipt. |
552
+ | `reverse_transaction(ref_num, *, session_key=None)` | `TransactionResult` | Refund. |
553
+ | `verify_callback(callback, *, expected_amount, session_key=None, reverse_on_mismatch=False)` | `TransactionResult` | The safe, complete check. |
554
+ | `render_redirect_form(token, *, language, nonce)` | `str` | HTML hand-off page. |
555
+ | `close()` / `aclose()` | – | Release connections. |
556
+
557
+ **Functions** — `parse_callback`, `render_redirect_form`, `redirect_form_fields`,
558
+ `build_send_token_url`, `hash_card_number`, `generate_session_key`, `derive_session_key`,
559
+ `iban_checksum_ok`.
560
+
561
+ **Data** — `TokenRequest`, `SettlementItem`, `Token`, `CallbackData`, `TransactionResult`,
562
+ `TransactionDetail`, `Endpoints`, `RetryPolicy`.
563
+
564
+ **Enums** — `TransactionState`, `TransactionStatus`, `ResultCode`.
565
+
566
+ **Testing** — `sep_sdk.testing.FakeSepGateway`.
567
+
568
+ ## Troubleshooting
569
+
570
+ | Symptom | Likely cause |
571
+ |---|---|
572
+ | `SepTokenError` with `code=8`, or callback `MerchantIpAddressIsInvalid` | Your server's IP is not registered with Saman. |
573
+ | `IPNotAllowedError` (`-106`) on verify | Same, for the verify service. |
574
+ | The bank page refuses to open | No `Referer`: serve the form from your own site (see section 3). |
575
+ | `TransactionNotFoundError` right after a real payment | Wrong `session_key`, or the receipt is not the one Saman issued. |
576
+ | `VerifyWindowExpiredError` | More than 30 minutes passed; Saman has reversed the payment. |
577
+ | `SepHTTPError` with status 404 | Endpoint path changed. Override `Endpoints`. |
578
+ | `AmountMismatchError` | The bank took another sum. Never deliver; refund (`reverse_on_mismatch=True`). |
579
+
580
+ ## Typing
581
+
582
+ The package ships `py.typed` and is checked with `mypy --strict`. Models are frozen,
583
+ slotted dataclasses; state and codes are enums; unions are explicit (`ResultCode | int`
584
+ where the gateway may send a code this version does not know).
585
+
586
+ ## Compatibility and versioning
587
+
588
+ Python 3.10 – 3.13. [Semantic Versioning](https://semver.org/). Built from Saman's integration
589
+ guide v3.6; a few details are not fully specified there (the exact `TxnRandomSessionKey`
590
+ semantics, the `…Transaction` vs `…Transcation` path spelling, `neo-pg` GET redirects) and are
591
+ handled defensively — please open an issue if your terminal behaves differently.
592
+
593
+ ## Contributing
594
+
595
+ See [CONTRIBUTING.md](CONTRIBUTING.md). The suite runs offline with 100% line and branch
596
+ coverage; `ruff`, `mypy --strict` and `pytest` must pass.
597
+
598
+ ## Security
599
+
600
+ Report vulnerabilities privately — see [SECURITY.md](SECURITY.md).
601
+
602
+ ## License
603
+
604
+ [MIT](LICENSE).