dominaite 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.
@@ -0,0 +1,28 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ environment: pypi
11
+ permissions:
12
+ id-token: write
13
+ contents: read
14
+ steps:
15
+ - uses: actions/checkout@v4
16
+ - uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.12"
19
+ - name: Run the test suite first
20
+ run: |
21
+ python -m pip install pytest
22
+ python -m pytest -q
23
+ - name: Build sdist and wheel
24
+ run: |
25
+ python -m pip install build
26
+ python -m build
27
+ - name: Publish (trusted publishing, no tokens)
28
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ build/
7
+ dist/
@@ -0,0 +1,206 @@
1
+ Metadata-Version: 2.5
2
+ Name: dominaite
3
+ Version: 0.1.0
4
+ Summary: Server-side Python client for the Dominaite merchant API: create hosted checkout sessions from your own backend.
5
+ License: Proprietary
6
+ Requires-Python: >=3.9
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=7.0; extra == 'dev'
9
+ Description-Content-Type: text/markdown
10
+
11
+ # dominaite-python
12
+
13
+ Server-side Python client for the Dominaite merchant API. One call from your backend opens a
14
+ hosted checkout session; a two-line script tag renders the payment widget on your page. Card
15
+ details go straight from your customer's browser into the payment widget - they never touch
16
+ your server, which keeps your PCI scope minimal (SAQ A).
17
+
18
+ Python 3.9+, standard library only. No `requests`, no framework, nothing to vendor.
19
+
20
+ ## Install
21
+
22
+ The package name is `dominaite` on PyPI (verified free 2026-08-17; matches `import dominaite`,
23
+ the same pattern Stripe uses). It is **not published yet** - until it is, install from a checkout:
24
+
25
+ ```bash
26
+ pip install /path/to/dominaite-python-sdk
27
+ # or, while you are working on the SDK itself:
28
+ pip install -e /path/to/dominaite-python-sdk
29
+ ```
30
+
31
+ ## Credentials
32
+
33
+ You get two values from the Dominaite dashboard, under **Online payments -> Website
34
+ integration**, when you create an API key. The secret is shown **once** - store both like
35
+ passwords:
36
+
37
+ - `dmk_...` - your API key id. Identifies you; not secret by itself.
38
+ - `dms_...` - your API secret. Server-side only: environment variable or a config file outside
39
+ the web root. Never in a browser, never in git, never in logs.
40
+
41
+ Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock
42
+ on NTP - signatures older than 5 minutes are rejected.
43
+
44
+ ## Quickstart against dev
45
+
46
+ Everything you need to go from nothing to a live session on the dev environment.
47
+
48
+ **1. Set your credentials.** Both come from the dashboard's Website-integration tab (dev
49
+ dashboard, dev key - a prod key will not authenticate against dev):
50
+
51
+ ```bash
52
+ export DOMINAITE_KEY_ID='dmk_...' # the key id shown on the tab
53
+ export DOMINAITE_SECRET='dms_...' # the secret shown once at key creation
54
+ export DOMINAITE_BASE_URL='https://func-dom-gw-payments-dev-gwc-01.azurewebsites.net/api'
55
+ ```
56
+
57
+ That base URL is the dev payments service. Production is
58
+ `https://api.dominaite.com/payments`, which is the SDK's default when you pass no `base_url`.
59
+
60
+ **2. Check your signing before you call anything.** This runs offline against the published
61
+ test vector and authenticates nothing, so it can never fail for credential reasons:
62
+
63
+ ```bash
64
+ python -m pytest tests/test_signing.py
65
+ ```
66
+
67
+ **3. Mint a session** (`mint.py`):
68
+
69
+ ```python
70
+ import os
71
+
72
+ from dominaite import CheckoutRefusedError, DominaiteClient, TransportError
73
+
74
+ client = DominaiteClient(
75
+ os.environ["DOMINAITE_KEY_ID"],
76
+ os.environ["DOMINAITE_SECRET"],
77
+ base_url=os.environ.get("DOMINAITE_BASE_URL", "https://api.dominaite.com/payments"),
78
+ )
79
+
80
+ try:
81
+ session = client.create_checkout_session(
82
+ amount=2500, # minor units: 2500 = 25.00 EUR
83
+ currency="EUR",
84
+ order_reference="order-1042", # your own order id, shows up in your dashboard
85
+ customer={
86
+ # Pass everything you already know - prefilled fields are hidden from the
87
+ # payer, so the checkout form stays short.
88
+ "firstName": "Ana",
89
+ "lastName": "Kirova",
90
+ "email": "ana@example.com",
91
+ },
92
+ language="bg", # widget UI language
93
+ theme="dark",
94
+ )
95
+ except CheckoutRefusedError as refusal:
96
+ # Machine-readable: refusal.error_code - see the exception docstring for the codes.
97
+ raise SystemExit("Payment unavailable: " + refusal.error_code)
98
+ except TransportError:
99
+ # Network blip - safe to retry with the same idempotency_key.
100
+ raise SystemExit("Payment temporarily unavailable")
101
+
102
+ print(session["transactionId"], session["cashierKey"], session["cashierToken"])
103
+ ```
104
+
105
+ ```bash
106
+ python mint.py
107
+ ```
108
+
109
+ A transaction id, cashier key and cashier token on stdout means the whole chain works: your
110
+ credentials, your clock, your signing, and the dev gateway.
111
+
112
+ **If it fails**, the error tells you which one:
113
+
114
+ | What you see | What is wrong |
115
+ |---|---|
116
+ | `AuthenticationError` + `INVALID_API_KEY` | Wrong or revoked key id, or a prod key against dev. |
117
+ | `AuthenticationError` + `INVALID_SIGNATURE` | Secret does not match the key id. |
118
+ | `AuthenticationError` + `TIMESTAMP_OUT_OF_RANGE` | Your machine's clock is more than 5 minutes off. |
119
+ | `AuthenticationError` + `IP_NOT_ALLOWED` | The key has an IP allowlist that does not include you. |
120
+ | `CheckoutRefusedError` | You authenticated fine; the gateway declined to open a session. |
121
+ | `TransportError` | Wrong base URL, or the service is down. Retry with the same key. |
122
+
123
+ **4. Render the widget.** Store `session["transactionId"]` against your order, then hand the
124
+ two cashier values to the page:
125
+
126
+ ```html
127
+ <div id="checkout"></div>
128
+ <script src="https://bp-checkout.dominaite.com/v2/launcher"
129
+ data-cashier-key="{{ cashier_key }}"
130
+ data-cashier-token="{{ cashier_token }}"></script>
131
+ ```
132
+
133
+ HTML-escape both when templating (Jinja's autoescape does it for you). They are per-payment
134
+ session values, not your credentials.
135
+
136
+ That's the whole integration: the session call, the script tag, and your domain bound to your
137
+ checkout by Dominaite during onboarding.
138
+
139
+ ## Amounts are minor units
140
+
141
+ `amount` is always an integer in the currency's minor unit: `2500` is 25.00 EUR. A float or a
142
+ string raises `ValueError` before anything is sent. The amount is locked server-side - what you
143
+ pass here is what gets charged; nothing in the browser can change it.
144
+
145
+ ## Retries and double-charges
146
+
147
+ Every `create_checkout_session` call carries an idempotency key (auto-generated, or pass your
148
+ own as `idempotency_key`). Retrying with the same key never opens a second payment - on a
149
+ timeout, retry with the same key rather than generating a new one.
150
+
151
+ There is a helper that does exactly that:
152
+
153
+ ```python
154
+ session = client.create_checkout_session_with_retry(
155
+ amount=2500,
156
+ currency="EUR",
157
+ order_reference="order-1042",
158
+ max_attempts=3,
159
+ )
160
+ ```
161
+
162
+ It retries only `TransportError` (network failures, 5xx, `MERCHANT_API_UNAVAILABLE`), reuses the
163
+ one key across all attempts, and backs off between them. Refusals and authentication failures
164
+ are raised immediately.
165
+
166
+ ## Sessions expire
167
+
168
+ A session is valid for 2 hours. If the payer comes back later, create a new session.
169
+
170
+ ## Status polling
171
+
172
+ ```python
173
+ status = client.get_status(session["transactionId"])
174
+ # {"transactionId": ..., "orderReference": "order-1042", "status": "succeeded",
175
+ # "amount": 2500, "currency": "EUR", ...}
176
+ ```
177
+
178
+ `status` is one of: `pending`, `processing`, `succeeded`, `failed`, `refunded`,
179
+ `partially_refunded`, `cancelled`, `disputed`, `abandoned`. While the session is still payable
180
+ the response also carries `expiresAt`; after that instant a `pending` session can only become
181
+ `abandoned`. An unknown transaction id raises `ApiError` with `http_status == 404`.
182
+
183
+ Poll after the payer returns to you, or on your order timeout - not in a tight loop; the
184
+ endpoint is rate limited per key.
185
+
186
+ ## Errors
187
+
188
+ | Exception | Means | Retry? |
189
+ |---|---|---|
190
+ | `AuthenticationError` | Bad credentials, bad signature, clock skew, IP not allowlisted | No - fix config |
191
+ | `CheckoutRefusedError` | The gateway refused to open the session (`error_code`) | Depends on the code |
192
+ | `ApiError` | Unexpected response, or a 4xx like an unknown transaction id (`http_status`) | No |
193
+ | `TransportError` | Network failure or 5xx; you don't know if it landed | Yes, same idempotency key |
194
+
195
+ All four inherit from `DominaiteError` if you only care that the call failed.
196
+
197
+ ## Running the tests
198
+
199
+ ```bash
200
+ python -m venv .venv && .venv/bin/pip install pytest
201
+ .venv/bin/python -m pytest
202
+ ```
203
+
204
+ `tests/test_signing.py` reproduces the signing test vector published on the dashboard's
205
+ Website-integration tab. If it ever fails, the SDK cannot authenticate - fix the signing, never
206
+ the expected value.
@@ -0,0 +1,196 @@
1
+ # dominaite-python
2
+
3
+ Server-side Python client for the Dominaite merchant API. One call from your backend opens a
4
+ hosted checkout session; a two-line script tag renders the payment widget on your page. Card
5
+ details go straight from your customer's browser into the payment widget - they never touch
6
+ your server, which keeps your PCI scope minimal (SAQ A).
7
+
8
+ Python 3.9+, standard library only. No `requests`, no framework, nothing to vendor.
9
+
10
+ ## Install
11
+
12
+ The package name is `dominaite` on PyPI (verified free 2026-08-17; matches `import dominaite`,
13
+ the same pattern Stripe uses). It is **not published yet** - until it is, install from a checkout:
14
+
15
+ ```bash
16
+ pip install /path/to/dominaite-python-sdk
17
+ # or, while you are working on the SDK itself:
18
+ pip install -e /path/to/dominaite-python-sdk
19
+ ```
20
+
21
+ ## Credentials
22
+
23
+ You get two values from the Dominaite dashboard, under **Online payments -> Website
24
+ integration**, when you create an API key. The secret is shown **once** - store both like
25
+ passwords:
26
+
27
+ - `dmk_...` - your API key id. Identifies you; not secret by itself.
28
+ - `dms_...` - your API secret. Server-side only: environment variable or a config file outside
29
+ the web root. Never in a browser, never in git, never in logs.
30
+
31
+ Every request is signed with the secret (HMAC-SHA256) and timestamped. Keep your server clock
32
+ on NTP - signatures older than 5 minutes are rejected.
33
+
34
+ ## Quickstart against dev
35
+
36
+ Everything you need to go from nothing to a live session on the dev environment.
37
+
38
+ **1. Set your credentials.** Both come from the dashboard's Website-integration tab (dev
39
+ dashboard, dev key - a prod key will not authenticate against dev):
40
+
41
+ ```bash
42
+ export DOMINAITE_KEY_ID='dmk_...' # the key id shown on the tab
43
+ export DOMINAITE_SECRET='dms_...' # the secret shown once at key creation
44
+ export DOMINAITE_BASE_URL='https://func-dom-gw-payments-dev-gwc-01.azurewebsites.net/api'
45
+ ```
46
+
47
+ That base URL is the dev payments service. Production is
48
+ `https://api.dominaite.com/payments`, which is the SDK's default when you pass no `base_url`.
49
+
50
+ **2. Check your signing before you call anything.** This runs offline against the published
51
+ test vector and authenticates nothing, so it can never fail for credential reasons:
52
+
53
+ ```bash
54
+ python -m pytest tests/test_signing.py
55
+ ```
56
+
57
+ **3. Mint a session** (`mint.py`):
58
+
59
+ ```python
60
+ import os
61
+
62
+ from dominaite import CheckoutRefusedError, DominaiteClient, TransportError
63
+
64
+ client = DominaiteClient(
65
+ os.environ["DOMINAITE_KEY_ID"],
66
+ os.environ["DOMINAITE_SECRET"],
67
+ base_url=os.environ.get("DOMINAITE_BASE_URL", "https://api.dominaite.com/payments"),
68
+ )
69
+
70
+ try:
71
+ session = client.create_checkout_session(
72
+ amount=2500, # minor units: 2500 = 25.00 EUR
73
+ currency="EUR",
74
+ order_reference="order-1042", # your own order id, shows up in your dashboard
75
+ customer={
76
+ # Pass everything you already know - prefilled fields are hidden from the
77
+ # payer, so the checkout form stays short.
78
+ "firstName": "Ana",
79
+ "lastName": "Kirova",
80
+ "email": "ana@example.com",
81
+ },
82
+ language="bg", # widget UI language
83
+ theme="dark",
84
+ )
85
+ except CheckoutRefusedError as refusal:
86
+ # Machine-readable: refusal.error_code - see the exception docstring for the codes.
87
+ raise SystemExit("Payment unavailable: " + refusal.error_code)
88
+ except TransportError:
89
+ # Network blip - safe to retry with the same idempotency_key.
90
+ raise SystemExit("Payment temporarily unavailable")
91
+
92
+ print(session["transactionId"], session["cashierKey"], session["cashierToken"])
93
+ ```
94
+
95
+ ```bash
96
+ python mint.py
97
+ ```
98
+
99
+ A transaction id, cashier key and cashier token on stdout means the whole chain works: your
100
+ credentials, your clock, your signing, and the dev gateway.
101
+
102
+ **If it fails**, the error tells you which one:
103
+
104
+ | What you see | What is wrong |
105
+ |---|---|
106
+ | `AuthenticationError` + `INVALID_API_KEY` | Wrong or revoked key id, or a prod key against dev. |
107
+ | `AuthenticationError` + `INVALID_SIGNATURE` | Secret does not match the key id. |
108
+ | `AuthenticationError` + `TIMESTAMP_OUT_OF_RANGE` | Your machine's clock is more than 5 minutes off. |
109
+ | `AuthenticationError` + `IP_NOT_ALLOWED` | The key has an IP allowlist that does not include you. |
110
+ | `CheckoutRefusedError` | You authenticated fine; the gateway declined to open a session. |
111
+ | `TransportError` | Wrong base URL, or the service is down. Retry with the same key. |
112
+
113
+ **4. Render the widget.** Store `session["transactionId"]` against your order, then hand the
114
+ two cashier values to the page:
115
+
116
+ ```html
117
+ <div id="checkout"></div>
118
+ <script src="https://bp-checkout.dominaite.com/v2/launcher"
119
+ data-cashier-key="{{ cashier_key }}"
120
+ data-cashier-token="{{ cashier_token }}"></script>
121
+ ```
122
+
123
+ HTML-escape both when templating (Jinja's autoescape does it for you). They are per-payment
124
+ session values, not your credentials.
125
+
126
+ That's the whole integration: the session call, the script tag, and your domain bound to your
127
+ checkout by Dominaite during onboarding.
128
+
129
+ ## Amounts are minor units
130
+
131
+ `amount` is always an integer in the currency's minor unit: `2500` is 25.00 EUR. A float or a
132
+ string raises `ValueError` before anything is sent. The amount is locked server-side - what you
133
+ pass here is what gets charged; nothing in the browser can change it.
134
+
135
+ ## Retries and double-charges
136
+
137
+ Every `create_checkout_session` call carries an idempotency key (auto-generated, or pass your
138
+ own as `idempotency_key`). Retrying with the same key never opens a second payment - on a
139
+ timeout, retry with the same key rather than generating a new one.
140
+
141
+ There is a helper that does exactly that:
142
+
143
+ ```python
144
+ session = client.create_checkout_session_with_retry(
145
+ amount=2500,
146
+ currency="EUR",
147
+ order_reference="order-1042",
148
+ max_attempts=3,
149
+ )
150
+ ```
151
+
152
+ It retries only `TransportError` (network failures, 5xx, `MERCHANT_API_UNAVAILABLE`), reuses the
153
+ one key across all attempts, and backs off between them. Refusals and authentication failures
154
+ are raised immediately.
155
+
156
+ ## Sessions expire
157
+
158
+ A session is valid for 2 hours. If the payer comes back later, create a new session.
159
+
160
+ ## Status polling
161
+
162
+ ```python
163
+ status = client.get_status(session["transactionId"])
164
+ # {"transactionId": ..., "orderReference": "order-1042", "status": "succeeded",
165
+ # "amount": 2500, "currency": "EUR", ...}
166
+ ```
167
+
168
+ `status` is one of: `pending`, `processing`, `succeeded`, `failed`, `refunded`,
169
+ `partially_refunded`, `cancelled`, `disputed`, `abandoned`. While the session is still payable
170
+ the response also carries `expiresAt`; after that instant a `pending` session can only become
171
+ `abandoned`. An unknown transaction id raises `ApiError` with `http_status == 404`.
172
+
173
+ Poll after the payer returns to you, or on your order timeout - not in a tight loop; the
174
+ endpoint is rate limited per key.
175
+
176
+ ## Errors
177
+
178
+ | Exception | Means | Retry? |
179
+ |---|---|---|
180
+ | `AuthenticationError` | Bad credentials, bad signature, clock skew, IP not allowlisted | No - fix config |
181
+ | `CheckoutRefusedError` | The gateway refused to open the session (`error_code`) | Depends on the code |
182
+ | `ApiError` | Unexpected response, or a 4xx like an unknown transaction id (`http_status`) | No |
183
+ | `TransportError` | Network failure or 5xx; you don't know if it landed | Yes, same idempotency key |
184
+
185
+ All four inherit from `DominaiteError` if you only care that the call failed.
186
+
187
+ ## Running the tests
188
+
189
+ ```bash
190
+ python -m venv .venv && .venv/bin/pip install pytest
191
+ .venv/bin/python -m pytest
192
+ ```
193
+
194
+ `tests/test_signing.py` reproduces the signing test vector published on the dashboard's
195
+ Website-integration tab. If it ever fails, the SDK cannot authenticate - fix the signing, never
196
+ the expected value.
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ # PyPI name settled as "dominaite" (verified free 2026-08-17); matches the import name.
7
+ name = "dominaite"
8
+ version = "0.1.0"
9
+ description = "Server-side Python client for the Dominaite merchant API: create hosted checkout sessions from your own backend."
10
+ requires-python = ">=3.9"
11
+ license = { text = "Proprietary" }
12
+ readme = "README.md"
13
+ dependencies = []
14
+
15
+ [project.optional-dependencies]
16
+ dev = ["pytest>=7.0"]
17
+
18
+ [tool.hatch.build.targets.wheel]
19
+ packages = ["src/dominaite"]
20
+
21
+ [tool.pytest.ini_options]
22
+ testpaths = ["tests"]
@@ -0,0 +1,29 @@
1
+ """Server-side Python client for the Dominaite merchant API."""
2
+
3
+ from .client import (
4
+ DEFAULT_BASE_URL,
5
+ SESSIONS_PATH,
6
+ DominaiteClient,
7
+ __version__,
8
+ sign_request,
9
+ )
10
+ from .exceptions import (
11
+ ApiError,
12
+ AuthenticationError,
13
+ CheckoutRefusedError,
14
+ DominaiteError,
15
+ TransportError,
16
+ )
17
+
18
+ __all__ = [
19
+ "DEFAULT_BASE_URL",
20
+ "SESSIONS_PATH",
21
+ "ApiError",
22
+ "AuthenticationError",
23
+ "CheckoutRefusedError",
24
+ "DominaiteClient",
25
+ "DominaiteError",
26
+ "TransportError",
27
+ "__version__",
28
+ "sign_request",
29
+ ]
@@ -0,0 +1,328 @@
1
+ """Server-side client for the Dominaite merchant API."""
2
+
3
+ import hashlib
4
+ import hmac
5
+ import json
6
+ import re
7
+ import secrets
8
+ import time
9
+ import urllib.error
10
+ import urllib.request
11
+ from typing import Any, Dict, Mapping, Optional
12
+
13
+ from .exceptions import (
14
+ ApiError,
15
+ AuthenticationError,
16
+ CheckoutRefusedError,
17
+ TransportError,
18
+ )
19
+
20
+ __version__ = "0.1.0"
21
+
22
+ DEFAULT_BASE_URL = "https://api.dominaite.com/payments"
23
+ SESSIONS_PATH = "/merchant-api/bridgerpay/checkout/sessions"
24
+ DEFAULT_TIMEOUT_SECONDS = 45.0 # serverless cold starts hit 10+s on dev; 15s was a coin flip
25
+
26
+ _TRANSACTION_ID_RE = re.compile(
27
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
28
+ )
29
+
30
+
31
+ def sign_request(
32
+ secret: str,
33
+ timestamp: str,
34
+ method: str,
35
+ path: str,
36
+ idempotency_key: str,
37
+ body: str,
38
+ ) -> str:
39
+ """Build the ``X-Signature`` value for one request.
40
+
41
+ Lowercase hex HMAC-SHA256 over
42
+ ``"{timestamp}\\n{METHOD}\\n{path}\\n{idempotencyKey}\\n{sha256hex(body)}"``.
43
+
44
+ The idempotency key is INSIDE the signature, so a captured request cannot be
45
+ replayed with a different key to mint extra sessions. The server rejects
46
+ timestamps more than 5 minutes off - keep your server clock on NTP.
47
+
48
+ Exposed so you can pin it in your own test against the published vector.
49
+ """
50
+ body_hash = hashlib.sha256(body.encode("utf-8")).hexdigest()
51
+ payload = "\n".join([timestamp, method.upper(), path, idempotency_key, body_hash])
52
+ return hmac.new(
53
+ secret.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256
54
+ ).hexdigest()
55
+
56
+
57
+ class DominaiteClient:
58
+ """Server-side client for the Dominaite merchant API.
59
+
60
+ Keep your API secret on the server. Never ship it to a browser, never commit it,
61
+ never log it. Card details never touch your backend or this SDK - the payer enters
62
+ them inside the hosted checkout widget.
63
+
64
+ Usage::
65
+
66
+ client = DominaiteClient(os.environ["DOMINAITE_KEY_ID"], os.environ["DOMINAITE_SECRET"])
67
+ session = client.create_checkout_session(
68
+ amount=2500, # minor units: 25.00 EUR
69
+ currency="EUR",
70
+ order_reference="order-1042",
71
+ customer={"firstName": "Ana", "lastName": "K", "email": "ana@example.com"},
72
+ )
73
+ # Hand session["cashierKey"] + session["cashierToken"] to the embed snippet.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ key_id: str,
79
+ secret: str,
80
+ base_url: str = DEFAULT_BASE_URL,
81
+ timeout: float = DEFAULT_TIMEOUT_SECONDS,
82
+ ) -> None:
83
+ """
84
+ :param key_id: Your API key id (``dmk_...``), from the Dominaite dashboard.
85
+ :param secret: Your API secret (``dms_...``). Server-side only.
86
+ :param base_url: Override for non-production environments.
87
+ :param timeout: Per-request socket timeout in seconds.
88
+ """
89
+ if not key_id.startswith("dmk_"):
90
+ raise ValueError("key_id must start with dmk_")
91
+ if not secret.startswith("dms_"):
92
+ raise ValueError("secret must start with dms_")
93
+ self._key_id = key_id
94
+ self._secret = secret
95
+ self._base_url = base_url.rstrip("/")
96
+ self._timeout = timeout
97
+
98
+ def create_checkout_session(
99
+ self,
100
+ amount: int,
101
+ currency: str,
102
+ order_reference: str,
103
+ customer: Optional[Mapping[str, Any]] = None,
104
+ country: Optional[str] = None,
105
+ language: Optional[str] = None,
106
+ theme: Optional[str] = None,
107
+ description: Optional[str] = None,
108
+ idempotency_key: Optional[str] = None,
109
+ ) -> Dict[str, Any]:
110
+ """Create a hosted checkout session for one payment.
111
+
112
+ :param amount: Integer in MINOR units (2500 = 25.00 EUR). Never a float.
113
+ :param currency: ISO 4217 code.
114
+ :param order_reference: Your own order id, 100 chars or fewer.
115
+ :param customer: ``{"firstName", "lastName", "email", "phone"}``. Pass everything
116
+ you already know - prefilled fields are hidden from the payer, so the
117
+ checkout form stays short.
118
+ :param country: ISO 3166-1 alpha-2.
119
+ :param language: ISO 639-1, the widget UI language.
120
+ :param theme: ``light``, ``dark``, or ``bright``.
121
+ :param description: Free-text description shown on the checkout.
122
+ :param idempotency_key: Auto-generated when omitted. Retrying with the same key
123
+ never creates a second payment.
124
+ :returns: ``{"transactionId", "orderId", "cashierKey", "cashierToken", "amount",
125
+ "currency", "expiresAt"}``.
126
+
127
+ :raises AuthenticationError: Wrong/revoked credentials or bad signature
128
+ (fix config; do not retry).
129
+ :raises CheckoutRefusedError: The gateway refused the session (inspect
130
+ ``error_code``).
131
+ :raises ApiError: Unexpected API response.
132
+ :raises TransportError: Network-level failure (safe to retry WITH the same
133
+ ``idempotency_key``).
134
+ """
135
+ # bool is a subclass of int in Python, so True would otherwise sail through
136
+ # and get serialized as `true`.
137
+ if isinstance(amount, bool) or not isinstance(amount, int) or amount <= 0:
138
+ raise ValueError(
139
+ "amount must be a positive integer in MINOR units (e.g. 2500 for 25.00 EUR)"
140
+ )
141
+ if not currency:
142
+ raise ValueError("currency is required")
143
+ if not order_reference:
144
+ raise ValueError("order_reference is required")
145
+
146
+ key = idempotency_key if idempotency_key is not None else secrets.token_hex(16)
147
+ if not isinstance(key, str) or not key or len(key) > 100:
148
+ raise ValueError(
149
+ "idempotency_key must be a non-empty string of at most 100 characters"
150
+ )
151
+
152
+ body: Dict[str, Any] = {
153
+ "amount": amount,
154
+ "currency": currency,
155
+ "orderReference": order_reference,
156
+ }
157
+ if customer is not None:
158
+ body["customer"] = dict(customer)
159
+ if country is not None:
160
+ body["country"] = country
161
+ if language is not None:
162
+ body["language"] = language
163
+ if theme is not None:
164
+ body["theme"] = theme
165
+ if description is not None:
166
+ body["description"] = description
167
+
168
+ response = self._request("POST", SESSIONS_PATH, body, key)
169
+
170
+ if response.get("success") is not True or "checkout" not in response:
171
+ raise CheckoutRefusedError(
172
+ str(response.get("errorCode") or "UNKNOWN"),
173
+ str(response.get("errorMessage") or "The checkout session was refused."),
174
+ )
175
+
176
+ return response["checkout"]
177
+
178
+ def create_checkout_session_with_retry(
179
+ self,
180
+ max_attempts: int = 3,
181
+ backoff_seconds: float = 0.5,
182
+ **kwargs: Any,
183
+ ) -> Dict[str, Any]:
184
+ """Create a session, retrying transport failures with the SAME idempotency key.
185
+
186
+ A ``TransportError`` (network blip, 5xx, ``MERCHANT_API_UNAVAILABLE``) leaves you
187
+ not knowing whether the request landed. Reusing the key is what makes the retry
188
+ safe: the server returns the original session instead of opening a second one.
189
+ Generating a fresh key here would be the double-charge bug this method exists to
190
+ prevent.
191
+
192
+ Refusals and authentication failures are raised immediately - retrying them just
193
+ burns time.
194
+
195
+ :param max_attempts: Total attempts including the first one.
196
+ :param backoff_seconds: Base delay; doubles after each failed attempt.
197
+ :param kwargs: Passed straight to :meth:`create_checkout_session`.
198
+ """
199
+ if max_attempts < 1:
200
+ raise ValueError("max_attempts must be at least 1")
201
+
202
+ kwargs.setdefault("idempotency_key", secrets.token_hex(16))
203
+
204
+ delay = backoff_seconds
205
+ for attempt in range(1, max_attempts + 1):
206
+ try:
207
+ return self.create_checkout_session(**kwargs)
208
+ except TransportError:
209
+ if attempt == max_attempts:
210
+ raise
211
+ if delay > 0:
212
+ time.sleep(delay)
213
+ delay *= 2
214
+
215
+ raise AssertionError("unreachable")
216
+
217
+ def get_status(self, transaction_id: str) -> Dict[str, Any]:
218
+ """Read the payment status of one of your checkout sessions.
219
+
220
+ Status values: ``pending``, ``processing``, ``succeeded``, ``failed``,
221
+ ``refunded``, ``partially_refunded``, ``cancelled``, ``disputed``,
222
+ ``abandoned``. While a session is still payable the response carries
223
+ ``expiresAt``; amounts are integers in MINOR units.
224
+
225
+ :param transaction_id: The ``transactionId`` from
226
+ :meth:`create_checkout_session`.
227
+
228
+ :raises AuthenticationError: Wrong/revoked credentials or bad signature.
229
+ :raises ApiError: Unknown transaction id (HTTP 404) or unexpected response.
230
+ :raises TransportError: Network-level failure (safe to retry).
231
+ """
232
+ normalized = transaction_id.strip().lower()
233
+ if not _TRANSACTION_ID_RE.match(normalized):
234
+ raise ValueError(
235
+ "transaction_id must be the UUID returned by create_checkout_session()"
236
+ )
237
+
238
+ return self._request("GET", SESSIONS_PATH + "/" + normalized, None, "")
239
+
240
+ def _request(
241
+ self,
242
+ method: str,
243
+ path: str,
244
+ body: Optional[Mapping[str, Any]],
245
+ idempotency_key: str,
246
+ ) -> Dict[str, Any]:
247
+ """Sign and send one request.
248
+
249
+ ``body`` is None for GET: an empty body (and an empty idempotency key) is what
250
+ gets signed.
251
+ """
252
+ if body is None:
253
+ payload = ""
254
+ else:
255
+ # Compact separators and no ASCII escaping: the bytes we hash must be the
256
+ # exact bytes we send, so the JSON is serialized once and reused.
257
+ payload = json.dumps(body, separators=(",", ":"), ensure_ascii=False)
258
+
259
+ timestamp = str(int(time.time()))
260
+ signature = sign_request(
261
+ self._secret, timestamp, method, path, idempotency_key, payload
262
+ )
263
+
264
+ headers = {
265
+ "Content-Type": "application/json",
266
+ "X-Api-Key-Id": self._key_id,
267
+ "X-Timestamp": timestamp,
268
+ "X-Signature": signature,
269
+ # Some edges block requests without a real User-Agent - always send one.
270
+ "User-Agent": "dominaite-python/" + __version__,
271
+ }
272
+ if idempotency_key:
273
+ headers["Idempotency-Key"] = idempotency_key
274
+
275
+ data = payload.encode("utf-8") if body is not None else None
276
+ request = urllib.request.Request(
277
+ self._base_url + path, data=data, headers=headers, method=method
278
+ )
279
+
280
+ try:
281
+ with urllib.request.urlopen(request, timeout=self._timeout) as response:
282
+ status = response.status
283
+ raw = response.read().decode("utf-8", errors="replace")
284
+ except urllib.error.HTTPError as error:
285
+ # 4xx/5xx: the body still carries the machine-readable code.
286
+ status = error.code
287
+ raw = error.read().decode("utf-8", errors="replace")
288
+ except (urllib.error.URLError, OSError) as error:
289
+ raise TransportError(
290
+ "Could not reach the Dominaite API: {0}".format(error)
291
+ ) from error
292
+
293
+ try:
294
+ decoded = json.loads(raw)
295
+ except ValueError:
296
+ decoded = None
297
+ if not isinstance(decoded, dict):
298
+ raise ApiError(status, "The API returned a non-JSON response")
299
+
300
+ # The gateway wraps responses as { success, data, ... }; unwrap when present.
301
+ # Error responses carry the machine-readable code at error.code.
302
+ inner = decoded.get("data")
303
+ result = inner if isinstance(inner, dict) else decoded
304
+ envelope_error = decoded.get("error")
305
+ if not isinstance(envelope_error, dict):
306
+ envelope_error = {}
307
+
308
+ if status in (401, 403):
309
+ raise AuthenticationError(
310
+ str(result.get("errorCode") or envelope_error.get("code") or "UNAUTHORIZED"),
311
+ "Authentication failed - check your key id, secret, and server clock.",
312
+ )
313
+ if status >= 500:
314
+ raise TransportError(
315
+ "The Dominaite API is unavailable (HTTP {0}); "
316
+ "retry with the same idempotency key.".format(status)
317
+ )
318
+ if status >= 400:
319
+ raise ApiError(
320
+ status,
321
+ str(
322
+ result.get("errorMessage")
323
+ or envelope_error.get("message")
324
+ or "Request rejected"
325
+ ),
326
+ )
327
+
328
+ return result
@@ -0,0 +1,63 @@
1
+ """Exceptions raised by the Dominaite merchant API client.
2
+
3
+ The split matters when you write your error handling: a CheckoutRefused means the
4
+ gateway understood you and said no, a TransportError means you do not know whether
5
+ the request landed. Only the second one is safe to retry.
6
+ """
7
+
8
+
9
+ class DominaiteError(Exception):
10
+ """Base class for every error this SDK raises.
11
+
12
+ Catch this if you only care that the payment call failed. Catch the subclasses
13
+ when you want to branch on why.
14
+ """
15
+
16
+
17
+ class ApiError(DominaiteError):
18
+ """The API answered, but with an unexpected or rejecting response."""
19
+
20
+ def __init__(self, http_status: int, message: str) -> None:
21
+ super().__init__(message)
22
+ self.http_status = http_status
23
+
24
+
25
+ class AuthenticationError(DominaiteError):
26
+ """The API rejected your credentials or signature.
27
+
28
+ Not retryable - fix the key id, secret, or server clock. Machine-readable code
29
+ on ``error_code``:
30
+
31
+ - ``INVALID_API_KEY``: wrong or revoked key id.
32
+ - ``INVALID_SIGNATURE``: your signing is wrong; re-run the test vector.
33
+ - ``TIMESTAMP_OUT_OF_RANGE``: server clock is off; fix NTP, do not retry-loop.
34
+ - ``IP_NOT_ALLOWED``: this key is locked to addresses that don't include yours.
35
+ """
36
+
37
+ def __init__(self, error_code: str, message: str) -> None:
38
+ super().__init__(message)
39
+ self.error_code = error_code
40
+
41
+
42
+ class CheckoutRefusedError(DominaiteError):
43
+ """The gateway understood the request but refused to open a checkout session.
44
+
45
+ Branch on ``error_code``:
46
+
47
+ - ``PAYMENT_PROCESSING_UNAVAILABLE``: card payments are off right now; retry later.
48
+ - ``DUPLICATE_REQUEST``: a session for this idempotency key is already open.
49
+ - ``ALREADY_PROCESSED``: this idempotency key's payment already completed.
50
+ - ``IDEMPOTENCY_KEY_REUSED``: same key sent with a DIFFERENT body; use a fresh key.
51
+ """
52
+
53
+ def __init__(self, error_code: str, message: str) -> None:
54
+ super().__init__(message)
55
+ self.error_code = error_code
56
+
57
+
58
+ class TransportError(DominaiteError):
59
+ """Network-level failure or a 5xx.
60
+
61
+ The request may or may not have reached the API. Safe to retry WITH THE SAME
62
+ idempotency key; a retried key never creates a second payment.
63
+ """
@@ -0,0 +1,5 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ # Run the suite straight from a checkout, without installing the package first.
5
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
@@ -0,0 +1,422 @@
1
+ """Contract tests for DominaiteClient: what it sends, and how it classifies answers."""
2
+
3
+ import hashlib
4
+ import io
5
+ import json
6
+ import urllib.error
7
+ import urllib.request
8
+
9
+ import pytest
10
+
11
+ from dominaite import (
12
+ SESSIONS_PATH,
13
+ ApiError,
14
+ AuthenticationError,
15
+ CheckoutRefusedError,
16
+ DominaiteClient,
17
+ TransportError,
18
+ sign_request,
19
+ )
20
+
21
+ KEY_ID = "dmk_0123456789abcdef0123456789abcdef"
22
+ SECRET = "dms_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
23
+ BASE_URL = "https://api.example.test/payments"
24
+ TRANSACTION_ID = "11111111-2222-4333-8444-555555555555"
25
+
26
+ EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
27
+
28
+ CHECKOUT = {
29
+ "transactionId": TRANSACTION_ID,
30
+ "orderId": "ord_1",
31
+ "cashierKey": "ck_1",
32
+ "cashierToken": "ct_1",
33
+ "amount": 2500,
34
+ "currency": "EUR",
35
+ "expiresAt": "2026-08-16T12:00:00Z",
36
+ }
37
+
38
+
39
+ class _Response:
40
+ def __init__(self, status, payload):
41
+ self.status = status
42
+ self._body = json.dumps(payload).encode("utf-8")
43
+
44
+ def read(self):
45
+ return self._body
46
+
47
+ def __enter__(self):
48
+ return self
49
+
50
+ def __exit__(self, *exc):
51
+ return False
52
+
53
+
54
+ class _Recorder:
55
+ """Stands in for urlopen and records every request the client builds."""
56
+
57
+ def __init__(self, *outcomes):
58
+ self.outcomes = list(outcomes)
59
+ self.requests = []
60
+
61
+ def __call__(self, request, timeout=None):
62
+ self.requests.append(request)
63
+ outcome = self.outcomes.pop(0) if len(self.outcomes) > 1 else self.outcomes[0]
64
+ if isinstance(outcome, Exception):
65
+ raise outcome
66
+ status, payload = outcome
67
+ if status >= 400:
68
+ raise urllib.error.HTTPError(
69
+ request.full_url,
70
+ status,
71
+ "error",
72
+ {},
73
+ io.BytesIO(json.dumps(payload).encode("utf-8")),
74
+ )
75
+ return _Response(status, payload)
76
+
77
+ @property
78
+ def last(self):
79
+ return self.requests[-1]
80
+
81
+
82
+ @pytest.fixture
83
+ def client():
84
+ return DominaiteClient(KEY_ID, SECRET, base_url=BASE_URL)
85
+
86
+
87
+ @pytest.fixture
88
+ def urlopen(monkeypatch):
89
+ def install(*outcomes):
90
+ recorder = _Recorder(*outcomes)
91
+ monkeypatch.setattr(urllib.request, "urlopen", recorder)
92
+ return recorder
93
+
94
+ return install
95
+
96
+
97
+ def _ok():
98
+ return (200, {"success": True, "checkout": CHECKOUT})
99
+
100
+
101
+ def _headers(request):
102
+ # urllib title-cases header names on the Request object.
103
+ return {name.lower(): value for name, value in request.headers.items()}
104
+
105
+
106
+ # --- constructor -------------------------------------------------------------
107
+
108
+
109
+ def test_rejects_key_id_without_dmk_prefix():
110
+ with pytest.raises(ValueError, match="dmk_"):
111
+ DominaiteClient("nope", SECRET)
112
+
113
+
114
+ def test_rejects_secret_without_dms_prefix():
115
+ with pytest.raises(ValueError, match="dms_"):
116
+ DominaiteClient(KEY_ID, "nope")
117
+
118
+
119
+ # --- what goes on the wire ---------------------------------------------------
120
+
121
+
122
+ def test_post_sends_signed_headers_and_compact_json(client, urlopen):
123
+ recorder = urlopen(_ok())
124
+
125
+ client.create_checkout_session(
126
+ amount=2500,
127
+ currency="EUR",
128
+ order_reference="order-1042",
129
+ idempotency_key="00000000-0000-4000-8000-000000000001",
130
+ )
131
+
132
+ request = recorder.last
133
+ headers = _headers(request)
134
+ assert request.full_url == BASE_URL + SESSIONS_PATH
135
+ assert request.get_method() == "POST"
136
+ assert headers["x-api-key-id"] == KEY_ID
137
+ assert headers["idempotency-key"] == "00000000-0000-4000-8000-000000000001"
138
+ assert headers["content-type"] == "application/json"
139
+
140
+ body = request.data.decode("utf-8")
141
+ assert body == '{"amount":2500,"currency":"EUR","orderReference":"order-1042"}'
142
+
143
+ expected = sign_request(
144
+ SECRET,
145
+ headers["x-timestamp"],
146
+ "POST",
147
+ SESSIONS_PATH,
148
+ "00000000-0000-4000-8000-000000000001",
149
+ body,
150
+ )
151
+ assert headers["x-signature"] == expected
152
+
153
+
154
+ def test_post_reproduces_the_published_vector_end_to_end(client, urlopen, monkeypatch):
155
+ """The signature the client actually puts on the wire, against the published vector.
156
+
157
+ test_signing.py pins the sign_request() function; this pins the whole path -
158
+ argument order, JSON serialization, header assembly - to the same answer.
159
+ """
160
+ monkeypatch.setattr("dominaite.client.time.time", lambda: 1755302400)
161
+ recorder = urlopen(_ok())
162
+
163
+ client.create_checkout_session(
164
+ amount=2500,
165
+ currency="EUR",
166
+ order_reference="order-1042",
167
+ idempotency_key="00000000-0000-4000-8000-000000000001",
168
+ )
169
+
170
+ headers = _headers(recorder.last)
171
+ assert headers["x-timestamp"] == "1755302400"
172
+ assert (
173
+ headers["x-signature"]
174
+ == "95759958a0a0a9bd3e6e37101c01e8e7fee1166406e4ac2ff488764f5f742cbf"
175
+ )
176
+
177
+
178
+ def test_get_status_signs_empty_idempotency_key_and_empty_body(client, urlopen):
179
+ recorder = urlopen((200, {"transactionId": TRANSACTION_ID, "status": "succeeded"}))
180
+
181
+ client.get_status(TRANSACTION_ID)
182
+
183
+ request = recorder.last
184
+ headers = _headers(request)
185
+ path = SESSIONS_PATH + "/" + TRANSACTION_ID
186
+
187
+ assert request.get_method() == "GET"
188
+ assert request.data is None
189
+ assert "idempotency-key" not in headers
190
+
191
+ # The signed payload uses an EMPTY idempotency key and the hash of an EMPTY body.
192
+ expected = sign_request(SECRET, headers["x-timestamp"], "GET", path, "", "")
193
+ assert headers["x-signature"] == expected
194
+
195
+ payload = "\n".join([headers["x-timestamp"], "GET", path, "", EMPTY_SHA256])
196
+ assert payload.count("\n") == 4
197
+
198
+
199
+ def test_get_status_normalizes_transaction_id_casing(client, urlopen):
200
+ recorder = urlopen((200, {"transactionId": TRANSACTION_ID}))
201
+
202
+ client.get_status(" " + TRANSACTION_ID.upper() + " ")
203
+
204
+ assert recorder.last.full_url.endswith(SESSIONS_PATH + "/" + TRANSACTION_ID)
205
+
206
+
207
+ def test_get_status_rejects_non_uuid(client):
208
+ with pytest.raises(ValueError, match="UUID"):
209
+ client.get_status("order-1042")
210
+
211
+
212
+ def test_optional_fields_are_omitted_when_not_passed(client, urlopen):
213
+ recorder = urlopen(_ok())
214
+
215
+ client.create_checkout_session(
216
+ amount=2500,
217
+ currency="EUR",
218
+ order_reference="order-1042",
219
+ customer={"firstName": "Ana", "email": "ana@example.com"},
220
+ language="bg",
221
+ )
222
+
223
+ body = json.loads(recorder.last.data.decode("utf-8"))
224
+ assert body["customer"] == {"firstName": "Ana", "email": "ana@example.com"}
225
+ assert body["language"] == "bg"
226
+ assert "theme" not in body
227
+ assert "country" not in body
228
+ assert "description" not in body
229
+
230
+
231
+ def test_generates_an_idempotency_key_when_none_is_given(client, urlopen):
232
+ recorder = urlopen(_ok())
233
+
234
+ client.create_checkout_session(amount=2500, currency="EUR", order_reference="o-1")
235
+ client.create_checkout_session(amount=2500, currency="EUR", order_reference="o-2")
236
+
237
+ first = _headers(recorder.requests[0])["idempotency-key"]
238
+ second = _headers(recorder.requests[1])["idempotency-key"]
239
+ assert first and second and first != second
240
+
241
+
242
+ # --- amounts -----------------------------------------------------------------
243
+
244
+
245
+ @pytest.mark.parametrize("amount", [25.0, "2500", 0, -1, True])
246
+ def test_amount_must_be_a_positive_integer_in_minor_units(client, amount):
247
+ with pytest.raises(ValueError, match="MINOR units"):
248
+ client.create_checkout_session(
249
+ amount=amount, currency="EUR", order_reference="order-1042"
250
+ )
251
+
252
+
253
+ # --- refusal vs transport ----------------------------------------------------
254
+
255
+
256
+ def test_business_refusal_raises_checkout_refused_with_the_error_code(client, urlopen):
257
+ urlopen(
258
+ (
259
+ 200,
260
+ {
261
+ "success": False,
262
+ "errorCode": "PAYMENT_PROCESSING_UNAVAILABLE",
263
+ "errorMessage": "Card payments are off",
264
+ },
265
+ )
266
+ )
267
+
268
+ with pytest.raises(CheckoutRefusedError) as caught:
269
+ client.create_checkout_session(
270
+ amount=2500, currency="EUR", order_reference="order-1042"
271
+ )
272
+
273
+ assert caught.value.error_code == "PAYMENT_PROCESSING_UNAVAILABLE"
274
+
275
+
276
+ def test_503_raises_transport_not_refusal(client, urlopen):
277
+ urlopen((503, {"errorCode": "MERCHANT_API_UNAVAILABLE"}))
278
+
279
+ with pytest.raises(TransportError):
280
+ client.create_checkout_session(
281
+ amount=2500, currency="EUR", order_reference="order-1042"
282
+ )
283
+
284
+
285
+ def test_network_failure_raises_transport(client, urlopen):
286
+ urlopen(urllib.error.URLError("connection reset"))
287
+
288
+ with pytest.raises(TransportError):
289
+ client.create_checkout_session(
290
+ amount=2500, currency="EUR", order_reference="order-1042"
291
+ )
292
+
293
+
294
+ @pytest.mark.parametrize(
295
+ "code",
296
+ ["INVALID_API_KEY", "INVALID_SIGNATURE", "TIMESTAMP_OUT_OF_RANGE", "IP_NOT_ALLOWED"],
297
+ )
298
+ def test_401_raises_authentication_with_the_error_code(client, urlopen, code):
299
+ urlopen((401, {"errorCode": code}))
300
+
301
+ with pytest.raises(AuthenticationError) as caught:
302
+ client.create_checkout_session(
303
+ amount=2500, currency="EUR", order_reference="order-1042"
304
+ )
305
+
306
+ assert caught.value.error_code == code
307
+
308
+
309
+ def test_422_key_reuse_raises_api_error_not_transport(client, urlopen):
310
+ urlopen((422, {"errorCode": "IDEMPOTENCY_KEY_REUSED", "errorMessage": "Use a fresh key"}))
311
+
312
+ with pytest.raises(ApiError) as caught:
313
+ client.create_checkout_session(
314
+ amount=2500, currency="EUR", order_reference="order-1042"
315
+ )
316
+
317
+ assert caught.value.http_status == 422
318
+
319
+
320
+ def test_non_json_response_raises_api_error(client, urlopen, monkeypatch):
321
+ class _Garbage(_Response):
322
+ def read(self):
323
+ return b"<html>502 Bad Gateway</html>"
324
+
325
+ monkeypatch.setattr(
326
+ urllib.request, "urlopen", lambda request, timeout=None: _Garbage(200, {})
327
+ )
328
+
329
+ with pytest.raises(ApiError):
330
+ client.create_checkout_session(
331
+ amount=2500, currency="EUR", order_reference="order-1042"
332
+ )
333
+
334
+
335
+ def test_envelope_wrapped_response_is_unwrapped(client, urlopen):
336
+ urlopen((200, {"success": True, "data": {"success": True, "checkout": CHECKOUT}}))
337
+
338
+ session = client.create_checkout_session(
339
+ amount=2500, currency="EUR", order_reference="order-1042"
340
+ )
341
+
342
+ assert session == CHECKOUT
343
+
344
+
345
+ # --- retry with the same key -------------------------------------------------
346
+
347
+
348
+ def test_retry_helper_reuses_the_same_idempotency_key(client, urlopen):
349
+ recorder = urlopen((503, {"errorCode": "MERCHANT_API_UNAVAILABLE"}), _ok())
350
+
351
+ session = client.create_checkout_session_with_retry(
352
+ amount=2500,
353
+ currency="EUR",
354
+ order_reference="order-1042",
355
+ max_attempts=2,
356
+ backoff_seconds=0,
357
+ )
358
+
359
+ assert session == CHECKOUT
360
+ assert len(recorder.requests) == 2
361
+ keys = {_headers(r)["idempotency-key"] for r in recorder.requests}
362
+ assert len(keys) == 1, "a retry must not mint a new key - that is the double-charge bug"
363
+
364
+
365
+ def test_retry_helper_honours_a_caller_supplied_key(client, urlopen):
366
+ recorder = urlopen((503, {}), _ok())
367
+
368
+ client.create_checkout_session_with_retry(
369
+ amount=2500,
370
+ currency="EUR",
371
+ order_reference="order-1042",
372
+ idempotency_key="my-own-key",
373
+ max_attempts=2,
374
+ backoff_seconds=0,
375
+ )
376
+
377
+ assert all(_headers(r)["idempotency-key"] == "my-own-key" for r in recorder.requests)
378
+
379
+
380
+ def test_retry_helper_does_not_retry_a_refusal(client, urlopen):
381
+ recorder = urlopen((200, {"success": False, "errorCode": "ALREADY_PROCESSED"}))
382
+
383
+ with pytest.raises(CheckoutRefusedError):
384
+ client.create_checkout_session_with_retry(
385
+ amount=2500,
386
+ currency="EUR",
387
+ order_reference="order-1042",
388
+ max_attempts=3,
389
+ backoff_seconds=0,
390
+ )
391
+
392
+ assert len(recorder.requests) == 1
393
+
394
+
395
+ def test_retry_helper_does_not_retry_an_auth_failure(client, urlopen):
396
+ recorder = urlopen((401, {"errorCode": "INVALID_SIGNATURE"}))
397
+
398
+ with pytest.raises(AuthenticationError):
399
+ client.create_checkout_session_with_retry(
400
+ amount=2500,
401
+ currency="EUR",
402
+ order_reference="order-1042",
403
+ max_attempts=3,
404
+ backoff_seconds=0,
405
+ )
406
+
407
+ assert len(recorder.requests) == 1
408
+
409
+
410
+ def test_retry_helper_gives_up_and_raises_the_transport_error(client, urlopen):
411
+ recorder = urlopen(urllib.error.URLError("down"))
412
+
413
+ with pytest.raises(TransportError):
414
+ client.create_checkout_session_with_retry(
415
+ amount=2500,
416
+ currency="EUR",
417
+ order_reference="order-1042",
418
+ max_attempts=3,
419
+ backoff_seconds=0,
420
+ )
421
+
422
+ assert len(recorder.requests) == 3
@@ -0,0 +1,63 @@
1
+ """Known-answer tests for the request signature.
2
+
3
+ The vector is the one published on the Website-integration tab and pinned in
4
+ web-platform's `SIGNING_TEST_VECTOR`. If these fail, the SDK cannot authenticate
5
+ against the gateway - fix the signing, never the expected value.
6
+
7
+ The secret below is a dummy from the public docs. It authenticates nothing.
8
+ """
9
+
10
+ import hashlib
11
+
12
+ from dominaite import SESSIONS_PATH, sign_request
13
+
14
+ SECRET = "dms_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
15
+ TIMESTAMP = "1755302400"
16
+ IDEMPOTENCY_KEY = "00000000-0000-4000-8000-000000000001"
17
+ BODY = '{"amount":2500,"currency":"EUR","orderReference":"order-1042"}'
18
+ EXPECTED_BODY_SHA256 = "aa3edd72cd1829f4e053abb048b08c1ae91c2d67b08955997c4b6c4dab4f98ff"
19
+ EXPECTED_SIGNATURE = "95759958a0a0a9bd3e6e37101c01e8e7fee1166406e4ac2ff488764f5f742cbf"
20
+
21
+
22
+ def test_body_hash_matches_published_vector():
23
+ assert hashlib.sha256(BODY.encode("utf-8")).hexdigest() == EXPECTED_BODY_SHA256
24
+
25
+
26
+ def test_post_signature_matches_published_vector():
27
+ signature = sign_request(
28
+ secret=SECRET,
29
+ timestamp=TIMESTAMP,
30
+ method="POST",
31
+ path=SESSIONS_PATH,
32
+ idempotency_key=IDEMPOTENCY_KEY,
33
+ body=BODY,
34
+ )
35
+
36
+ assert signature == EXPECTED_SIGNATURE
37
+
38
+
39
+ def test_signature_is_lowercase_hex():
40
+ signature = sign_request(SECRET, TIMESTAMP, "POST", SESSIONS_PATH, IDEMPOTENCY_KEY, BODY)
41
+
42
+ assert signature == signature.lower()
43
+ assert len(signature) == 64
44
+
45
+
46
+ def test_method_is_uppercased_before_signing():
47
+ lower = sign_request(SECRET, TIMESTAMP, "post", SESSIONS_PATH, IDEMPOTENCY_KEY, BODY)
48
+
49
+ assert lower == EXPECTED_SIGNATURE
50
+
51
+
52
+ def test_changing_the_idempotency_key_changes_the_signature():
53
+ """The key is inside the signature, which is what makes replay harmless."""
54
+ other = sign_request(
55
+ SECRET,
56
+ TIMESTAMP,
57
+ "POST",
58
+ SESSIONS_PATH,
59
+ "00000000-0000-4000-8000-000000000002",
60
+ BODY,
61
+ )
62
+
63
+ assert other != EXPECTED_SIGNATURE