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.
- python_getpaid_paynow-3.1.0/.github/release-drafter.yml +31 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.github/workflows/ci.yml +7 -1
- python_getpaid_paynow-3.1.0/.github/workflows/release.yml +70 -0
- python_getpaid_paynow-3.1.0/.python-version +1 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/PKG-INFO +6 -5
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/README.md +1 -1
- python_getpaid_paynow-3.1.0/docs/changelog.md +28 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/concepts.md +16 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/pyproject.toml +17 -5
- python_getpaid_paynow-3.1.0/src/getpaid_paynow/__init__.py +11 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/client.py +146 -53
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/processor.py +27 -6
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/types.py +1 -6
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/conftest.py +0 -1
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_callback.py +0 -1
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_client.py +131 -0
- python_getpaid_paynow-3.1.0/tests/test_entry_points.py +27 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_processor.py +85 -1
- python_getpaid_paynow-3.1.0/tests/test_public_api.py +33 -0
- python_getpaid_paynow-3.0.0a5/docs/changelog.md +0 -19
- python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/__init__.py +0 -21
- python_getpaid_paynow-3.0.0a5/tests/test_public_api.py +0 -18
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.gitignore +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.pre-commit-config.yaml +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/.readthedocs.yml +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/CODE_OF_CONDUCT.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/CONTRIBUTING.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/LICENSE +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/codeofconduct.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/conf.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/configuration.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/contributing.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/getting-started.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/index.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/license.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/reference.md +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/docs/requirements.txt +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/py.typed +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/__init__.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/plugin.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/routes.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/signing.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/transitions.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/webhooks.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/__init__.py +0 -0
- {python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_simulator_plugin.py +0 -0
- {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
|
|
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 ::
|
|
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.
|
|
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.
|
|
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.
|
|
173
|
+
- `python-getpaid-core >= 3.0.0`
|
|
173
174
|
- `httpx >= 0.27.0`
|
|
174
175
|
|
|
175
176
|
## 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 ::
|
|
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.
|
|
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.
|
|
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 = ['
|
|
136
|
+
include = ['tests/test_simulator_plugin.py']
|
|
125
137
|
[tool.ty.overrides.rules]
|
|
126
138
|
unresolved-attribute = 'ignore'
|
|
127
|
-
|
|
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
|
-
|
|
154
|
-
idempotency_key = self._generate_idempotency_key()
|
|
160
|
+
"""Execute an authenticated HTTP request with retry for transient
|
|
161
|
+
failures.
|
|
155
162
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
idempotency_key=
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
|
|
185
|
-
"""
|
|
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
|
-
|
|
200
|
-
return int(
|
|
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
|
{python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/processor.py
RENAMED
|
@@ -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 =
|
|
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
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
|
@@ -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
|
-
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/plugin.py
RENAMED
|
File without changes
|
{python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/src/getpaid_paynow/simulator/routes.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_getpaid_paynow-3.0.0a5 → python_getpaid_paynow-3.1.0}/tests/test_simulator_plugin.py
RENAMED
|
File without changes
|
|
File without changes
|