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.
Files changed (40) hide show
  1. python_getpaid_paynow-3.0.0a4/.pre-commit-config.yaml +15 -0
  2. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/PKG-INFO +40 -1
  3. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/README.md +36 -0
  4. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/pyproject.toml +9 -0
  5. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/__init__.py +1 -1
  6. python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/__init__.py +6 -0
  7. python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/plugin.py +61 -0
  8. python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/routes.py +406 -0
  9. python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/signing.py +49 -0
  10. python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/transitions.py +17 -0
  11. python_getpaid_paynow-3.0.0a4/src/getpaid_paynow/simulator/webhooks.py +59 -0
  12. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_public_api.py +1 -1
  13. python_getpaid_paynow-3.0.0a4/tests/test_simulator_plugin.py +158 -0
  14. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/.github/workflows/ci.yml +0 -0
  15. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/.gitignore +0 -0
  16. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/.readthedocs.yml +0 -0
  17. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/CODE_OF_CONDUCT.md +0 -0
  18. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/CONTRIBUTING.md +0 -0
  19. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/LICENSE +0 -0
  20. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/changelog.md +0 -0
  21. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/codeofconduct.md +0 -0
  22. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/concepts.md +0 -0
  23. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/conf.py +0 -0
  24. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/configuration.md +0 -0
  25. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/contributing.md +0 -0
  26. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/getting-started.md +0 -0
  27. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/index.md +0 -0
  28. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/license.md +0 -0
  29. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/reference.md +0 -0
  30. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/docs/requirements.txt +0 -0
  31. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/client.py +0 -0
  32. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/processor.py +0 -0
  33. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/py.typed +0 -0
  34. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/src/getpaid_paynow/types.py +0 -0
  35. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/__init__.py +0 -0
  36. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/conftest.py +0 -0
  37. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_callback.py +0 -0
  38. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_client.py +0 -0
  39. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a4}/tests/test_processor.py +0 -0
  40. {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.0a3
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'
@@ -1,6 +1,6 @@
1
1
  """Paynow V3 payment gateway integration for python-getpaid ecosystem."""
2
2
 
3
- __version__ = "3.0.0a3"
3
+ __version__ = "3.0.0a4"
4
4
 
5
5
  __all__ = [
6
6
  "PaynowClient",
@@ -0,0 +1,6 @@
1
+ """PayNow simulator plugin for python-getpaid-simulator."""
2
+
3
+ from getpaid_paynow.simulator.plugin import get_plugin
4
+
5
+
6
+ __all__ = ["get_plugin"]
@@ -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
@@ -4,4 +4,4 @@ import getpaid_paynow
4
4
 
5
5
 
6
6
  def test_version() -> None:
7
- assert getpaid_paynow.__version__ == "3.0.0a3"
7
+ assert getpaid_paynow.__version__ == "3.0.0a4"
@@ -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 == []