python-getpaid-paynow 3.0.0a3__tar.gz → 3.0.0a5__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 (41) hide show
  1. python_getpaid_paynow-3.0.0a5/.pre-commit-config.yaml +15 -0
  2. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/PKG-INFO +42 -3
  3. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/README.md +37 -1
  4. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/pyproject.toml +10 -1
  5. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/src/getpaid_paynow/__init__.py +1 -1
  6. python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/simulator/__init__.py +6 -0
  7. python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/simulator/plugin.py +62 -0
  8. python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/simulator/routes.py +421 -0
  9. python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/simulator/signing.py +49 -0
  10. python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/simulator/transitions.py +17 -0
  11. python_getpaid_paynow-3.0.0a5/src/getpaid_paynow/simulator/webhooks.py +59 -0
  12. python_getpaid_paynow-3.0.0a5/tests/test_public_api.py +18 -0
  13. python_getpaid_paynow-3.0.0a5/tests/test_simulator_plugin.py +163 -0
  14. python_getpaid_paynow-3.0.0a3/tests/test_public_api.py +0 -7
  15. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/.github/workflows/ci.yml +0 -0
  16. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/.gitignore +0 -0
  17. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/.readthedocs.yml +0 -0
  18. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/CODE_OF_CONDUCT.md +0 -0
  19. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/CONTRIBUTING.md +0 -0
  20. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/LICENSE +0 -0
  21. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/changelog.md +0 -0
  22. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/codeofconduct.md +0 -0
  23. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/concepts.md +0 -0
  24. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/conf.py +0 -0
  25. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/configuration.md +0 -0
  26. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/contributing.md +0 -0
  27. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/getting-started.md +0 -0
  28. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/index.md +0 -0
  29. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/license.md +0 -0
  30. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/reference.md +0 -0
  31. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/docs/requirements.txt +0 -0
  32. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/src/getpaid_paynow/client.py +0 -0
  33. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/src/getpaid_paynow/processor.py +0 -0
  34. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/src/getpaid_paynow/py.typed +0 -0
  35. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/src/getpaid_paynow/types.py +0 -0
  36. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/tests/__init__.py +0 -0
  37. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/tests/conftest.py +0 -0
  38. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/tests/test_callback.py +0 -0
  39. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/tests/test_client.py +0 -0
  40. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/tests/test_processor.py +0 -0
  41. {python_getpaid_paynow-3.0.0a3 → python_getpaid_paynow-3.0.0a5}/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.0a5
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
@@ -18,7 +18,10 @@ Classifier: Topic :: Office/Business :: Financial :: Point-Of-Sale
18
18
  Classifier: Typing :: Typed
19
19
  Requires-Python: >=3.12
20
20
  Requires-Dist: httpx>=0.27.0
21
- Requires-Dist: python-getpaid-core>=3.0.0a3
21
+ Requires-Dist: python-getpaid-core>=3.0.0a4
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
@@ -130,7 +169,7 @@ GETPAID_BACKEND_SETTINGS = {
130
169
  ## Requirements
131
170
 
132
171
  - Python 3.12+
133
- - `python-getpaid-core >= 3.0.0a3`
172
+ - `python-getpaid-core >= 3.0.0a4`
134
173
  - `httpx >= 0.27.0`
135
174
 
136
175
  ## Links
@@ -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
@@ -107,7 +143,7 @@ GETPAID_BACKEND_SETTINGS = {
107
143
  ## Requirements
108
144
 
109
145
  - Python 3.12+
110
- - `python-getpaid-core >= 3.0.0a3`
146
+ - `python-getpaid-core >= 3.0.0a4`
111
147
  - `httpx >= 0.27.0`
112
148
 
113
149
  ## Links
@@ -19,10 +19,16 @@ classifiers = [
19
19
  'Typing :: Typed',
20
20
  ]
21
21
  dependencies = [
22
- 'python-getpaid-core>=3.0.0a3',
22
+ 'python-getpaid-core>=3.0.0a4',
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.0a5"
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,62 @@
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
+ "amount_minor_unit_places": 2,
33
+ "api_key": environment.get(
34
+ "SIMULATOR_PAYNOW_API_KEY",
35
+ "sim-paynow-api-key",
36
+ ),
37
+ "signature_key": environment.get(
38
+ "SIMULATOR_PAYNOW_SIGNATURE_KEY",
39
+ "sim-paynow-key-default",
40
+ ),
41
+ "notify_url": environment.get("SIMULATOR_PAYNOW_NOTIFY_URL", ""),
42
+ }
43
+
44
+
45
+ def get_plugin() -> SimulatorProviderPlugin:
46
+ return SimulatorProviderPlugin(
47
+ api_version=SIMULATOR_PLUGIN_API_VERSION,
48
+ slug="paynow",
49
+ display_name="PayNow",
50
+ api_handlers=(
51
+ create_payment,
52
+ get_payment_status,
53
+ get_payment_methods,
54
+ create_refund,
55
+ get_refund_status,
56
+ cancel_refund,
57
+ ),
58
+ ui_handlers=(paynow_authorize_get, paynow_authorize_post),
59
+ transitions=PAYNOW_TRANSITIONS,
60
+ load_config=load_provider_config,
61
+ authorize_path_template="/sim/paynow/authorize/{entity_id}",
62
+ )
@@ -0,0 +1,421 @@
1
+ """PayNow simulator routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from decimal import Decimal
7
+ from decimal import InvalidOperation
8
+ from typing import Any
9
+ from typing import TypedDict
10
+ from typing import cast
11
+
12
+ from litestar import Request
13
+ from litestar import get
14
+ from litestar import post
15
+ from litestar.enums import MediaType
16
+ from litestar.enums import RequestEncodingType
17
+ from litestar.exceptions import HTTPException
18
+ from litestar.exceptions import NotFoundException
19
+ from litestar.params import Body
20
+ from litestar.response import Redirect
21
+ from litestar.response import Response
22
+ from litestar.response import Template
23
+
24
+ from getpaid_paynow.simulator.webhooks import trigger_paynow_webhook
25
+
26
+
27
+ logger = logging.getLogger(__name__)
28
+ URL_ENCODED_BODY = Body(media_type=RequestEncodingType.URL_ENCODED)
29
+
30
+ PAYNOW_STATUSES = {
31
+ "NEW",
32
+ "PENDING",
33
+ "CONFIRMED",
34
+ "REJECTED",
35
+ "ERROR",
36
+ "EXPIRED",
37
+ "ABANDONED",
38
+ }
39
+
40
+
41
+ class PaynowError(TypedDict):
42
+ errorType: str
43
+ message: str
44
+
45
+
46
+ class BuyerData(TypedDict, total=False):
47
+ email: str
48
+
49
+
50
+ class PaymentMethod(TypedDict):
51
+ id: int
52
+ name: str
53
+ description: str
54
+ image: str
55
+ status: str
56
+ authorizationType: str
57
+
58
+
59
+ class PaymentMethodGroup(TypedDict):
60
+ type: str
61
+ paymentMethods: list[PaymentMethod]
62
+
63
+
64
+ class CreatePaymentPayload(TypedDict, total=False):
65
+ amount: int
66
+ currency: str
67
+ externalId: str
68
+ description: str
69
+ buyer: BuyerData
70
+ status: str
71
+
72
+
73
+ PAYMENT_METHODS: list[PaymentMethodGroup] = [
74
+ {
75
+ "type": "PBL",
76
+ "paymentMethods": [
77
+ {
78
+ "id": 2001,
79
+ "name": "mTransfer",
80
+ "description": "mBank",
81
+ "image": "https://static.paynow.pl/payment-method-icons/2001.png",
82
+ "status": "ENABLED",
83
+ "authorizationType": "REDIRECT",
84
+ }
85
+ ],
86
+ },
87
+ {
88
+ "type": "CARD",
89
+ "paymentMethods": [
90
+ {
91
+ "id": 3001,
92
+ "name": "Visa",
93
+ "description": "Visa",
94
+ "image": "https://static.paynow.pl/payment-method-icons/3001.png",
95
+ "status": "ENABLED",
96
+ "authorizationType": "REDIRECT",
97
+ }
98
+ ],
99
+ },
100
+ {
101
+ "type": "BLIK",
102
+ "paymentMethods": [
103
+ {
104
+ "id": 5001,
105
+ "name": "BLIK",
106
+ "description": "BLIK",
107
+ "image": "https://static.paynow.pl/payment-method-icons/5001.png",
108
+ "status": "ENABLED",
109
+ "authorizationType": "CODE",
110
+ }
111
+ ],
112
+ },
113
+ ]
114
+
115
+
116
+ def _provider_config(request: Request[Any, Any, Any]) -> dict[str, Any]:
117
+ return dict(request.app.state.provider_configs["paynow"])
118
+
119
+
120
+ def _format_amount_for_display(
121
+ payment: dict[str, Any],
122
+ provider_config: dict[str, Any],
123
+ ) -> str:
124
+ amount_raw = payment.get("amount", payment.get("totalAmount", 0))
125
+ try:
126
+ amount_value = Decimal(str(amount_raw))
127
+ except (InvalidOperation, TypeError, ValueError):
128
+ return str(amount_raw)
129
+
130
+ minor_unit_places = int(provider_config.get("amount_minor_unit_places", 2))
131
+ if minor_unit_places >= 0:
132
+ amount_value /= Decimal(10) ** minor_unit_places
133
+
134
+ currency = payment.get("currency", payment.get("currencyCode", "PLN"))
135
+ return f"{amount_value:.2f} {currency}"
136
+
137
+
138
+ def _error_response(
139
+ status_code: int,
140
+ error_type: str,
141
+ message: str,
142
+ ) -> Response[object]:
143
+ return Response(
144
+ content={
145
+ "statusCode": status_code,
146
+ "errors": [{"errorType": error_type, "message": message}],
147
+ },
148
+ status_code=status_code,
149
+ media_type=MediaType.JSON,
150
+ )
151
+
152
+
153
+ def _warn_if_signature_missing(request: Request[Any, Any, Any]) -> None:
154
+ if request.headers.get("signature"):
155
+ return
156
+ logger.warning("Signature header missing for PayNow request")
157
+
158
+
159
+ def _validate_create_payload(payload: object) -> list[str]:
160
+ if not isinstance(payload, dict):
161
+ return ["Request body must be an object"]
162
+
163
+ typed_payload = cast("CreatePaymentPayload", cast("object", payload))
164
+ required_fields = [
165
+ "amount",
166
+ "currency",
167
+ "externalId",
168
+ "description",
169
+ "buyer",
170
+ ]
171
+ errors = [
172
+ f"Field '{field_name}' is required"
173
+ for field_name in required_fields
174
+ if field_name not in typed_payload
175
+ ]
176
+
177
+ buyer = typed_payload.get("buyer")
178
+ if "buyer" in typed_payload and (
179
+ not isinstance(buyer, dict) or not buyer.get("email")
180
+ ):
181
+ errors.append("Field 'buyer.email' is required")
182
+
183
+ amount = typed_payload.get("amount")
184
+ if "amount" in typed_payload and not isinstance(amount, int):
185
+ errors.append("Field 'amount' must be an integer")
186
+
187
+ status = typed_payload.get("status", "NEW")
188
+ if status not in PAYNOW_STATUSES:
189
+ errors.append("Field 'status' has invalid value")
190
+ return errors
191
+
192
+
193
+ @post("/paynow/v3/payments")
194
+ async def create_payment(
195
+ request: Request[Any, Any, Any],
196
+ ) -> Response[object]:
197
+ _warn_if_signature_missing(request)
198
+
199
+ payload_object: object = await request.json()
200
+ validation_errors = _validate_create_payload(payload_object)
201
+ if validation_errors:
202
+ return _error_response(400, "VALIDATION_ERROR", validation_errors[0])
203
+
204
+ payload = cast("CreatePaymentPayload", payload_object)
205
+ payment_data = dict(payload)
206
+ payment_data["status"] = str(payload.get("status", "NEW"))
207
+
208
+ provider_config = _provider_config(request)
209
+ notify_url = provider_config.get("notify_url")
210
+ if notify_url:
211
+ payment_data["notifyUrl"] = notify_url
212
+
213
+ payment_id = request.app.state.storage.create_order(
214
+ payment_data,
215
+ provider="paynow",
216
+ )
217
+
218
+ host = request.headers.get("host", "localhost")
219
+ redirect_url = f"http://{host}/sim/paynow/authorize/{payment_id}"
220
+ response_body = {
221
+ "redirectUrl": redirect_url,
222
+ "paymentId": payment_id,
223
+ "status": payment_data["status"],
224
+ }
225
+ return Response(
226
+ content=response_body,
227
+ status_code=201,
228
+ media_type=MediaType.JSON,
229
+ )
230
+
231
+
232
+ @get("/paynow/v3/payments/{payment_id:str}/status")
233
+ async def get_payment_status(
234
+ request: Request[Any, Any, Any],
235
+ payment_id: str,
236
+ ) -> Response[object]:
237
+ _warn_if_signature_missing(request)
238
+
239
+ payment = request.app.state.storage.get_order(payment_id)
240
+ if payment is None or payment.get("provider") != "paynow":
241
+ return _error_response(
242
+ 404, "NOT_FOUND", f"Payment {payment_id} not found"
243
+ )
244
+
245
+ return Response(
246
+ content={
247
+ "paymentId": payment_id,
248
+ "status": str(payment.get("status", "NEW")),
249
+ },
250
+ status_code=200,
251
+ media_type=MediaType.JSON,
252
+ )
253
+
254
+
255
+ @get("/paynow/v3/payments/paymentmethods")
256
+ async def get_payment_methods(
257
+ request: Request[Any, Any, Any],
258
+ ) -> Response[object]:
259
+ _warn_if_signature_missing(request)
260
+ return Response(
261
+ content=PAYMENT_METHODS, status_code=200, media_type=MediaType.JSON
262
+ )
263
+
264
+
265
+ @post("/paynow/v3/payments/{payment_id:str}/refunds")
266
+ async def create_refund(
267
+ request: Request[Any, Any, Any],
268
+ payment_id: str,
269
+ ) -> Response[object]:
270
+ _warn_if_signature_missing(request)
271
+
272
+ payment = request.app.state.storage.get_order(payment_id)
273
+ if payment is None or payment.get("provider") != "paynow":
274
+ return _error_response(
275
+ 404, "NOT_FOUND", f"Payment {payment_id} not found"
276
+ )
277
+
278
+ payment_status = payment.get("status", "NEW")
279
+ if payment_status != "CONFIRMED":
280
+ return _error_response(
281
+ 400,
282
+ "VALIDATION_ERROR",
283
+ f"Payment not in CONFIRMED status (current: {payment_status})",
284
+ )
285
+
286
+ payload = await request.json()
287
+ if not isinstance(payload, dict):
288
+ payload = {}
289
+
290
+ amount = payload.get("amount")
291
+ reason = payload.get("reason")
292
+ if amount is None:
293
+ return _error_response(
294
+ 400, "VALIDATION_ERROR", "Field 'amount' is required"
295
+ )
296
+
297
+ refund_data = {"amount": amount, "status": "SUCCESSFUL"}
298
+ if reason is not None:
299
+ refund_data["reason"] = reason
300
+
301
+ refund_id = request.app.state.storage.create_refund(payment_id, refund_data)
302
+ return Response(
303
+ content={"refundId": refund_id, "status": "SUCCESSFUL"},
304
+ status_code=201,
305
+ media_type=MediaType.JSON,
306
+ )
307
+
308
+
309
+ @get("/paynow/v3/refunds/{refund_id:str}/status")
310
+ async def get_refund_status(
311
+ request: Request[Any, Any, Any],
312
+ refund_id: str,
313
+ ) -> Response[object]:
314
+ _warn_if_signature_missing(request)
315
+
316
+ refund = request.app.state.storage.get_refund(refund_id)
317
+ if refund is None:
318
+ return _error_response(
319
+ 404, "NOT_FOUND", f"Refund {refund_id} not found"
320
+ )
321
+
322
+ amount = refund.get("amount")
323
+ if isinstance(amount, str):
324
+ amount = int(amount)
325
+ return Response(
326
+ content={
327
+ "refundId": refund_id,
328
+ "status": str(refund.get("status", "SUCCESSFUL")),
329
+ "amount": amount,
330
+ },
331
+ status_code=200,
332
+ media_type=MediaType.JSON,
333
+ )
334
+
335
+
336
+ @post("/paynow/v3/refunds/{refund_id:str}/cancel")
337
+ async def cancel_refund(
338
+ request: Request[Any, Any, Any],
339
+ refund_id: str,
340
+ ) -> Response[object]:
341
+ _warn_if_signature_missing(request)
342
+
343
+ refund = request.app.state.storage.get_refund(refund_id)
344
+ if refund is None:
345
+ return _error_response(
346
+ 404, "NOT_FOUND", f"Refund {refund_id} not found"
347
+ )
348
+
349
+ request.app.state.storage.update_refund(refund_id, status="CANCELLED")
350
+ return Response(
351
+ content={"refundId": refund_id, "status": "CANCELLED"},
352
+ status_code=200,
353
+ media_type=MediaType.JSON,
354
+ )
355
+
356
+
357
+ @get("/sim/paynow/authorize/{payment_id:str}")
358
+ async def paynow_authorize_get(
359
+ payment_id: str,
360
+ request: Request[Any, Any, Any],
361
+ ) -> Template:
362
+ payment = request.app.state.storage.get_order(payment_id)
363
+ if not payment:
364
+ raise NotFoundException("Payment not found")
365
+
366
+ if payment.get("status") in ("CONFIRMED", "REJECTED"):
367
+ raise HTTPException(status_code=400, detail="Payment already processed")
368
+
369
+ formatted_amount = _format_amount_for_display(
370
+ payment,
371
+ _provider_config(request),
372
+ )
373
+
374
+ return Template(
375
+ template_name="authorize.html",
376
+ context={
377
+ "provider": "PayNow",
378
+ "payment": payment,
379
+ "payment_id": payment_id,
380
+ "order_id": payment_id,
381
+ "amount": formatted_amount,
382
+ "status": payment.get("status", "NEW"),
383
+ },
384
+ )
385
+
386
+
387
+ @post("/sim/paynow/authorize/{payment_id:str}")
388
+ async def paynow_authorize_post(
389
+ payment_id: str,
390
+ request: Request[Any, Any, Any],
391
+ data: dict[str, str] = URL_ENCODED_BODY,
392
+ ) -> Redirect:
393
+ payment = request.app.state.storage.get_order(payment_id)
394
+ if not payment:
395
+ raise NotFoundException("Payment not found")
396
+
397
+ current_status = payment.get("status", "NEW")
398
+ if current_status in ("CONFIRMED", "REJECTED"):
399
+ raise HTTPException(status_code=400, detail="Payment already processed")
400
+
401
+ action = data.get("action")
402
+ if action == "approve":
403
+ if current_status == "NEW":
404
+ request.app.state.state_machine.transition(payment_id, "PENDING")
405
+ request.app.state.state_machine.transition(payment_id, "CONFIRMED")
406
+ elif action == "reject":
407
+ if current_status == "NEW":
408
+ request.app.state.state_machine.transition(payment_id, "PENDING")
409
+ request.app.state.state_machine.transition(payment_id, "REJECTED")
410
+ else:
411
+ raise HTTPException(status_code=400, detail="Invalid action")
412
+
413
+ await trigger_paynow_webhook(
414
+ payment_id,
415
+ request.app.state.storage,
416
+ _provider_config(request),
417
+ request.app.state.webhook_transport,
418
+ )
419
+
420
+ continue_url = payment.get("continueUrl", "/sim/dashboard")
421
+ 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,18 @@
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
+ )
@@ -0,0 +1,163 @@
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
+ "amount_minor_unit_places": 2,
100
+ "api_key": "override-api-key",
101
+ "signature_key": "override-signature",
102
+ "notify_url": "https://merchant.example/paynow/callback",
103
+ }
104
+
105
+
106
+ def test_load_provider_config_includes_amount_minor_unit_places() -> None:
107
+ assert load_provider_config()["amount_minor_unit_places"] == 2
108
+
109
+
110
+ @pytest.mark.asyncio
111
+ async def test_trigger_paynow_webhook_uses_provider_notify_url_fallback() -> (
112
+ None
113
+ ):
114
+ storage = FakeStorage(
115
+ {
116
+ "externalId": "PAYNOW-42",
117
+ "status": "CONFIRMED",
118
+ }
119
+ )
120
+ transport = FakeTransport()
121
+
122
+ result = await trigger_paynow_webhook(
123
+ "payment-1",
124
+ storage,
125
+ {
126
+ "signature_key": "secret-key",
127
+ "notify_url": "https://merchant.example/paynow/callback",
128
+ },
129
+ transport,
130
+ )
131
+
132
+ assert result is True
133
+ assert storage.updated == {"webhook_status": "success"}
134
+ assert len(transport.calls) == 1
135
+
136
+ request = transport.calls[0]
137
+ assert request["url"] == "https://merchant.example/paynow/callback"
138
+ body = request["body"]
139
+ assert isinstance(body, bytes)
140
+ payload = json.loads(body)
141
+ assert payload["paymentId"] == "payment-1"
142
+ assert payload["externalId"] == "PAYNOW-42"
143
+ assert payload["status"] == "CONFIRMED"
144
+ assert request["headers"] == {
145
+ "Content-Type": "application/json",
146
+ **sign_webhook(body, "secret-key"),
147
+ }
148
+
149
+
150
+ @pytest.mark.asyncio
151
+ async def test_trigger_paynow_webhook_returns_none_without_target() -> None:
152
+ storage = FakeStorage({"externalId": "PAYNOW-42", "status": "CONFIRMED"})
153
+ transport = FakeTransport()
154
+
155
+ result = await trigger_paynow_webhook(
156
+ "payment-1",
157
+ storage,
158
+ {"signature_key": "secret-key", "notify_url": ""},
159
+ transport,
160
+ )
161
+
162
+ assert result is None
163
+ assert transport.calls == []
@@ -1,7 +0,0 @@
1
- """Tests for the public package API."""
2
-
3
- import getpaid_paynow
4
-
5
-
6
- def test_version() -> None:
7
- assert getpaid_paynow.__version__ == "3.0.0a3"