python-getpaid-paynow 3.0.0a3__tar.gz → 3.0.0a4__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.0.0a4/.pre-commit-config.yaml +15 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/PKG-INFO +40 -1
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/README.md +36 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/pyproject.toml +9 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/__init__.py +1 -1
- python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/__init__.py +6 -0
- python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/plugin.py +61 -0
- python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/routes.py +406 -0
- python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/signing.py +49 -0
- python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/transitions.py +17 -0
- python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/webhooks.py +59 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_public_api.py +1 -1
- python_getpaid_paynow-3.0.0a4/tests/test_simulator_plugin.py +158 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/.github/workflows/ci.yml +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/.gitignore +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/.readthedocs.yml +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/CODE_OF_CONDUCT.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/CONTRIBUTING.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/LICENSE +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/changelog.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/codeofconduct.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/concepts.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/conf.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/configuration.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/contributing.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/getting-started.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/index.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/license.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/reference.md +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/requirements.txt +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/client.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/processor.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/py.typed +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/types.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/__init__.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/conftest.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_callback.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_client.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_processor.py +0 -0
- {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_types.py +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
repos:
|
|
2
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
3
|
+
rev: v5.0.0
|
|
4
|
+
hooks:
|
|
5
|
+
- id: trailing-whitespace
|
|
6
|
+
- id: end-of-file-fixer
|
|
7
|
+
- id: check-yaml
|
|
8
|
+
- id: check-added-large-files
|
|
9
|
+
|
|
10
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
11
|
+
rev: v0.11.12
|
|
12
|
+
hooks:
|
|
13
|
+
- id: ruff
|
|
14
|
+
args: [--fix]
|
|
15
|
+
- id: ruff-format
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-getpaid-paynow
|
|
3
|
-
Version: 3.0.
|
|
3
|
+
Version: 3.0.0a4
|
|
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
|
|
@@ -19,6 +19,9 @@ Classifier: Typing :: Typed
|
|
|
19
19
|
Requires-Python: >=3.12
|
|
20
20
|
Requires-Dist: httpx>=0.27.0
|
|
21
21
|
Requires-Dist: python-getpaid-core>=3.0.0a3
|
|
22
|
+
Provides-Extra: simulator
|
|
23
|
+
Requires-Dist: litestar>=2.0; extra == 'simulator'
|
|
24
|
+
Requires-Dist: python-getpaid-simulator>=3.0.0a3; extra == 'simulator'
|
|
22
25
|
Description-Content-Type: text/markdown
|
|
23
26
|
|
|
24
27
|
# python-getpaid-paynow
|
|
@@ -64,6 +67,42 @@ Install the package using pip:
|
|
|
64
67
|
pip install python-getpaid-paynow
|
|
65
68
|
```
|
|
66
69
|
|
|
70
|
+
Install simulator support only when you want this package to register its local
|
|
71
|
+
simulator plugin with `python-getpaid-simulator`:
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
pip install python-getpaid-paynow[simulator]
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This extra installs the simulator host and Litestar dependencies, then exposes
|
|
78
|
+
the `paynow` plugin through the `getpaid.simulator.providers` entry point.
|
|
79
|
+
|
|
80
|
+
## Simulator Plugin
|
|
81
|
+
|
|
82
|
+
When `python-getpaid-paynow[simulator]` is installed alongside
|
|
83
|
+
`python-getpaid-simulator`, the simulator host auto-discovers the PayNow
|
|
84
|
+
plugin.
|
|
85
|
+
|
|
86
|
+
Typical local setup:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pip install python-getpaid-simulator python-getpaid-paynow[simulator]
|
|
90
|
+
getpaid-simulator
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The plugin contributes:
|
|
94
|
+
|
|
95
|
+
- PayNow payment and refund simulator API routes under `/paynow/v3/...`
|
|
96
|
+
- PayNow authorization UI at `/sim/paynow/authorize/{payment_id}`
|
|
97
|
+
- PayNow-specific webhook signing and state transitions
|
|
98
|
+
|
|
99
|
+
Useful simulator environment variables:
|
|
100
|
+
|
|
101
|
+
- `SIMULATOR_PAYNOW_API_KEY`
|
|
102
|
+
- `SIMULATOR_PAYNOW_SIGNATURE_KEY`
|
|
103
|
+
- `SIMULATOR_PAYNOW_NOTIFY_URL`
|
|
104
|
+
- `SIMULATOR_PLUGIN_FAILURE_MODE` (`warn` or `strict`)
|
|
105
|
+
|
|
67
106
|
## Quick Usage
|
|
68
107
|
|
|
69
108
|
### Standalone Client
|
|
@@ -41,6 +41,42 @@ Install the package using pip:
|
|
|
41
41
|
pip install python-getpaid-paynow
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
+
Install simulator support only when you want this package to register its local
|
|
45
|
+
simulator plugin with `python-getpaid-simulator`:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install python-getpaid-paynow[simulator]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
This extra installs the simulator host and Litestar dependencies, then exposes
|
|
52
|
+
the `paynow` plugin through the `getpaid.simulator.providers` entry point.
|
|
53
|
+
|
|
54
|
+
## Simulator Plugin
|
|
55
|
+
|
|
56
|
+
When `python-getpaid-paynow[simulator]` is installed alongside
|
|
57
|
+
`python-getpaid-simulator`, the simulator host auto-discovers the PayNow
|
|
58
|
+
plugin.
|
|
59
|
+
|
|
60
|
+
Typical local setup:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install python-getpaid-simulator python-getpaid-paynow[simulator]
|
|
64
|
+
getpaid-simulator
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
The plugin contributes:
|
|
68
|
+
|
|
69
|
+
- PayNow payment and refund simulator API routes under `/paynow/v3/...`
|
|
70
|
+
- PayNow authorization UI at `/sim/paynow/authorize/{payment_id}`
|
|
71
|
+
- PayNow-specific webhook signing and state transitions
|
|
72
|
+
|
|
73
|
+
Useful simulator environment variables:
|
|
74
|
+
|
|
75
|
+
- `SIMULATOR_PAYNOW_API_KEY`
|
|
76
|
+
- `SIMULATOR_PAYNOW_SIGNATURE_KEY`
|
|
77
|
+
- `SIMULATOR_PAYNOW_NOTIFY_URL`
|
|
78
|
+
- `SIMULATOR_PLUGIN_FAILURE_MODE` (`warn` or `strict`)
|
|
79
|
+
|
|
44
80
|
## Quick Usage
|
|
45
81
|
|
|
46
82
|
### Standalone Client
|
|
@@ -23,6 +23,12 @@ dependencies = [
|
|
|
23
23
|
'httpx>=0.27.0',
|
|
24
24
|
]
|
|
25
25
|
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
simulator = [
|
|
28
|
+
'python-getpaid-simulator>=3.0.0a3',
|
|
29
|
+
'litestar>=2.0',
|
|
30
|
+
]
|
|
31
|
+
|
|
26
32
|
[dependency-groups]
|
|
27
33
|
dev = [
|
|
28
34
|
'pytest>=8.0',
|
|
@@ -45,6 +51,9 @@ Changelog = 'https://github.com/django-getpaid/python-getpaid-paynow/releases'
|
|
|
45
51
|
[project.entry-points."getpaid.backends"]
|
|
46
52
|
paynow = 'getpaid_paynow.processor:PaynowProcessor'
|
|
47
53
|
|
|
54
|
+
[project.entry-points."getpaid.simulator.providers"]
|
|
55
|
+
paynow = 'getpaid_paynow.simulator:get_plugin'
|
|
56
|
+
|
|
48
57
|
[build-system]
|
|
49
58
|
requires = ['hatchling']
|
|
50
59
|
build-backend = 'hatchling.build'
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""PayNow simulator plugin factory."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from getpaid_simulator.spi import SIMULATOR_PLUGIN_API_VERSION
|
|
10
|
+
from getpaid_simulator.spi import SimulatorProviderPlugin
|
|
11
|
+
|
|
12
|
+
from getpaid_paynow.simulator.routes import cancel_refund
|
|
13
|
+
from getpaid_paynow.simulator.routes import create_payment
|
|
14
|
+
from getpaid_paynow.simulator.routes import create_refund
|
|
15
|
+
from getpaid_paynow.simulator.routes import get_payment_methods
|
|
16
|
+
from getpaid_paynow.simulator.routes import get_payment_status
|
|
17
|
+
from getpaid_paynow.simulator.routes import get_refund_status
|
|
18
|
+
from getpaid_paynow.simulator.routes import paynow_authorize_get
|
|
19
|
+
from getpaid_paynow.simulator.routes import paynow_authorize_post
|
|
20
|
+
from getpaid_paynow.simulator.transitions import PAYNOW_TRANSITIONS
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def load_provider_config(
|
|
28
|
+
env: Mapping[str, str] | None = None,
|
|
29
|
+
) -> dict[str, Any]:
|
|
30
|
+
environment = env or os.environ
|
|
31
|
+
return {
|
|
32
|
+
"api_key": environment.get(
|
|
33
|
+
"SIMULATOR_PAYNOW_API_KEY",
|
|
34
|
+
"sim-paynow-api-key",
|
|
35
|
+
),
|
|
36
|
+
"signature_key": environment.get(
|
|
37
|
+
"SIMULATOR_PAYNOW_SIGNATURE_KEY",
|
|
38
|
+
"sim-paynow-key-default",
|
|
39
|
+
),
|
|
40
|
+
"notify_url": environment.get("SIMULATOR_PAYNOW_NOTIFY_URL", ""),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_plugin() -> SimulatorProviderPlugin:
|
|
45
|
+
return SimulatorProviderPlugin(
|
|
46
|
+
api_version=SIMULATOR_PLUGIN_API_VERSION,
|
|
47
|
+
slug="paynow",
|
|
48
|
+
display_name="PayNow",
|
|
49
|
+
api_handlers=(
|
|
50
|
+
create_payment,
|
|
51
|
+
get_payment_status,
|
|
52
|
+
get_payment_methods,
|
|
53
|
+
create_refund,
|
|
54
|
+
get_refund_status,
|
|
55
|
+
cancel_refund,
|
|
56
|
+
),
|
|
57
|
+
ui_handlers=(paynow_authorize_get, paynow_authorize_post),
|
|
58
|
+
transitions=PAYNOW_TRANSITIONS,
|
|
59
|
+
load_config=load_provider_config,
|
|
60
|
+
authorize_path_template="/sim/paynow/authorize/{entity_id}",
|
|
61
|
+
)
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
"""PayNow simulator routes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import Any
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
from typing import cast
|
|
9
|
+
|
|
10
|
+
from litestar import Request
|
|
11
|
+
from litestar import get
|
|
12
|
+
from litestar import post
|
|
13
|
+
from litestar.enums import MediaType
|
|
14
|
+
from litestar.enums import RequestEncodingType
|
|
15
|
+
from litestar.exceptions import HTTPException
|
|
16
|
+
from litestar.exceptions import NotFoundException
|
|
17
|
+
from litestar.params import Body
|
|
18
|
+
from litestar.response import Redirect
|
|
19
|
+
from litestar.response import Response
|
|
20
|
+
from litestar.response import Template
|
|
21
|
+
|
|
22
|
+
from getpaid_paynow.simulator.webhooks import trigger_paynow_webhook
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
URL_ENCODED_BODY = Body(media_type=RequestEncodingType.URL_ENCODED)
|
|
27
|
+
|
|
28
|
+
PAYNOW_STATUSES = {
|
|
29
|
+
"NEW",
|
|
30
|
+
"PENDING",
|
|
31
|
+
"CONFIRMED",
|
|
32
|
+
"REJECTED",
|
|
33
|
+
"ERROR",
|
|
34
|
+
"EXPIRED",
|
|
35
|
+
"ABANDONED",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PaynowError(TypedDict):
|
|
40
|
+
errorType: str
|
|
41
|
+
message: str
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BuyerData(TypedDict, total=False):
|
|
45
|
+
email: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PaymentMethod(TypedDict):
|
|
49
|
+
id: int
|
|
50
|
+
name: str
|
|
51
|
+
description: str
|
|
52
|
+
image: str
|
|
53
|
+
status: str
|
|
54
|
+
authorizationType: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class PaymentMethodGroup(TypedDict):
|
|
58
|
+
type: str
|
|
59
|
+
paymentMethods: list[PaymentMethod]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class CreatePaymentPayload(TypedDict, total=False):
|
|
63
|
+
amount: int
|
|
64
|
+
currency: str
|
|
65
|
+
externalId: str
|
|
66
|
+
description: str
|
|
67
|
+
buyer: BuyerData
|
|
68
|
+
status: str
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
PAYMENT_METHODS: list[PaymentMethodGroup] = [
|
|
72
|
+
{
|
|
73
|
+
"type": "PBL",
|
|
74
|
+
"paymentMethods": [
|
|
75
|
+
{
|
|
76
|
+
"id": 2001,
|
|
77
|
+
"name": "mTransfer",
|
|
78
|
+
"description": "mBank",
|
|
79
|
+
"image": "https://static.paynow.pl/payment-method-icons/2001.png",
|
|
80
|
+
"status": "ENABLED",
|
|
81
|
+
"authorizationType": "REDIRECT",
|
|
82
|
+
}
|
|
83
|
+
],
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"type": "CARD",
|
|
87
|
+
"paymentMethods": [
|
|
88
|
+
{
|
|
89
|
+
"id": 3001,
|
|
90
|
+
"name": "Visa",
|
|
91
|
+
"description": "Visa",
|
|
92
|
+
"image": "https://static.paynow.pl/payment-method-icons/3001.png",
|
|
93
|
+
"status": "ENABLED",
|
|
94
|
+
"authorizationType": "REDIRECT",
|
|
95
|
+
}
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"type": "BLIK",
|
|
100
|
+
"paymentMethods": [
|
|
101
|
+
{
|
|
102
|
+
"id": 5001,
|
|
103
|
+
"name": "BLIK",
|
|
104
|
+
"description": "BLIK",
|
|
105
|
+
"image": "https://static.paynow.pl/payment-method-icons/5001.png",
|
|
106
|
+
"status": "ENABLED",
|
|
107
|
+
"authorizationType": "CODE",
|
|
108
|
+
}
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _provider_config(request: Request[Any, Any, Any]) -> dict[str, Any]:
|
|
115
|
+
return dict(request.app.state.provider_configs["paynow"])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _error_response(
|
|
119
|
+
status_code: int,
|
|
120
|
+
error_type: str,
|
|
121
|
+
message: str,
|
|
122
|
+
) -> Response[object]:
|
|
123
|
+
return Response(
|
|
124
|
+
content={
|
|
125
|
+
"statusCode": status_code,
|
|
126
|
+
"errors": [{"errorType": error_type, "message": message}],
|
|
127
|
+
},
|
|
128
|
+
status_code=status_code,
|
|
129
|
+
media_type=MediaType.JSON,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _warn_if_signature_missing(request: Request[Any, Any, Any]) -> None:
|
|
134
|
+
if request.headers.get("signature"):
|
|
135
|
+
return
|
|
136
|
+
logger.warning("Signature header missing for PayNow request")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _validate_create_payload(payload: object) -> list[str]:
|
|
140
|
+
if not isinstance(payload, dict):
|
|
141
|
+
return ["Request body must be an object"]
|
|
142
|
+
|
|
143
|
+
typed_payload = cast("CreatePaymentPayload", cast("object", payload))
|
|
144
|
+
required_fields = [
|
|
145
|
+
"amount",
|
|
146
|
+
"currency",
|
|
147
|
+
"externalId",
|
|
148
|
+
"description",
|
|
149
|
+
"buyer",
|
|
150
|
+
]
|
|
151
|
+
errors = [
|
|
152
|
+
f"Field '{field_name}' is required"
|
|
153
|
+
for field_name in required_fields
|
|
154
|
+
if field_name not in typed_payload
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
buyer = typed_payload.get("buyer")
|
|
158
|
+
if "buyer" in typed_payload and (
|
|
159
|
+
not isinstance(buyer, dict) or not buyer.get("email")
|
|
160
|
+
):
|
|
161
|
+
errors.append("Field 'buyer.email' is required")
|
|
162
|
+
|
|
163
|
+
amount = typed_payload.get("amount")
|
|
164
|
+
if "amount" in typed_payload and not isinstance(amount, int):
|
|
165
|
+
errors.append("Field 'amount' must be an integer")
|
|
166
|
+
|
|
167
|
+
status = typed_payload.get("status", "NEW")
|
|
168
|
+
if status not in PAYNOW_STATUSES:
|
|
169
|
+
errors.append("Field 'status' has invalid value")
|
|
170
|
+
return errors
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@post("/paynow/v3/payments")
|
|
174
|
+
async def create_payment(
|
|
175
|
+
request: Request[Any, Any, Any],
|
|
176
|
+
) -> Response[object]:
|
|
177
|
+
_warn_if_signature_missing(request)
|
|
178
|
+
|
|
179
|
+
payload_object: object = await request.json()
|
|
180
|
+
validation_errors = _validate_create_payload(payload_object)
|
|
181
|
+
if validation_errors:
|
|
182
|
+
return _error_response(400, "VALIDATION_ERROR", validation_errors[0])
|
|
183
|
+
|
|
184
|
+
payload = cast("CreatePaymentPayload", payload_object)
|
|
185
|
+
payment_data = dict(payload)
|
|
186
|
+
payment_data["status"] = str(payload.get("status", "NEW"))
|
|
187
|
+
|
|
188
|
+
provider_config = _provider_config(request)
|
|
189
|
+
notify_url = provider_config.get("notify_url")
|
|
190
|
+
if notify_url:
|
|
191
|
+
payment_data["notifyUrl"] = notify_url
|
|
192
|
+
|
|
193
|
+
payment_id = request.app.state.storage.create_order(
|
|
194
|
+
payment_data,
|
|
195
|
+
provider="paynow",
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
host = request.headers.get("host", "localhost")
|
|
199
|
+
redirect_url = f"http://{host}/sim/paynow/authorize/{payment_id}"
|
|
200
|
+
response_body = {
|
|
201
|
+
"redirectUrl": redirect_url,
|
|
202
|
+
"paymentId": payment_id,
|
|
203
|
+
"status": payment_data["status"],
|
|
204
|
+
}
|
|
205
|
+
return Response(
|
|
206
|
+
content=response_body,
|
|
207
|
+
status_code=201,
|
|
208
|
+
media_type=MediaType.JSON,
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@get("/paynow/v3/payments/{payment_id:str}/status")
|
|
213
|
+
async def get_payment_status(
|
|
214
|
+
request: Request[Any, Any, Any],
|
|
215
|
+
payment_id: str,
|
|
216
|
+
) -> Response[object]:
|
|
217
|
+
_warn_if_signature_missing(request)
|
|
218
|
+
|
|
219
|
+
payment = request.app.state.storage.get_order(payment_id)
|
|
220
|
+
if payment is None or payment.get("provider") != "paynow":
|
|
221
|
+
return _error_response(
|
|
222
|
+
404, "NOT_FOUND", f"Payment {payment_id} not found"
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
return Response(
|
|
226
|
+
content={
|
|
227
|
+
"paymentId": payment_id,
|
|
228
|
+
"status": str(payment.get("status", "NEW")),
|
|
229
|
+
},
|
|
230
|
+
status_code=200,
|
|
231
|
+
media_type=MediaType.JSON,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@get("/paynow/v3/payments/paymentmethods")
|
|
236
|
+
async def get_payment_methods(
|
|
237
|
+
request: Request[Any, Any, Any],
|
|
238
|
+
) -> Response[object]:
|
|
239
|
+
_warn_if_signature_missing(request)
|
|
240
|
+
return Response(
|
|
241
|
+
content=PAYMENT_METHODS, status_code=200, media_type=MediaType.JSON
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@post("/paynow/v3/payments/{payment_id:str}/refunds")
|
|
246
|
+
async def create_refund(
|
|
247
|
+
request: Request[Any, Any, Any],
|
|
248
|
+
payment_id: str,
|
|
249
|
+
) -> Response[object]:
|
|
250
|
+
_warn_if_signature_missing(request)
|
|
251
|
+
|
|
252
|
+
payment = request.app.state.storage.get_order(payment_id)
|
|
253
|
+
if payment is None or payment.get("provider") != "paynow":
|
|
254
|
+
return _error_response(
|
|
255
|
+
404, "NOT_FOUND", f"Payment {payment_id} not found"
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
payment_status = payment.get("status", "NEW")
|
|
259
|
+
if payment_status != "CONFIRMED":
|
|
260
|
+
return _error_response(
|
|
261
|
+
400,
|
|
262
|
+
"VALIDATION_ERROR",
|
|
263
|
+
f"Payment not in CONFIRMED status (current: {payment_status})",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
payload = await request.json()
|
|
267
|
+
if not isinstance(payload, dict):
|
|
268
|
+
payload = {}
|
|
269
|
+
|
|
270
|
+
amount = payload.get("amount")
|
|
271
|
+
reason = payload.get("reason")
|
|
272
|
+
if amount is None:
|
|
273
|
+
return _error_response(
|
|
274
|
+
400, "VALIDATION_ERROR", "Field 'amount' is required"
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
refund_data = {"amount": amount, "status": "SUCCESSFUL"}
|
|
278
|
+
if reason is not None:
|
|
279
|
+
refund_data["reason"] = reason
|
|
280
|
+
|
|
281
|
+
refund_id = request.app.state.storage.create_refund(payment_id, refund_data)
|
|
282
|
+
return Response(
|
|
283
|
+
content={"refundId": refund_id, "status": "SUCCESSFUL"},
|
|
284
|
+
status_code=201,
|
|
285
|
+
media_type=MediaType.JSON,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@get("/paynow/v3/refunds/{refund_id:str}/status")
|
|
290
|
+
async def get_refund_status(
|
|
291
|
+
request: Request[Any, Any, Any],
|
|
292
|
+
refund_id: str,
|
|
293
|
+
) -> Response[object]:
|
|
294
|
+
_warn_if_signature_missing(request)
|
|
295
|
+
|
|
296
|
+
refund = request.app.state.storage.get_refund(refund_id)
|
|
297
|
+
if refund is None:
|
|
298
|
+
return _error_response(
|
|
299
|
+
404, "NOT_FOUND", f"Refund {refund_id} not found"
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
amount = refund.get("amount")
|
|
303
|
+
if isinstance(amount, str):
|
|
304
|
+
amount = int(amount)
|
|
305
|
+
return Response(
|
|
306
|
+
content={
|
|
307
|
+
"refundId": refund_id,
|
|
308
|
+
"status": str(refund.get("status", "SUCCESSFUL")),
|
|
309
|
+
"amount": amount,
|
|
310
|
+
},
|
|
311
|
+
status_code=200,
|
|
312
|
+
media_type=MediaType.JSON,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@post("/paynow/v3/refunds/{refund_id:str}/cancel")
|
|
317
|
+
async def cancel_refund(
|
|
318
|
+
request: Request[Any, Any, Any],
|
|
319
|
+
refund_id: str,
|
|
320
|
+
) -> Response[object]:
|
|
321
|
+
_warn_if_signature_missing(request)
|
|
322
|
+
|
|
323
|
+
refund = request.app.state.storage.get_refund(refund_id)
|
|
324
|
+
if refund is None:
|
|
325
|
+
return _error_response(
|
|
326
|
+
404, "NOT_FOUND", f"Refund {refund_id} not found"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
request.app.state.storage.update_refund(refund_id, status="CANCELLED")
|
|
330
|
+
return Response(
|
|
331
|
+
content={"refundId": refund_id, "status": "CANCELLED"},
|
|
332
|
+
status_code=200,
|
|
333
|
+
media_type=MediaType.JSON,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
@get("/sim/paynow/authorize/{payment_id:str}")
|
|
338
|
+
async def paynow_authorize_get(
|
|
339
|
+
payment_id: str,
|
|
340
|
+
request: Request[Any, Any, Any],
|
|
341
|
+
) -> Template:
|
|
342
|
+
payment = request.app.state.storage.get_order(payment_id)
|
|
343
|
+
if not payment:
|
|
344
|
+
raise NotFoundException("Payment not found")
|
|
345
|
+
|
|
346
|
+
if payment.get("status") in ("CONFIRMED", "REJECTED"):
|
|
347
|
+
raise HTTPException(status_code=400, detail="Payment already processed")
|
|
348
|
+
|
|
349
|
+
amount_raw = payment.get("amount", payment.get("totalAmount", 0))
|
|
350
|
+
try:
|
|
351
|
+
amount_value = float(amount_raw) / 100
|
|
352
|
+
formatted_amount = (
|
|
353
|
+
f"{amount_value:.2f} "
|
|
354
|
+
f"{payment.get('currency', payment.get('currencyCode', 'PLN'))}"
|
|
355
|
+
)
|
|
356
|
+
except (ValueError, TypeError):
|
|
357
|
+
formatted_amount = str(amount_raw)
|
|
358
|
+
|
|
359
|
+
return Template(
|
|
360
|
+
template_name="authorize.html",
|
|
361
|
+
context={
|
|
362
|
+
"provider": "PayNow",
|
|
363
|
+
"payment": payment,
|
|
364
|
+
"payment_id": payment_id,
|
|
365
|
+
"order_id": payment_id,
|
|
366
|
+
"amount": formatted_amount,
|
|
367
|
+
"status": payment.get("status", "NEW"),
|
|
368
|
+
},
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@post("/sim/paynow/authorize/{payment_id:str}")
|
|
373
|
+
async def paynow_authorize_post(
|
|
374
|
+
payment_id: str,
|
|
375
|
+
request: Request[Any, Any, Any],
|
|
376
|
+
data: dict[str, str] = URL_ENCODED_BODY,
|
|
377
|
+
) -> Redirect:
|
|
378
|
+
payment = request.app.state.storage.get_order(payment_id)
|
|
379
|
+
if not payment:
|
|
380
|
+
raise NotFoundException("Payment not found")
|
|
381
|
+
|
|
382
|
+
current_status = payment.get("status", "NEW")
|
|
383
|
+
if current_status in ("CONFIRMED", "REJECTED"):
|
|
384
|
+
raise HTTPException(status_code=400, detail="Payment already processed")
|
|
385
|
+
|
|
386
|
+
action = data.get("action")
|
|
387
|
+
if action == "approve":
|
|
388
|
+
if current_status == "NEW":
|
|
389
|
+
request.app.state.state_machine.transition(payment_id, "PENDING")
|
|
390
|
+
request.app.state.state_machine.transition(payment_id, "CONFIRMED")
|
|
391
|
+
elif action == "reject":
|
|
392
|
+
if current_status == "NEW":
|
|
393
|
+
request.app.state.state_machine.transition(payment_id, "PENDING")
|
|
394
|
+
request.app.state.state_machine.transition(payment_id, "REJECTED")
|
|
395
|
+
else:
|
|
396
|
+
raise HTTPException(status_code=400, detail="Invalid action")
|
|
397
|
+
|
|
398
|
+
await trigger_paynow_webhook(
|
|
399
|
+
payment_id,
|
|
400
|
+
request.app.state.storage,
|
|
401
|
+
_provider_config(request),
|
|
402
|
+
request.app.state.webhook_transport,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
continue_url = payment.get("continueUrl", "/sim/dashboard")
|
|
406
|
+
return Redirect(path=str(continue_url))
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""PayNow signing helpers for the simulator plugin."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import hashlib
|
|
5
|
+
import hmac
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def calculate_notification_signature(body: str, signature_key: str) -> str:
|
|
10
|
+
digest = hmac.new(
|
|
11
|
+
signature_key.encode("utf-8"),
|
|
12
|
+
body.encode("utf-8"),
|
|
13
|
+
hashlib.sha256,
|
|
14
|
+
).digest()
|
|
15
|
+
return base64.b64encode(digest).decode("utf-8")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def calculate_request_signature(
|
|
19
|
+
api_key: str,
|
|
20
|
+
idempotency_key: str,
|
|
21
|
+
body: str,
|
|
22
|
+
signature_key: str,
|
|
23
|
+
parameters: dict[str, str] | None = None,
|
|
24
|
+
) -> str:
|
|
25
|
+
params = parameters or {}
|
|
26
|
+
headers_dict = {
|
|
27
|
+
"Api-Key": api_key,
|
|
28
|
+
"Idempotency-Key": idempotency_key,
|
|
29
|
+
}
|
|
30
|
+
payload = {
|
|
31
|
+
"headers": dict(sorted(headers_dict.items())),
|
|
32
|
+
"parameters": dict(sorted(params.items())),
|
|
33
|
+
"body": body,
|
|
34
|
+
}
|
|
35
|
+
payload_json = json.dumps(payload, separators=(",", ":"))
|
|
36
|
+
digest = hmac.new(
|
|
37
|
+
signature_key.encode("utf-8"),
|
|
38
|
+
payload_json.encode("utf-8"),
|
|
39
|
+
hashlib.sha256,
|
|
40
|
+
).digest()
|
|
41
|
+
return base64.b64encode(digest).decode("utf-8")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sign_webhook(body: bytes, signature_key: str) -> dict[str, str]:
|
|
45
|
+
signature = calculate_notification_signature(
|
|
46
|
+
body.decode("utf-8"),
|
|
47
|
+
signature_key,
|
|
48
|
+
)
|
|
49
|
+
return {"Signature": signature}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""PayNow simulator state transitions."""
|
|
2
|
+
|
|
3
|
+
PAYNOW_TRANSITIONS: dict[str, set[str]] = {
|
|
4
|
+
"NEW": {"PENDING", "ABANDONED"},
|
|
5
|
+
"PENDING": {
|
|
6
|
+
"CONFIRMED",
|
|
7
|
+
"REJECTED",
|
|
8
|
+
"ERROR",
|
|
9
|
+
"EXPIRED",
|
|
10
|
+
"ABANDONED",
|
|
11
|
+
},
|
|
12
|
+
"CONFIRMED": set(),
|
|
13
|
+
"REJECTED": set(),
|
|
14
|
+
"ERROR": set(),
|
|
15
|
+
"EXPIRED": set(),
|
|
16
|
+
"ABANDONED": set(),
|
|
17
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""PayNow webhook delivery for the simulator plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from datetime import UTC
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from getpaid_paynow.simulator.signing import sign_webhook
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from getpaid_simulator.core.storage import SimulatorStorage
|
|
16
|
+
from getpaid_simulator.core.webhooks import WebhookTransport
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_notification_payload(
|
|
20
|
+
payment_id: str,
|
|
21
|
+
payment: dict[str, Any],
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
return {
|
|
24
|
+
"paymentId": payment_id,
|
|
25
|
+
"externalId": payment.get("externalId", ""),
|
|
26
|
+
"status": payment.get("status", "NEW"),
|
|
27
|
+
"modifiedAt": datetime.now(UTC).isoformat(),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def trigger_paynow_webhook(
|
|
32
|
+
payment_id: str,
|
|
33
|
+
storage: SimulatorStorage,
|
|
34
|
+
provider_config: dict[str, Any],
|
|
35
|
+
transport: WebhookTransport,
|
|
36
|
+
) -> bool | None:
|
|
37
|
+
payment = storage.get_order(payment_id)
|
|
38
|
+
if payment is None:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
notify_url = payment.get("notifyUrl") or provider_config.get("notify_url")
|
|
42
|
+
if not notify_url:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
payload = build_notification_payload(payment_id, payment)
|
|
46
|
+
body = json.dumps(payload, separators=(",", ":")).encode("utf-8")
|
|
47
|
+
headers = {
|
|
48
|
+
"Content-Type": "application/json",
|
|
49
|
+
**sign_webhook(body, str(provider_config["signature_key"])),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
result = await transport.deliver(
|
|
53
|
+
url=str(notify_url), body=body, headers=headers
|
|
54
|
+
)
|
|
55
|
+
storage.update_order(
|
|
56
|
+
payment_id,
|
|
57
|
+
webhook_status="success" if result else "failed",
|
|
58
|
+
)
|
|
59
|
+
return result
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Tests for the PayNow simulator plugin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from importlib.metadata import entry_points
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from getpaid_simulator.spi import SIMULATOR_PLUGIN_API_VERSION
|
|
10
|
+
|
|
11
|
+
from getpaid_paynow.simulator import get_plugin
|
|
12
|
+
from getpaid_paynow.simulator.plugin import load_provider_config
|
|
13
|
+
from getpaid_paynow.simulator.signing import sign_webhook
|
|
14
|
+
from getpaid_paynow.simulator.webhooks import trigger_paynow_webhook
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _handler_name(handler: object) -> str:
|
|
18
|
+
return str(handler.fn.__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeStorage:
|
|
22
|
+
def __init__(self, payment: dict[str, object] | None) -> None:
|
|
23
|
+
self.payment = payment
|
|
24
|
+
self.updated: dict[str, object] = {}
|
|
25
|
+
|
|
26
|
+
def get_order(self, payment_id: str) -> dict[str, object] | None:
|
|
27
|
+
if self.payment is None or payment_id != "payment-1":
|
|
28
|
+
return None
|
|
29
|
+
return dict(self.payment)
|
|
30
|
+
|
|
31
|
+
def update_order(self, payment_id: str, **updates: object) -> None:
|
|
32
|
+
assert payment_id == "payment-1"
|
|
33
|
+
self.updated = dict(updates)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class FakeTransport:
|
|
37
|
+
def __init__(self, result: bool = True) -> None:
|
|
38
|
+
self.result = result
|
|
39
|
+
self.calls: list[dict[str, object]] = []
|
|
40
|
+
|
|
41
|
+
async def deliver(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
url: str,
|
|
45
|
+
body: bytes,
|
|
46
|
+
headers: dict[str, str],
|
|
47
|
+
) -> bool:
|
|
48
|
+
self.calls.append({"url": url, "body": body, "headers": dict(headers)})
|
|
49
|
+
return self.result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_paynow_simulator_entry_point_registered() -> None:
|
|
53
|
+
simulator_plugins = [
|
|
54
|
+
entry_point
|
|
55
|
+
for entry_point in entry_points(group="getpaid.simulator.providers")
|
|
56
|
+
if entry_point.name == "paynow"
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
assert len(simulator_plugins) == 1
|
|
60
|
+
assert simulator_plugins[0].value == "getpaid_paynow.simulator:get_plugin"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_get_plugin_returns_paynow_simulator_descriptor() -> None:
|
|
64
|
+
plugin = get_plugin()
|
|
65
|
+
|
|
66
|
+
assert plugin.api_version == SIMULATOR_PLUGIN_API_VERSION
|
|
67
|
+
assert plugin.slug == "paynow"
|
|
68
|
+
assert plugin.display_name == "PayNow"
|
|
69
|
+
assert plugin.authorize_path_template == "/sim/paynow/authorize/{entity_id}"
|
|
70
|
+
assert (
|
|
71
|
+
plugin.build_authorize_path("payment-123")
|
|
72
|
+
== "/sim/paynow/authorize/payment-123"
|
|
73
|
+
)
|
|
74
|
+
assert {_handler_name(handler) for handler in plugin.api_handlers} == {
|
|
75
|
+
"create_payment",
|
|
76
|
+
"get_payment_status",
|
|
77
|
+
"get_payment_methods",
|
|
78
|
+
"create_refund",
|
|
79
|
+
"get_refund_status",
|
|
80
|
+
"cancel_refund",
|
|
81
|
+
}
|
|
82
|
+
assert {_handler_name(handler) for handler in plugin.ui_handlers} == {
|
|
83
|
+
"paynow_authorize_get",
|
|
84
|
+
"paynow_authorize_post",
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_load_provider_config_reads_env_overrides(
|
|
89
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
90
|
+
) -> None:
|
|
91
|
+
monkeypatch.setenv("SIMULATOR_PAYNOW_API_KEY", "override-api-key")
|
|
92
|
+
monkeypatch.setenv("SIMULATOR_PAYNOW_SIGNATURE_KEY", "override-signature")
|
|
93
|
+
monkeypatch.setenv(
|
|
94
|
+
"SIMULATOR_PAYNOW_NOTIFY_URL",
|
|
95
|
+
"https://merchant.example/paynow/callback",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
assert load_provider_config() == {
|
|
99
|
+
"api_key": "override-api-key",
|
|
100
|
+
"signature_key": "override-signature",
|
|
101
|
+
"notify_url": "https://merchant.example/paynow/callback",
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_trigger_paynow_webhook_uses_provider_notify_url_fallback() -> (
|
|
107
|
+
None
|
|
108
|
+
):
|
|
109
|
+
storage = FakeStorage(
|
|
110
|
+
{
|
|
111
|
+
"externalId": "PAYNOW-42",
|
|
112
|
+
"status": "CONFIRMED",
|
|
113
|
+
}
|
|
114
|
+
)
|
|
115
|
+
transport = FakeTransport()
|
|
116
|
+
|
|
117
|
+
result = await trigger_paynow_webhook(
|
|
118
|
+
"payment-1",
|
|
119
|
+
storage,
|
|
120
|
+
{
|
|
121
|
+
"signature_key": "secret-key",
|
|
122
|
+
"notify_url": "https://merchant.example/paynow/callback",
|
|
123
|
+
},
|
|
124
|
+
transport,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert result is True
|
|
128
|
+
assert storage.updated == {"webhook_status": "success"}
|
|
129
|
+
assert len(transport.calls) == 1
|
|
130
|
+
|
|
131
|
+
request = transport.calls[0]
|
|
132
|
+
assert request["url"] == "https://merchant.example/paynow/callback"
|
|
133
|
+
body = request["body"]
|
|
134
|
+
assert isinstance(body, bytes)
|
|
135
|
+
payload = json.loads(body)
|
|
136
|
+
assert payload["paymentId"] == "payment-1"
|
|
137
|
+
assert payload["externalId"] == "PAYNOW-42"
|
|
138
|
+
assert payload["status"] == "CONFIRMED"
|
|
139
|
+
assert request["headers"] == {
|
|
140
|
+
"Content-Type": "application/json",
|
|
141
|
+
**sign_webhook(body, "secret-key"),
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@pytest.mark.asyncio
|
|
146
|
+
async def test_trigger_paynow_webhook_returns_none_without_target() -> None:
|
|
147
|
+
storage = FakeStorage({"externalId": "PAYNOW-42", "status": "CONFIRMED"})
|
|
148
|
+
transport = FakeTransport()
|
|
149
|
+
|
|
150
|
+
result = await trigger_paynow_webhook(
|
|
151
|
+
"payment-1",
|
|
152
|
+
storage,
|
|
153
|
+
{"signature_key": "secret-key", "notify_url": ""},
|
|
154
|
+
transport,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
assert result is None
|
|
158
|
+
assert transport.calls == []
|
|
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.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/client.py
RENAMED
|
File without changes
|
{python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/processor.py
RENAMED
|
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
|