python-getpaid-paynow 3.0.0a5__tar.gz → 3.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 (47) hide show
  1. python_getpaid_paynow-3.1.0/.github/release-drafter.yml +31 -0
  2. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.github/workflows/ci.yml +7 -1
  3. python_getpaid_paynow-3.1.0/.github/workflows/release.yml +70 -0
  4. python_getpaid_paynow-3.1.0/.python-version +1 -0
  5. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/PKG-INFO +6 -5
  6. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/README.md +1 -1
  7. python_getpaid_paynow-3.1.0/docs/changelog.md +28 -0
  8. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/concepts.md +16 -0
  9. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/pyproject.toml +17 -5
  10. python_getpaid_paynow-3.1.0/src/getpaid_paynow/__init__.py +11 -0
  11. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/client.py +146 -53
  12. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/processor.py +27 -6
  13. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/types.py +1 -6
  14. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/conftest.py +0 -1
  15. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_callback.py +0 -1
  16. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_client.py +131 -0
  17. python_getpaid_paynow-3.1.0/tests/test_entry_points.py +27 -0
  18. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_processor.py +85 -1
  19. python_getpaid_paynow-3.1.0/tests/test_public_api.py +33 -0
  20. python_getpaid_paynow-3.0.0a5/docs/changelog.md +0 -19
  21. python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/__init__.py +0 -21
  22. python_getpaid_paynow-3.0.0a5/tests/test_public_api.py +0 -18
  23. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.gitignore +0 -0
  24. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.pre-commit-config.yaml +0 -0
  25. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.readthedocs.yml +0 -0
  26. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/CODE_OF_CONDUCT.md +0 -0
  27. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/CONTRIBUTING.md +0 -0
  28. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/LICENSE +0 -0
  29. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/codeofconduct.md +0 -0
  30. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/conf.py +0 -0
  31. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/configuration.md +0 -0
  32. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/contributing.md +0 -0
  33. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/getting-started.md +0 -0
  34. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/index.md +0 -0
  35. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/license.md +0 -0
  36. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/reference.md +0 -0
  37. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/requirements.txt +0 -0
  38. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/py.typed +0 -0
  39. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/__init__.py +0 -0
  40. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/plugin.py +0 -0
  41. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/routes.py +0 -0
  42. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/signing.py +0 -0
  43. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/transitions.py +0 -0
  44. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/webhooks.py +0 -0
  45. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/__init__.py +0 -0
  46. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_simulator_plugin.py +0 -0
  47. {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_types.py +0 -0
@@ -0,0 +1,31 @@
1
+ name-template: 'v$RESOLVED_VERSION'
2
+ tag-template: 'v$RESOLVED_VERSION'
3
+ categories:
4
+ - title: ':boom: Breaking Changes'
5
+ label: 'breaking'
6
+ - title: ':rocket: Features'
7
+ label: 'enhancement'
8
+ - title: ':fire: Removals and Deprecations'
9
+ label: 'removal'
10
+ - title: ':beetle: Fixes'
11
+ label: 'bug'
12
+ - title: ':racehorse: Performance'
13
+ label: 'performance'
14
+ - title: ':rotating_light: Testing'
15
+ label: 'testing'
16
+ - title: ':construction_worker: Continuous Integration'
17
+ label: 'ci'
18
+ - title: ':books: Documentation'
19
+ label: 'documentation'
20
+ - title: ':hammer: Refactoring'
21
+ label: 'refactoring'
22
+ - title: ':lipstick: Style'
23
+ label: 'style'
24
+ - title: ':package: Dependencies'
25
+ labels:
26
+ - 'dependencies'
27
+ - 'build'
28
+ template: |
29
+ ## Changes
30
+
31
+ $CHANGES
@@ -29,5 +29,11 @@ jobs:
29
29
  - name: Lint with ruff
30
30
  run: uv run ruff check .
31
31
 
32
+ - name: Type check with ty
33
+ run: uv run ty check
34
+
35
+ - name: Audit dependencies
36
+ run: uv run pip-audit --strict
37
+
32
38
  - name: Run tests
33
- run: uv run pytest --tb=short
39
+ run: uv run pytest --tb=short --ignore tests/test_simulator_plugin.py
@@ -0,0 +1,70 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+
9
+ jobs:
10
+ release:
11
+ name: Release
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - name: Check out the repository
15
+ uses: actions/checkout@v4
16
+ with:
17
+ fetch-depth: 0 # Full history needed for version detection
18
+
19
+ - name: Set up Python
20
+ uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install uv
25
+ run: pip install uv
26
+
27
+ # Version is read from __init__.py (dynamic via hatch)
28
+ - name: Detect version from __init__.py
29
+ id: get-version
30
+ run: |
31
+ init_py=$(grep -A2 '\[tool\.hatch\.version\]' pyproject.toml | grep '^path\s*=' | head -1 | sed -E "s/path\s*=\s*['\"]([^'\"]+)['\"].*/\1/")
32
+ version=$(grep '__version__' "$init_py" | head -1 | sed -E "s/.*= ['\"]([^'\"]+)['\"].*/\1/")
33
+ echo "version=$version" >> "$GITHUB_OUTPUT"
34
+
35
+ - name: Check if tag already exists
36
+ id: check-tag
37
+ run: |
38
+ tag="v${{ steps.get-version.outputs.version }}"
39
+ if git rev-parse "$tag" >/dev/null 2>&1; then
40
+ echo "already_tagged=true" >> "$GITHUB_OUTPUT"
41
+ else
42
+ echo "already_tagged=false" >> "$GITHUB_OUTPUT"
43
+ fi
44
+
45
+ - name: Tag new version
46
+ if: steps.check-tag.outputs.already_tagged == 'false'
47
+ run: |
48
+ tag="v${{ steps.get-version.outputs.version }}"
49
+ git config user.name "github-actions[bot]"
50
+ git config user.email "github-actions[bot]@users.noreply.github.com"
51
+ git tag -a "$tag" -m "Release $tag"
52
+ git push origin "$tag"
53
+
54
+ - name: Build package
55
+ run: uv build
56
+
57
+ - name: Publish package on PyPI
58
+ uses: pypa/gh-action-pypi-publish@release/v1
59
+ with:
60
+ password: ${{ secrets.PYPI_TOKEN }}
61
+
62
+ - name: Publish the release notes
63
+ if: steps.check-tag.outputs.already_tagged == 'false'
64
+ uses: release-drafter/release-drafter@v5.20.0
65
+ with:
66
+ publish: true
67
+ name: v${{ steps.get-version.outputs.version }}
68
+ tag: v${{ steps.get-version.outputs.version }}
69
+ env:
70
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -0,0 +1 @@
1
+ 3.12
@@ -1,14 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-getpaid-paynow
3
- Version: 3.0.0a5
3
+ Version: 3.1.0
4
4
  Summary: Paynow payment gateway integration for python-getpaid ecosystem.
5
5
  Project-URL: Homepage, https://github.com/django-getpaid/python-getpaid-paynow
6
6
  Project-URL: Repository, https://github.com/django-getpaid/python-getpaid-paynow
7
+ Project-URL: Documentation, https://python-getpaid-paynow.readthedocs.io
7
8
  Project-URL: Changelog, https://github.com/django-getpaid/python-getpaid-paynow/releases
8
9
  Author-email: Dominik Kozaczko <dominik@kozaczko.info>
9
10
  License: MIT
10
11
  License-File: LICENSE
11
- Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Development Status :: 5 - Production/Stable
12
13
  Classifier: Intended Audience :: Developers
13
14
  Classifier: License :: OSI Approved :: MIT License
14
15
  Classifier: Programming Language :: Python :: 3.12
@@ -18,10 +19,10 @@ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
18
19
  Classifier: Typing :: Typed
19
20
  Requires-Python: >=3.12
20
21
  Requires-Dist: httpx>=0.27.0
21
- Requires-Dist: python-getpaid-core>=3.0.0a4
22
+ Requires-Dist: python-getpaid-core>=3.0.0
22
23
  Provides-Extra: simulator
23
24
  Requires-Dist: litestar>=2.0; extra == 'simulator'
24
- Requires-Dist: python-getpaid-simulator>=3.0.0a3; extra == 'simulator'
25
+ Requires-Dist: python-getpaid-simulator>=3.0.0; extra == 'simulator'
25
26
  Description-Content-Type: text/markdown
26
27
 
27
28
  # python-getpaid-paynow
@@ -169,7 +170,7 @@ GETPAID_BACKEND_SETTINGS = {
169
170
  ## Requirements
170
171
 
171
172
  - Python 3.12+
172
- - `python-getpaid-core >= 3.0.0a4`
173
+ - `python-getpaid-core >= 3.0.0`
173
174
  - `httpx >= 0.27.0`
174
175
 
175
176
  ## Links
@@ -143,7 +143,7 @@ GETPAID_BACKEND_SETTINGS = {
143
143
  ## Requirements
144
144
 
145
145
  - Python 3.12+
146
- - `python-getpaid-core >= 3.0.0a4`
146
+ - `python-getpaid-core >= 3.0.0`
147
147
  - `httpx >= 0.27.0`
148
148
 
149
149
  ## Links
@@ -0,0 +1,28 @@
1
+ # Changelog
2
+
3
+ ## v3.0.0 (2026-06-04)
4
+
5
+ Stable release of the Paynow payment gateway integration.
6
+
7
+ ### Breaking Changes
8
+
9
+ - Version bumped from `3.0.0a4` to `3.0.0` (stable).
10
+ - Development status changed from `Alpha` to `Production/Stable`.
11
+ - Core dependency floor raised to `>=3.0.0` (from `>=3.0.0a4`).
12
+
13
+ ### Features
14
+
15
+ - Full Paynow V3 REST API coverage with async HTTP client.
16
+ - HMAC-SHA256 request and notification signature verification.
17
+ - Payment creation with redirect URL support.
18
+ - Notification (PUSH) callback handling with semantic payment updates.
19
+ - Status polling (PULL) via API.
20
+ - Refund lifecycle: create, check status, cancel.
21
+ - Payment methods retrieval.
22
+ - Simulator plugin for local testing via `python-getpaid-simulator`.
23
+ - Support for 4 currencies: PLN, EUR, USD, GBP.
24
+
25
+ ### Migration from alpha
26
+
27
+ - Update dependency from `python-getpaid-paynow>=3.0.0a4` to `python-getpaid-paynow>=3.0.0`.
28
+ - No API changes — all public interfaces remain stable.
@@ -145,6 +145,22 @@ grosze for PLN, cents for EUR). The client handles conversion automatically:
145
145
  - `PaynowClient._to_lowest_unit(Decimal("49.99"))` → `4999`
146
146
  - `PaynowClient._from_lowest_unit(4999)` → `Decimal("49.99")`
147
147
 
148
+ ## Refund Notifications
149
+
150
+ Paynow may send webhook notifications for **refund status changes** (NEW, PENDING,
151
+ SUCCESSFUL, FAILED, CANCELLED). This plugin does **not** process refund
152
+ notifications — the `handle_callback()` method only maps payment statuses.
153
+
154
+ If your application needs to react to refund status changes, implement a
155
+ custom webhook handler that calls `PaynowClient.get_refund_status()` to poll
156
+ the current state.
157
+
158
+ :::{note}
159
+ This is a known limitation. Future versions may add refund notification
160
+ support when the core `BaseProcessor` provides a dedicated refund callback
161
+ hook.
162
+ :::
163
+
148
164
  ## PUSH vs PULL Status Checking
149
165
 
150
166
  The plugin supports both notification models:
@@ -9,7 +9,7 @@ authors = [
9
9
  ]
10
10
  requires-python = '>=3.12'
11
11
  classifiers = [
12
- 'Development Status :: 3 - Alpha',
12
+ 'Development Status :: 5 - Production/Stable',
13
13
  'Intended Audience :: Developers',
14
14
  'License :: OSI Approved :: MIT License',
15
15
  'Programming Language :: Python :: 3.12',
@@ -19,13 +19,13 @@ classifiers = [
19
19
  'Typing :: Typed',
20
20
  ]
21
21
  dependencies = [
22
- 'python-getpaid-core>=3.0.0a4',
22
+ 'python-getpaid-core>=3.0.0',
23
23
  'httpx>=0.27.0',
24
24
  ]
25
25
 
26
26
  [project.optional-dependencies]
27
27
  simulator = [
28
- 'python-getpaid-simulator>=3.0.0a3',
28
+ 'python-getpaid-simulator>=3.0.0',
29
29
  'litestar>=2.0',
30
30
  ]
31
31
 
@@ -41,11 +41,13 @@ dev = [
41
41
  'furo>=2024.8.6',
42
42
  'sphinx>=8.0',
43
43
  'myst-parser>=4.0',
44
+ 'pip-audit>=2.7.0',
44
45
  ]
45
46
 
46
47
  [project.urls]
47
48
  Homepage = 'https://github.com/django-getpaid/python-getpaid-paynow'
48
49
  Repository = 'https://github.com/django-getpaid/python-getpaid-paynow'
50
+ Documentation = 'https://python-getpaid-paynow.readthedocs.io'
49
51
  Changelog = 'https://github.com/django-getpaid/python-getpaid-paynow/releases'
50
52
 
51
53
  [project.entry-points."getpaid.backends"]
@@ -120,8 +122,18 @@ include = ['tests/**']
120
122
  unresolved-attribute = 'ignore'
121
123
  invalid-argument-type = 'ignore'
122
124
 
125
+ # processor.py no longer needs overrides — client signatures accept str | None
126
+ # and runtime guards raise ValueError for None values.
127
+
128
+ # Simulator files depend on optional extras (litestar, getpaid_simulator)
129
+ [[tool.ty.overrides]]
130
+ include = ['src/getpaid_paynow/simulator/**']
131
+ [tool.ty.overrides.rules]
132
+ unresolved-attribute = 'ignore'
133
+ unresolved-import = 'ignore'
134
+
123
135
  [[tool.ty.overrides]]
124
- include = ['src/getpaid_paynow/processor.py']
136
+ include = ['tests/test_simulator_plugin.py']
125
137
  [tool.ty.overrides.rules]
126
138
  unresolved-attribute = 'ignore'
127
- call-non-callable = 'ignore'
139
+ unresolved-import = 'ignore'
@@ -0,0 +1,11 @@
1
+ """Paynow V3 payment gateway integration for python-getpaid ecosystem."""
2
+
3
+ from getpaid_paynow.client import PaynowClient
4
+ from getpaid_paynow.processor import PaynowProcessor
5
+
6
+ __all__ = [
7
+ "PaynowClient",
8
+ "PaynowProcessor",
9
+ ]
10
+
11
+ __version__ = "3.1.0"
@@ -4,8 +4,11 @@ import base64
4
4
  import hashlib
5
5
  import hmac
6
6
  import json
7
+ import logging
7
8
  import uuid
9
+ from decimal import ROUND_HALF_UP
8
10
  from decimal import Decimal
11
+ from typing import NoReturn
9
12
 
10
13
  import httpx
11
14
  from getpaid_core.exceptions import CommunicationError
@@ -19,6 +22,9 @@ from .types import PaymentStatusResponse
19
22
  from .types import RefundStatusResponse
20
23
 
21
24
 
25
+ logger = logging.getLogger(__name__)
26
+
27
+
22
28
  class PaynowClient:
23
29
  """Async client for Paynow V3 REST API.
24
30
 
@@ -30,23 +36,24 @@ class PaynowClient:
30
36
  await client.create_payment(...)
31
37
  """
32
38
 
33
- last_response: httpx.Response | None = None
34
-
35
39
  def __init__(
36
40
  self,
37
41
  *,
38
42
  api_key: str,
39
43
  signature_key: str,
40
44
  api_url: str,
45
+ timeout: float = 10.0,
41
46
  ) -> None:
42
47
  self.api_key = api_key
43
48
  self.signature_key = signature_key
44
49
  self.api_url = api_url.rstrip("/")
50
+ self.timeout = timeout
51
+ self.last_response: httpx.Response | None = None
45
52
  self._client: httpx.AsyncClient | None = None
46
53
  self._owns_client: bool = False
47
54
 
48
55
  async def __aenter__(self) -> "PaynowClient":
49
- self._client = httpx.AsyncClient()
56
+ self._client = httpx.AsyncClient(timeout=self.timeout)
50
57
  self._owns_client = True
51
58
  return self
52
59
 
@@ -124,8 +131,7 @@ class PaynowClient:
124
131
  body: str = "",
125
132
  parameters: dict | None = None,
126
133
  ) -> dict[str, str]:
127
- """Build request headers with authentication and
128
- signature."""
134
+ """Build request headers with authentication and signature."""
129
135
  params = parameters or {}
130
136
  signature = self._calculate_request_signature(
131
137
  api_key=self.api_key,
@@ -148,41 +154,110 @@ class PaynowClient:
148
154
  *,
149
155
  body: str | None = None,
150
156
  params: dict | None = None,
157
+ retries: int = 3,
158
+ backoff: float = 0.5,
151
159
  ) -> httpx.Response:
152
- """Execute an authenticated HTTP request."""
153
- url = f"{self.api_url}{path}"
154
- idempotency_key = self._generate_idempotency_key()
160
+ """Execute an authenticated HTTP request with retry for transient
161
+ failures.
155
162
 
156
- # Convert params to string values for signature
157
- str_params: dict = {}
158
- if params:
159
- str_params = {k: str(v) for k, v in params.items()}
160
-
161
- headers = self._build_headers(
162
- idempotency_key=idempotency_key,
163
- body=body or "",
164
- parameters=str_params,
165
- )
166
-
167
- if self._client is not None:
168
- return await self._client.request(
169
- method,
170
- url,
171
- headers=headers,
172
- content=body,
173
- params=params,
163
+ Retries on 5xx server errors, timeouts, and connection errors.
164
+ Does not retry on 4xx client errors or credential failures.
165
+ """
166
+ last_exc: Exception | None = None
167
+ for attempt in range(retries + 1):
168
+ url = f"{self.api_url}{path}"
169
+ idempotency_key = self._generate_idempotency_key()
170
+
171
+ # Convert params to string values for signature
172
+ str_params: dict = {}
173
+ if params:
174
+ str_params = {k: str(v) for k, v in params.items()}
175
+
176
+ headers = self._build_headers(
177
+ idempotency_key=idempotency_key,
178
+ body=body or "",
179
+ parameters=str_params,
174
180
  )
175
- async with httpx.AsyncClient() as client:
176
- return await client.request(
177
- method,
178
- url,
179
- headers=headers,
180
- content=body,
181
- params=params,
181
+
182
+ try:
183
+ if self._client is not None:
184
+ response = await self._client.request(
185
+ method,
186
+ url,
187
+ headers=headers,
188
+ content=body,
189
+ params=params,
190
+ )
191
+ else:
192
+ async with httpx.AsyncClient(
193
+ timeout=self.timeout
194
+ ) as client:
195
+ response = await client.request(
196
+ method,
197
+ url,
198
+ headers=headers,
199
+ content=body,
200
+ params=params,
201
+ )
202
+
203
+ # Retry only on transient failures (5xx, timeouts, conn errors)
204
+ if response.status_code < 500:
205
+ self.last_response = response
206
+ return response
207
+
208
+ # 5xx — retry
209
+ last_exc = CommunicationError(
210
+ f"Paynow API returned {response.status_code}. "
211
+ f"Attempt {attempt + 1}/{retries + 1}."
212
+ )
213
+ if attempt < retries:
214
+ await self._sleep(backoff * (2**attempt))
215
+ logger.warning(
216
+ "Paynow %s %s returned %d, retrying in %.1fs",
217
+ method,
218
+ path,
219
+ response.status_code,
220
+ backoff * (2**attempt),
221
+ )
222
+ continue
223
+
224
+ except (httpx.TimeoutException, httpx.ConnectError) as exc:
225
+ last_exc = exc
226
+ if attempt < retries:
227
+ await self._sleep(backoff * (2**attempt))
228
+ logger.warning(
229
+ "Paynow %s %s failed (%s), retrying in %.1fs",
230
+ method,
231
+ path,
232
+ type(exc).__name__,
233
+ backoff * (2**attempt),
234
+ )
235
+ continue
236
+
237
+ # All retries exhausted
238
+ if isinstance(last_exc, CommunicationError):
239
+ last_exc.args = (
240
+ f"Paynow API request failed after {retries + 1} attempts: "
241
+ f"{last_exc.args[0]}",
182
242
  )
243
+ raise last_exc
244
+ raise CommunicationError(
245
+ f"Paynow API request failed after {retries + 1} attempts: "
246
+ f"{last_exc}"
247
+ ) from last_exc
248
+
249
+ @staticmethod
250
+ async def _sleep(seconds: float) -> None:
251
+ """Sleep without blocking the event loop."""
252
+ import asyncio
253
+
254
+ await asyncio.sleep(seconds)
255
+
256
+ def _handle_error(self, response: httpx.Response) -> NoReturn:
257
+ """Raise appropriate exception based on status code.
183
258
 
184
- def _handle_error(self, response: httpx.Response) -> None:
185
- """Raise appropriate exception based on status code."""
259
+ This method always raises, so it is annotated as NoReturn.
260
+ """
186
261
  if response.status_code == 401:
187
262
  raise CredentialsError(
188
263
  "Paynow API authentication failed.",
@@ -195,9 +270,11 @@ class PaynowClient:
195
270
 
196
271
  @staticmethod
197
272
  def _to_lowest_unit(amount: Decimal) -> int:
198
- """Convert a Decimal amount to integer lowest currency
199
- unit."""
200
- return int(amount * 100)
273
+ """Convert a Decimal amount to integer lowest currency unit,
274
+ rounding half-up to avoid silent truncation."""
275
+ return int(
276
+ (amount * 100).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
277
+ )
201
278
 
202
279
  @staticmethod
203
280
  def _from_lowest_unit(amount: int) -> Decimal:
@@ -225,8 +302,7 @@ class PaynowClient:
225
302
 
226
303
  :param amount: Payment amount in main currency unit.
227
304
  :param currency: ISO 4217 currency code.
228
- :param external_id: Unique external ID (maps to
229
- payment.id).
305
+ :param external_id: Unique external ID (maps to payment.id).
230
306
  :param description: Payment description.
231
307
  :param buyer_email: Buyer email address.
232
308
  :return: Response with redirectUrl and paymentId.
@@ -263,31 +339,34 @@ class PaynowClient:
263
339
  if self.last_response.status_code in (200, 201):
264
340
  return self.last_response.json()
265
341
  self._handle_error(self.last_response)
266
- # unreachable — _handle_error always raises
267
- raise AssertionError # pragma: no cover
268
342
 
269
343
  async def get_payment_status(
270
344
  self,
271
- payment_id: str,
345
+ payment_id: str | None,
272
346
  ) -> PaymentStatusResponse:
273
347
  """Get payment status.
274
348
 
275
349
  GET /v3/payments/{paymentId}/status
276
350
 
277
- :param payment_id: Paynow payment ID.
351
+ :param payment_id: Paynow payment ID (may be None for uncreated
352
+ payments; raises a clear error in that case).
278
353
  :return: Payment status response.
279
354
  """
355
+ if payment_id is None:
356
+ raise ValueError(
357
+ "payment_id must not be None. "
358
+ "Call create_payment() first to obtain a payment ID."
359
+ )
280
360
  path = f"/v3/payments/{payment_id}/status"
281
361
  self.last_response = await self._request("GET", path)
282
362
  if self.last_response.status_code == 200:
283
363
  return self.last_response.json()
284
364
  self._handle_error(self.last_response)
285
- raise AssertionError # pragma: no cover
286
365
 
287
366
  async def create_refund(
288
367
  self,
289
368
  *,
290
- payment_id: str,
369
+ payment_id: str | None,
291
370
  amount: Decimal,
292
371
  reason: str | None = None,
293
372
  ) -> CreateRefundResponse:
@@ -295,11 +374,17 @@ class PaynowClient:
295
374
 
296
375
  POST /v3/payments/{paymentId}/refunds
297
376
 
298
- :param payment_id: Paynow payment ID.
377
+ :param payment_id: Paynow payment ID (may be None for uncreated
378
+ payments; raises a clear error in that case).
299
379
  :param amount: Refund amount in main currency unit.
300
380
  :param reason: Refund reason code.
301
381
  :return: Refund response.
302
382
  """
383
+ if payment_id is None:
384
+ raise ValueError(
385
+ "payment_id must not be None. "
386
+ "Call create_payment() first to obtain a payment ID."
387
+ )
303
388
  amount_int = self._to_lowest_unit(amount)
304
389
  data: dict = {"amount": amount_int}
305
390
  if reason is not None:
@@ -326,21 +411,26 @@ class PaynowClient:
326
411
 
327
412
  async def get_refund_status(
328
413
  self,
329
- refund_id: str,
414
+ refund_id: str | None,
330
415
  ) -> RefundStatusResponse:
331
416
  """Get refund status.
332
417
 
333
418
  GET /v3/refunds/{refundId}/status
334
419
 
335
- :param refund_id: Paynow refund ID.
420
+ :param refund_id: Paynow refund ID (may be None for uncreated
421
+ refunds; raises a clear error in that case).
336
422
  :return: Refund status response.
337
423
  """
424
+ if refund_id is None:
425
+ raise ValueError(
426
+ "refund_id must not be None. "
427
+ "Call create_refund() first to obtain a refund ID."
428
+ )
338
429
  path = f"/v3/refunds/{refund_id}/status"
339
430
  self.last_response = await self._request("GET", path)
340
431
  if self.last_response.status_code == 200:
341
432
  return self.last_response.json()
342
433
  self._handle_error(self.last_response)
343
- raise AssertionError # pragma: no cover
344
434
 
345
435
  async def cancel_refund(
346
436
  self,
@@ -352,6 +442,11 @@ class PaynowClient:
352
442
 
353
443
  :param refund_id: Paynow refund ID.
354
444
  """
445
+ if not refund_id:
446
+ raise ValueError(
447
+ "refund_id must not be empty. "
448
+ "Call create_refund() first to obtain a refund ID."
449
+ )
355
450
  path = f"/v3/refunds/{refund_id}/cancel"
356
451
  self.last_response = await self._request("POST", path)
357
452
  if self.last_response.status_code in (200, 202):
@@ -368,8 +463,7 @@ class PaynowClient:
368
463
 
369
464
  GET /v3/payments/paymentmethods
370
465
 
371
- :param amount: Optional amount filter (lowest currency
372
- unit).
466
+ :param amount: Optional amount filter (lowest currency unit).
373
467
  :param currency: Optional currency filter.
374
468
  :return: List of payment method groups.
375
469
  """
@@ -386,4 +480,3 @@ class PaynowClient:
386
480
  if self.last_response.status_code == 200:
387
481
  return self.last_response.json()
388
482
  self._handle_error(self.last_response)
389
- raise AssertionError # pragma: no cover
@@ -8,7 +8,7 @@ from typing import ClassVar
8
8
  from getpaid_core.enums import PaymentEvent
9
9
  from getpaid_core.exceptions import InvalidCallbackError
10
10
  from getpaid_core.processor import BaseProcessor
11
- from getpaid_core.types import ChargeResponse
11
+ from getpaid_core.types import ChargeResult as ChargeResponse
12
12
  from getpaid_core.types import PaymentUpdate
13
13
  from getpaid_core.types import RefundResult
14
14
  from getpaid_core.types import TransactionResult
@@ -45,6 +45,7 @@ class PaynowProcessor(BaseProcessor):
45
45
  api_key=str(self.get_setting("api_key", "")),
46
46
  signature_key=str(self.get_setting("signature_key", "")),
47
47
  api_url=self.get_paywall_baseurl(),
48
+ timeout=float(self.get_setting("timeout", 10.0)),
48
49
  )
49
50
 
50
51
  def _resolve_url(self, url_template: str) -> str:
@@ -241,7 +242,18 @@ class PaynowProcessor(BaseProcessor):
241
242
  ) -> RefundResult:
242
243
  """Start a refund via Paynow API."""
243
244
  client = self._get_client()
244
- refund_amount = amount or self.payment.amount_paid
245
+ refund_amount = (
246
+ amount if amount is not None else self.payment.amount_paid
247
+ )
248
+ if refund_amount <= 0:
249
+ raise ValueError(
250
+ f"Refund amount must be positive. Got {refund_amount}."
251
+ )
252
+ if refund_amount > self.payment.amount_paid:
253
+ raise ValueError(
254
+ f"Refund amount ({refund_amount}) exceeds paid amount "
255
+ f"({self.payment.amount_paid})."
256
+ )
245
257
  response = await client.create_refund(
246
258
  payment_id=self.payment.external_id,
247
259
  amount=refund_amount,
@@ -253,12 +265,21 @@ class PaynowProcessor(BaseProcessor):
253
265
  return RefundResult(amount=refund_amount, provider_data=provider_data)
254
266
 
255
267
  async def cancel_refund(self, **kwargs) -> bool:
256
- """Cancel an awaiting refund via Paynow API."""
268
+ """Cancel an awaiting refund via Paynow API.
269
+
270
+ The refund identifier is stored in provider_data["refund_id"]
271
+ by ``start_refund()``. If it is missing, raise an error —
272
+ the Payment protocol does not define an ``external_refund_id``
273
+ attribute, so falling back to ``getattr`` was a silent no-op
274
+ that could silently cancel the wrong refund or do nothing at
275
+ all.
276
+ """
257
277
  client = self._get_client()
258
278
  refund_id = self.payment.provider_data.get("refund_id")
259
279
  if not refund_id:
260
- refund_id = getattr(self.payment, "external_refund_id", "")
261
- if not refund_id:
262
- raise InvalidCallbackError("Missing refund identifier")
280
+ raise InvalidCallbackError(
281
+ "Missing refund identifier. "
282
+ 'Expected provider_data["refund_id"] set by start_refund().'
283
+ )
263
284
  await client.cancel_refund(refund_id)
264
285
  return True
@@ -1,15 +1,10 @@
1
1
  """Paynow V3 API types and enums."""
2
2
 
3
- from enum import StrEnum
4
3
  from enum import auto
5
4
  from enum import unique
6
5
  from typing import TypedDict
7
6
 
8
-
9
- class AutoName(StrEnum):
10
- @staticmethod
11
- def _generate_next_value_(name, start, count, last_values):
12
- return name.strip("_")
7
+ from getpaid_core import AutoName
13
8
 
14
9
 
15
10
  @unique
@@ -5,7 +5,6 @@ from decimal import Decimal
5
5
  from typing import Any
6
6
 
7
7
  import pytest
8
-
9
8
  from getpaid_core.enums import PaymentStatus
10
9
  from getpaid_core.types import BuyerInfo
11
10
  from getpaid_core.types import ItemInfo
@@ -6,7 +6,6 @@ import hmac as hmac_mod
6
6
  import json
7
7
 
8
8
  import pytest
9
-
10
9
  from getpaid_core.enums import PaymentEvent
11
10
  from getpaid_core.exceptions import InvalidCallbackError
12
11
 
@@ -3,6 +3,7 @@
3
3
  import json
4
4
  from decimal import Decimal
5
5
 
6
+ import httpx
6
7
  import pytest
7
8
  from getpaid_core.exceptions import CommunicationError
8
9
  from getpaid_core.exceptions import CredentialsError
@@ -146,6 +147,19 @@ class TestAmountConversion:
146
147
  def test_to_lowest_unit_small(self):
147
148
  assert PaynowClient._to_lowest_unit(Decimal("0.01")) == 1
148
149
 
150
+ def test_to_lowest_unit_no_truncation(self):
151
+ """Regression: int() truncation must not silently lose money.
152
+
153
+ Decimal("1.005") * 100 = Decimal("100.5"), which must round to 101,
154
+ not 100. Using int() directly would have produced 100 — a real
155
+ money-loss bug (PAYNOW-001).
156
+ """
157
+ assert PaynowClient._to_lowest_unit(Decimal("1.005")) == 101
158
+
159
+ def test_to_lowest_unit_rounds_down(self):
160
+ """Fractional cents below .5 must round down."""
161
+ assert PaynowClient._to_lowest_unit(Decimal("1.004")) == 100
162
+
149
163
  def test_from_lowest_unit(self):
150
164
  assert PaynowClient._from_lowest_unit(123) == Decimal("1.23")
151
165
 
@@ -587,3 +601,120 @@ class TestAsyncContextManager:
587
601
  await client.get_payment_status("PAY-123")
588
602
  assert client.last_response is not None
589
603
  assert client.last_response.status_code == 200
604
+
605
+
606
+ class TestNoneIdGuards:
607
+ """Tests for runtime guards on None/empty IDs."""
608
+
609
+ async def test_get_payment_status_raises_on_none(self):
610
+ client = _make_client()
611
+ with pytest.raises(ValueError, match="payment_id must not be None"):
612
+ await client.get_payment_status(None)
613
+
614
+ async def test_create_refund_raises_on_none_payment_id(self):
615
+ client = _make_client()
616
+ with pytest.raises(ValueError, match="payment_id must not be None"):
617
+ await client.create_refund(payment_id=None, amount=Decimal("10.00"))
618
+
619
+ async def test_get_refund_status_raises_on_none(self):
620
+ client = _make_client()
621
+ with pytest.raises(ValueError, match="refund_id must not be None"):
622
+ await client.get_refund_status(None)
623
+
624
+ async def test_cancel_refund_raises_on_empty_string(self):
625
+ client = _make_client()
626
+ with pytest.raises(ValueError, match="refund_id must not be empty"):
627
+ await client.cancel_refund("")
628
+
629
+
630
+ class TestRetryLogic:
631
+ """Tests for transient failure retry behavior."""
632
+
633
+ async def test_retry_on_500(self, respx_mock):
634
+ """5xx errors should be retried."""
635
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/status"
636
+ route = respx_mock.get(url)
637
+ route.side_effect = [
638
+ httpx.Response(500, json={"error": "server"}),
639
+ httpx.Response(
640
+ 200,
641
+ json={"paymentId": "PAY-123", "status": "CONFIRMED"},
642
+ ),
643
+ ]
644
+ client = _make_client()
645
+ result = await client.get_payment_status("PAY-123")
646
+ assert result["status"] == "CONFIRMED"
647
+ assert route.call_count == 2
648
+
649
+ async def test_retry_on_502(self, respx_mock):
650
+ """502 Bad Gateway should be retried."""
651
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/status"
652
+ route = respx_mock.get(url)
653
+ route.side_effect = [
654
+ httpx.Response(502),
655
+ httpx.Response(
656
+ 200,
657
+ json={"paymentId": "PAY-123", "status": "CONFIRMED"},
658
+ ),
659
+ ]
660
+ client = _make_client()
661
+ result = await client.get_payment_status("PAY-123")
662
+ assert result["status"] == "CONFIRMED"
663
+ assert route.call_count == 2
664
+
665
+ async def test_no_retry_on_4xx(self, respx_mock):
666
+ """4xx errors should NOT be retried."""
667
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/status"
668
+ respx_mock.get(url).respond(
669
+ status_code=404,
670
+ json={"error": "not found"},
671
+ )
672
+ client = _make_client()
673
+ with pytest.raises(CommunicationError):
674
+ await client.get_payment_status("PAY-123")
675
+
676
+ async def test_no_retry_on_401(self, respx_mock):
677
+ """401 should not be retried (credentials error)."""
678
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/status"
679
+ respx_mock.get(url).respond(
680
+ status_code=401,
681
+ json={"error": "unauthorized"},
682
+ )
683
+ client = _make_client()
684
+ with pytest.raises(CredentialsError):
685
+ await client.get_payment_status("PAY-123")
686
+
687
+ async def test_exhaust_retries_on_500(self, respx_mock):
688
+ """Should raise after max retries exhausted."""
689
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/status"
690
+ respx_mock.get(url).respond(
691
+ status_code=500,
692
+ json={"error": "server"},
693
+ )
694
+ client = _make_client()
695
+ with pytest.raises(CommunicationError, match="attempts"):
696
+ await client.get_payment_status("PAY-123")
697
+
698
+ async def test_retry_on_timeout(self, respx_mock):
699
+ """Timeouts should be retried."""
700
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/status"
701
+ route = respx_mock.get(url)
702
+ route.side_effect = [
703
+ httpx.TimeoutException("timeout"),
704
+ httpx.Response(
705
+ 200,
706
+ json={"paymentId": "PAY-123", "status": "CONFIRMED"},
707
+ ),
708
+ ]
709
+ client = _make_client()
710
+ result = await client.get_payment_status("PAY-123")
711
+ assert result["status"] == "CONFIRMED"
712
+ assert route.call_count == 2
713
+
714
+ async def test_exhaust_retries_on_timeout(self, respx_mock):
715
+ """Should raise after max retries on repeated timeouts."""
716
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/status"
717
+ respx_mock.get(url).side_effect = httpx.TimeoutException("timeout")
718
+ client = _make_client()
719
+ with pytest.raises(CommunicationError, match="attempts"):
720
+ await client.get_payment_status("PAY-123")
@@ -0,0 +1,27 @@
1
+ """Tests for getpaid.backends entry point registration."""
2
+
3
+ from getpaid_core.processor import BaseProcessor
4
+ from getpaid_core.registry import PluginRegistry
5
+
6
+
7
+ class TestEntryPoints:
8
+ """Verify entry points are correctly registered."""
9
+
10
+ def test_paynow_backend_entry_point(self):
11
+ """PaynowProcessor must be discoverable via entry points."""
12
+ registry = PluginRegistry()
13
+ registry.discover()
14
+
15
+ processor_class = registry.get_by_slug("paynow")
16
+ assert issubclass(processor_class, BaseProcessor)
17
+ assert processor_class.slug == "paynow"
18
+ assert processor_class.display_name == "Paynow"
19
+
20
+ def test_paynow_accepted_currencies(self):
21
+ """PaynowProcessor must list supported currencies."""
22
+ registry = PluginRegistry()
23
+ registry.discover()
24
+
25
+ processor_class = registry.get_by_slug("paynow")
26
+ assert len(processor_class.accepted_currencies) > 0
27
+ assert "PLN" in processor_class.accepted_currencies
@@ -4,7 +4,6 @@ import json
4
4
  from decimal import Decimal
5
5
 
6
6
  import pytest
7
-
8
7
  from getpaid_core.enums import BackendMethod
9
8
  from getpaid_core.enums import PaymentEvent
10
9
  from getpaid_core.exceptions import CommunicationError
@@ -206,3 +205,88 @@ class TestRefunds:
206
205
 
207
206
  with pytest.raises(CommunicationError):
208
207
  await processor.cancel_refund()
208
+
209
+
210
+ class TestRefundAmountGuard:
211
+ """Tests for PAYNOW-007: refund amount validation."""
212
+
213
+ async def test_refund_zero_amount_raises(self):
214
+ payment = make_mock_payment(external_id="PAY-123")
215
+ payment.amount_paid = Decimal("100.00")
216
+ processor = _make_processor(payment=payment)
217
+
218
+ with pytest.raises(ValueError, match="Refund amount must be positive"):
219
+ await processor.start_refund(amount=Decimal("0.00"))
220
+
221
+ async def test_refund_negative_amount_raises(self):
222
+ payment = make_mock_payment(external_id="PAY-123")
223
+ payment.amount_paid = Decimal("100.00")
224
+ processor = _make_processor(payment=payment)
225
+
226
+ with pytest.raises(ValueError, match="Refund amount must be positive"):
227
+ await processor.start_refund(amount=Decimal("-10.00"))
228
+
229
+ async def test_refund_exceeds_paid_raises(self):
230
+ payment = make_mock_payment(external_id="PAY-123")
231
+ payment.amount_paid = Decimal("50.00")
232
+ processor = _make_processor(payment=payment)
233
+
234
+ with pytest.raises(ValueError, match="exceeds paid amount"):
235
+ await processor.start_refund(amount=Decimal("100.00"))
236
+
237
+ async def test_refund_equal_to_paid_succeeds(self, respx_mock):
238
+ url = f"{SANDBOX_URL}/v3/payments/PAY-123/refunds"
239
+ respx_mock.post(url).respond(
240
+ json={"refundId": "REF-456", "status": "NEW"},
241
+ status_code=201,
242
+ )
243
+ payment = make_mock_payment(external_id="PAY-123")
244
+ payment.amount_paid = Decimal("100.00")
245
+ processor = _make_processor(payment=payment)
246
+
247
+ result = await processor.start_refund(amount=Decimal("100.00"))
248
+ assert result.amount == Decimal("100.00")
249
+
250
+
251
+ class TestCancelRefundMissingId:
252
+ """Tests for PAYNOW-006: cancel_refund raises when refund_id is missing.
253
+
254
+ The Payment protocol does not define an ``external_refund_id``
255
+ attribute, so falling back to ``getattr`` was a silent no-op.
256
+ """
257
+
258
+ async def test_cancel_refund_raises_when_no_refund_id(self):
259
+ """cancel_refund raises on missing refund_id."""
260
+ from getpaid_core.exceptions import InvalidCallbackError
261
+
262
+ payment = make_mock_payment(provider_data={})
263
+ processor = _make_processor(payment=payment)
264
+
265
+ with pytest.raises(
266
+ InvalidCallbackError, match="Missing refund identifier"
267
+ ):
268
+ await processor.cancel_refund()
269
+
270
+ async def test_cancel_refund_raises_when_provider_data_empty(self):
271
+ """cancel_refund raises when provider_data is empty dict."""
272
+ from getpaid_core.exceptions import InvalidCallbackError
273
+
274
+ payment = make_mock_payment(provider_data=None)
275
+ processor = _make_processor(payment=payment)
276
+
277
+ with pytest.raises(
278
+ InvalidCallbackError, match="Missing refund identifier"
279
+ ):
280
+ await processor.cancel_refund()
281
+
282
+ async def test_cancel_refund_raises_when_provider_data_missing_key(self):
283
+ """cancel_refund raises when provider_data lacks refund_id key."""
284
+ from getpaid_core.exceptions import InvalidCallbackError
285
+
286
+ payment = make_mock_payment(provider_data={"other_key": "value"})
287
+ processor = _make_processor(payment=payment)
288
+
289
+ with pytest.raises(
290
+ InvalidCallbackError, match="Missing refund identifier"
291
+ ):
292
+ await processor.cancel_refund()
@@ -0,0 +1,33 @@
1
+ """Tests for the public package API."""
2
+
3
+ import tomllib
4
+ from pathlib import Path
5
+
6
+ import getpaid_core
7
+
8
+ import getpaid_paynow
9
+
10
+
11
+ def test_version() -> None:
12
+ assert getpaid_paynow.__version__ == "3.0.0"
13
+
14
+
15
+ def test_core_dependency_floor() -> None:
16
+ current_version = getpaid_paynow.__version__
17
+ pyproject_data = tomllib.loads(Path("pyproject.toml").read_text())
18
+ assert (
19
+ f"python-getpaid-core>={current_version}"
20
+ in pyproject_data["project"]["dependencies"]
21
+ )
22
+
23
+
24
+ def test_core_version_major_minor_matches_paynow_version() -> None:
25
+ """Core and paynow must share the same major.minor version.
26
+
27
+ Patch-level drift is allowed because core may ship hotfixes
28
+ independently of backend plugins.
29
+ """
30
+ core_parts = getpaid_core.__version__.split(".")
31
+ paynow_parts = getpaid_paynow.__version__.split(".")
32
+ assert core_parts[0] == paynow_parts[0]
33
+ assert core_parts[1] == paynow_parts[1]
@@ -1,19 +0,0 @@
1
- # Changelog
2
-
3
- ## v0.1.0 (2026-02-14)
4
-
5
- Initial release.
6
-
7
- ### Features
8
-
9
- - Full Paynow V3 REST API coverage
10
- - Async HTTP client (`PaynowClient`) with API Key + HMAC-SHA256 signing
11
- - Payment processor (`PaynowProcessor`) implementing `BaseProcessor`
12
- - Payment creation with redirect URL
13
- - HMAC-SHA256 signature calculation and verification
14
- - Notification (PUSH) callback handling
15
- - Status polling (PULL) via API
16
- - Refund support (create, check status, cancel)
17
- - Payment methods retrieval
18
- - Amount conversion (`Decimal` ↔ integer lowest currency unit)
19
- - Support for PLN, EUR, USD, GBP currencies
@@ -1,21 +0,0 @@
1
- """Paynow V3 payment gateway integration for python-getpaid ecosystem."""
2
-
3
- __version__ = "3.0.0a5"
4
-
5
- __all__ = [
6
- "PaynowClient",
7
- "PaynowProcessor",
8
- ]
9
-
10
-
11
- def __getattr__(name: str):
12
- if name == "PaynowClient":
13
- from getpaid_paynow.client import PaynowClient
14
-
15
- return PaynowClient
16
- if name == "PaynowProcessor":
17
- from getpaid_paynow.processor import PaynowProcessor
18
-
19
- return PaynowProcessor
20
- msg = f"module {__name__!r} has no attribute {name!r}"
21
- raise AttributeError(msg)
@@ -1,18 +0,0 @@
1
- """Tests for the public package API."""
2
-
3
- import tomllib
4
- from pathlib import Path
5
-
6
- import getpaid_paynow
7
-
8
-
9
- def test_version() -> None:
10
- assert getpaid_paynow.__version__ == "3.0.0a4"
11
-
12
-
13
- def test_core_dependency_floor() -> None:
14
- pyproject_data = tomllib.loads(Path("pyproject.toml").read_text())
15
- assert (
16
- "python-getpaid-core>=3.0.0a4"
17
- in pyproject_data["project"]["dependencies"]
18
- )