python-getpaid-payu 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 (47) hide show
  1. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/PKG-INFO +37 -1
  2. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/README.md +33 -0
  3. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/pyproject.toml +9 -0
  4. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/src/getpaid_payu/__init__.py +1 -1
  5. python_getpaid_payu-3.0.0a4/src/getpaid_payu/simulator/__init__.py +6 -0
  6. python_getpaid_payu-3.0.0a4/src/getpaid_payu/simulator/plugin.py +58 -0
  7. python_getpaid_payu-3.0.0a4/src/getpaid_payu/simulator/routes.py +394 -0
  8. python_getpaid_payu-3.0.0a4/src/getpaid_payu/simulator/signing.py +14 -0
  9. python_getpaid_payu-3.0.0a4/src/getpaid_payu/simulator/transitions.py +9 -0
  10. python_getpaid_payu-3.0.0a4/src/getpaid_payu/simulator/webhooks.py +77 -0
  11. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/test_public_api.py +1 -1
  12. python_getpaid_payu-3.0.0a4/tests/test_simulator_plugin.py +153 -0
  13. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/uv.lock +500 -4
  14. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/.github/workflows/ci.yml +0 -0
  15. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/.gitignore +0 -0
  16. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/.pre-commit-config.yaml +0 -0
  17. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/.python-version +0 -0
  18. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/.readthedocs.yml +0 -0
  19. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/.sisyphus/evidence/task-26-readme-payu.txt +0 -0
  20. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/.sisyphus/evidence/task-5-baseline-payu.txt +0 -0
  21. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/CODE_OF_CONDUCT.md +0 -0
  22. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/CONTRIBUTING.md +0 -0
  23. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/LICENSE +0 -0
  24. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/changelog.md +0 -0
  25. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/codeofconduct.md +0 -0
  26. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/concepts.md +0 -0
  27. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/conf.py +0 -0
  28. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/configuration.md +0 -0
  29. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/contributing.md +0 -0
  30. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/getting-started.md +0 -0
  31. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/index.md +0 -0
  32. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/license.md +0 -0
  33. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/reference.md +0 -0
  34. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/docs/requirements.txt +0 -0
  35. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/sandbox_keys.txt +0 -0
  36. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/src/getpaid_payu/client.py +0 -0
  37. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/src/getpaid_payu/processor.py +0 -0
  38. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/src/getpaid_payu/py.typed +0 -0
  39. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/src/getpaid_payu/types.py +0 -0
  40. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/__init__.py +0 -0
  41. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/conftest.py +0 -0
  42. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/test_callback.py +0 -0
  43. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/test_client.py +0 -0
  44. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/test_entry_points.py +0 -0
  45. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/test_processor.py +0 -0
  46. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/test_types.py +0 -0
  47. {python_getpaid_payu-3.0.0a3 → python_getpaid_payu-3.0.0a4}/tests/test_url_construction.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-getpaid-payu
3
- Version: 3.0.0a3
3
+ Version: 3.0.0a4
4
4
  Summary: PayU payment gateway integration for python-getpaid ecosystem.
5
5
  Project-URL: Homepage, https://github.com/django-getpaid/python-getpaid-payu
6
6
  Project-URL: Repository, https://github.com/django-getpaid/python-getpaid-payu
@@ -20,6 +20,9 @@ Classifier: Typing :: Typed
20
20
  Requires-Python: >=3.12
21
21
  Requires-Dist: httpx>=0.27.0
22
22
  Requires-Dist: python-getpaid-core>=3.0.0a3
23
+ Provides-Extra: simulator
24
+ Requires-Dist: litestar>=2.0; extra == 'simulator'
25
+ Requires-Dist: python-getpaid-simulator>=3.0.0a3; extra == 'simulator'
23
26
  Description-Content-Type: text/markdown
24
27
 
25
28
  # python-getpaid-payu
@@ -53,6 +56,39 @@ BGN, CHF, CZK, DKK, EUR, GBP, HRK, HUF, NOK, PLN, RON, RUB, SEK, UAH, USD.
53
56
  pip install python-getpaid-payu
54
57
  ```
55
58
 
59
+ Install simulator support only when you want this package to register its local
60
+ simulator plugin with `python-getpaid-simulator`:
61
+
62
+ ```bash
63
+ pip install python-getpaid-payu[simulator]
64
+ ```
65
+
66
+ This extra installs the simulator host and Litestar dependencies, then exposes
67
+ the `payu` plugin through the `getpaid.simulator.providers` entry point.
68
+
69
+ ## Simulator Plugin
70
+
71
+ When `python-getpaid-payu[simulator]` is installed alongside
72
+ `python-getpaid-simulator`, the simulator host auto-discovers the PayU plugin.
73
+
74
+ Typical local setup:
75
+
76
+ ```bash
77
+ pip install python-getpaid-simulator python-getpaid-payu[simulator]
78
+ getpaid-simulator
79
+ ```
80
+
81
+ The plugin contributes:
82
+
83
+ - PayU OAuth and order/refund simulator API routes
84
+ - PayU authorization UI at `/sim/payu/authorize/{order_id}`
85
+ - PayU-specific webhook signing and state transitions
86
+
87
+ Useful simulator environment variables:
88
+
89
+ - `SIMULATOR_PAYU_SECOND_KEY`
90
+ - `SIMULATOR_PLUGIN_FAILURE_MODE` (`warn` or `strict`)
91
+
56
92
  ## Configuration
57
93
 
58
94
  To use the PayU backend, register it in your `getpaid` configuration and provide the following settings:
@@ -29,6 +29,39 @@ BGN, CHF, CZK, DKK, EUR, GBP, HRK, HUF, NOK, PLN, RON, RUB, SEK, UAH, USD.
29
29
  pip install python-getpaid-payu
30
30
  ```
31
31
 
32
+ Install simulator support only when you want this package to register its local
33
+ simulator plugin with `python-getpaid-simulator`:
34
+
35
+ ```bash
36
+ pip install python-getpaid-payu[simulator]
37
+ ```
38
+
39
+ This extra installs the simulator host and Litestar dependencies, then exposes
40
+ the `payu` plugin through the `getpaid.simulator.providers` entry point.
41
+
42
+ ## Simulator Plugin
43
+
44
+ When `python-getpaid-payu[simulator]` is installed alongside
45
+ `python-getpaid-simulator`, the simulator host auto-discovers the PayU plugin.
46
+
47
+ Typical local setup:
48
+
49
+ ```bash
50
+ pip install python-getpaid-simulator python-getpaid-payu[simulator]
51
+ getpaid-simulator
52
+ ```
53
+
54
+ The plugin contributes:
55
+
56
+ - PayU OAuth and order/refund simulator API routes
57
+ - PayU authorization UI at `/sim/payu/authorize/{order_id}`
58
+ - PayU-specific webhook signing and state transitions
59
+
60
+ Useful simulator environment variables:
61
+
62
+ - `SIMULATOR_PAYU_SECOND_KEY`
63
+ - `SIMULATOR_PLUGIN_FAILURE_MODE` (`warn` or `strict`)
64
+
32
65
  ## Configuration
33
66
 
34
67
  To use the PayU backend, register it in your `getpaid` configuration and provide the following settings:
@@ -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',
@@ -48,6 +54,9 @@ Changelog = 'https://github.com/django-getpaid/python-getpaid-payu/releases'
48
54
  [project.entry-points."getpaid.backends"]
49
55
  payu = 'getpaid_payu.processor:PayUProcessor'
50
56
 
57
+ [project.entry-points."getpaid.simulator.providers"]
58
+ payu = 'getpaid_payu.simulator:get_plugin'
59
+
51
60
  [build-system]
52
61
  requires = ['hatchling']
53
62
  build-backend = 'hatchling.build'
@@ -9,4 +9,4 @@ __all__ = [
9
9
  "PayUProcessor",
10
10
  ]
11
11
 
12
- __version__ = "3.0.0a3"
12
+ __version__ = "3.0.0a4"
@@ -0,0 +1,6 @@
1
+ """PayU simulator plugin for python-getpaid-simulator."""
2
+
3
+ from getpaid_payu.simulator.plugin import get_plugin
4
+
5
+
6
+ __all__ = ["get_plugin"]
@@ -0,0 +1,58 @@
1
+ """PayU 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_payu.simulator.routes import cancel_order
13
+ from getpaid_payu.simulator.routes import capture_order
14
+ from getpaid_payu.simulator.routes import create_order
15
+ from getpaid_payu.simulator.routes import create_refund
16
+ from getpaid_payu.simulator.routes import get_order_info
17
+ from getpaid_payu.simulator.routes import oauth_endpoint
18
+ from getpaid_payu.simulator.routes import payu_authorize_get
19
+ from getpaid_payu.simulator.routes import payu_authorize_post
20
+ from getpaid_payu.simulator.routes import test_protected_endpoint
21
+ from getpaid_payu.simulator.transitions import PAYU_TRANSITIONS
22
+
23
+
24
+ if TYPE_CHECKING:
25
+ from collections.abc import Mapping
26
+
27
+
28
+ def load_provider_config(
29
+ env: Mapping[str, str] | None = None,
30
+ ) -> dict[str, Any]:
31
+ environment = env or os.environ
32
+ return {
33
+ "second_key": environment.get(
34
+ "SIMULATOR_PAYU_SECOND_KEY",
35
+ "b6ca15b0d1020e8094d9b5f8d163db54",
36
+ ),
37
+ }
38
+
39
+
40
+ def get_plugin() -> SimulatorProviderPlugin:
41
+ return SimulatorProviderPlugin(
42
+ api_version=SIMULATOR_PLUGIN_API_VERSION,
43
+ slug="payu",
44
+ display_name="PayU",
45
+ api_handlers=(
46
+ oauth_endpoint,
47
+ test_protected_endpoint,
48
+ create_order,
49
+ get_order_info,
50
+ cancel_order,
51
+ capture_order,
52
+ create_refund,
53
+ ),
54
+ ui_handlers=(payu_authorize_get, payu_authorize_post),
55
+ transitions=PAYU_TRANSITIONS,
56
+ load_config=load_provider_config,
57
+ authorize_path_template="/sim/payu/authorize/{entity_id}",
58
+ )
@@ -0,0 +1,394 @@
1
+ """PayU simulator routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import UTC
6
+ from datetime import datetime
7
+ from inspect import isawaitable
8
+ from typing import Any
9
+ from urllib.parse import parse_qsl
10
+
11
+ from litestar import Request
12
+ from litestar import delete
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_payu.simulator.webhooks import trigger_payu_webhook
25
+
26
+
27
+ URL_ENCODED_BODY = Body(media_type=RequestEncodingType.URL_ENCODED)
28
+
29
+
30
+ def _provider_config(request: Request[Any, Any, Any]) -> dict[str, Any]:
31
+ return dict(request.app.state.provider_configs["payu"])
32
+
33
+
34
+ def _unauthorized_response() -> Response[dict[str, dict[str, str]]]:
35
+ return Response(
36
+ content={
37
+ "status": {
38
+ "statusCode": "UNAUTHORIZED",
39
+ "statusDesc": "Invalid or expired token",
40
+ }
41
+ },
42
+ status_code=401,
43
+ media_type=MediaType.JSON,
44
+ )
45
+
46
+
47
+ def _not_found_response(order_id: str) -> Response[dict[str, dict[str, str]]]:
48
+ return Response(
49
+ content={
50
+ "status": {
51
+ "statusCode": "ERROR_ORDER_NOT_EXISTS",
52
+ "statusDesc": f"Order {order_id} not found",
53
+ }
54
+ },
55
+ status_code=404,
56
+ media_type=MediaType.JSON,
57
+ )
58
+
59
+
60
+ def _extract_bearer_token(request: Request[Any, Any, Any]) -> str | None:
61
+ authorization_header = request.headers.get("authorization")
62
+ if authorization_header is None:
63
+ return None
64
+
65
+ token_type, _, token_value = authorization_header.partition(" ")
66
+ if token_type.lower() != "bearer" or not token_value:
67
+ return None
68
+ return token_value
69
+
70
+
71
+ def _is_authorized(request: Request[Any, Any, Any]) -> bool:
72
+ token = _extract_bearer_token(request)
73
+ if token is None:
74
+ return False
75
+ return request.app.state.storage.validate_token(token)
76
+
77
+
78
+ def _order_to_payu_order(
79
+ order_id: str,
80
+ order_data: dict[str, Any],
81
+ ) -> dict[str, Any]:
82
+ payu_order = dict(order_data)
83
+ payu_order.pop("id", None)
84
+ payu_order["orderId"] = order_id
85
+ return payu_order
86
+
87
+
88
+ @post("/payu/pl/standard/user/oauth/authorize")
89
+ async def oauth_endpoint(
90
+ request: Request[Any, Any, Any],
91
+ ) -> Response[dict[str, Any]]:
92
+ raw_body = await request.body()
93
+ form_data = dict(parse_qsl(raw_body.decode()))
94
+ client_id = form_data.get("client_id", "")
95
+ token_data = request.app.state.storage.create_token(
96
+ pos_id=client_id,
97
+ expires_in=43199,
98
+ )
99
+ return Response(
100
+ content={
101
+ "access_token": token_data["access_token"],
102
+ "token_type": "bearer",
103
+ "expires_in": 43199,
104
+ "grant_type": "client_credentials",
105
+ },
106
+ status_code=200,
107
+ media_type=MediaType.JSON,
108
+ )
109
+
110
+
111
+ @get("/payu/api/v2_1/test-protected")
112
+ async def test_protected_endpoint(
113
+ request: Request[Any, Any, Any],
114
+ ) -> Response[dict[str, Any]]:
115
+ if not _is_authorized(request):
116
+ return _unauthorized_response()
117
+
118
+ return Response(
119
+ content={"status": "ok"},
120
+ status_code=200,
121
+ media_type=MediaType.JSON,
122
+ )
123
+
124
+
125
+ @post("/payu/api/v2_1/orders")
126
+ async def create_order(
127
+ request: Request[Any, Any, Any],
128
+ ) -> Response[dict[str, Any]]:
129
+ if not _is_authorized(request):
130
+ return _unauthorized_response()
131
+
132
+ payload = await request.json()
133
+ if not isinstance(payload, dict):
134
+ payload = {}
135
+
136
+ order_data = dict(payload)
137
+ order_data["status"] = "NEW"
138
+ order_id = request.app.state.storage.create_order(
139
+ order_data,
140
+ provider="payu",
141
+ )
142
+ request.app.state.state_machine.transition(order_id, "PENDING")
143
+
144
+ host = request.headers.get("host", "localhost")
145
+ redirect_uri = f"http://{host}/sim/payu/authorize/{order_id}"
146
+ response_body = {
147
+ "status": {"statusCode": "SUCCESS"},
148
+ "orderId": order_id,
149
+ "extOrderId": order_data.get("extOrderId"),
150
+ "redirectUri": redirect_uri,
151
+ }
152
+ return Response(
153
+ content=response_body,
154
+ status_code=302,
155
+ headers={"Location": redirect_uri},
156
+ media_type=MediaType.JSON,
157
+ )
158
+
159
+
160
+ @get("/payu/api/v2_1/orders/{order_id:str}")
161
+ async def get_order_info(
162
+ request: Request[Any, Any, Any],
163
+ order_id: str,
164
+ ) -> Response[dict[str, Any]]:
165
+ if not _is_authorized(request):
166
+ return _unauthorized_response()
167
+
168
+ order = request.app.state.storage.get_order(order_id)
169
+ if order is None:
170
+ return _not_found_response(order_id)
171
+
172
+ return Response(
173
+ content={
174
+ "orders": [_order_to_payu_order(order_id, order)],
175
+ "status": {"statusCode": "SUCCESS"},
176
+ },
177
+ status_code=200,
178
+ media_type=MediaType.JSON,
179
+ )
180
+
181
+
182
+ @delete("/payu/api/v2_1/orders/{order_id:str}", status_code=200)
183
+ async def cancel_order(
184
+ request: Request[Any, Any, Any],
185
+ order_id: str,
186
+ ) -> Response[dict[str, Any]]:
187
+ if not _is_authorized(request):
188
+ return _unauthorized_response()
189
+
190
+ order = request.app.state.storage.get_order(order_id)
191
+ if order is None:
192
+ return _not_found_response(order_id)
193
+
194
+ try:
195
+ request.app.state.state_machine.transition(order_id, "CANCELED")
196
+ except request.app.state.invalid_transition_error as error:
197
+ return Response(
198
+ content=error.error_response,
199
+ status_code=200,
200
+ media_type=MediaType.JSON,
201
+ )
202
+
203
+ callback = trigger_payu_webhook(
204
+ order_id,
205
+ request.app.state.storage,
206
+ _provider_config(request),
207
+ request.app.state.webhook_transport,
208
+ )
209
+ if isawaitable(callback):
210
+ await callback
211
+
212
+ return Response(
213
+ content={"status": {"statusCode": "SUCCESS"}, "orderId": order_id},
214
+ status_code=200,
215
+ media_type=MediaType.JSON,
216
+ )
217
+
218
+
219
+ @post("/payu/api/v2_1/orders/{order_id:str}/captures")
220
+ async def capture_order(
221
+ request: Request[Any, Any, Any],
222
+ order_id: str,
223
+ ) -> Response[dict[str, Any]]:
224
+ if not _is_authorized(request):
225
+ return _unauthorized_response()
226
+
227
+ order = request.app.state.storage.get_order(order_id)
228
+ if order is None:
229
+ return _not_found_response(order_id)
230
+
231
+ try:
232
+ request.app.state.state_machine.transition(order_id, "COMPLETED")
233
+ except request.app.state.invalid_transition_error as error:
234
+ return Response(
235
+ content=error.error_response,
236
+ status_code=200,
237
+ media_type=MediaType.JSON,
238
+ )
239
+
240
+ callback = trigger_payu_webhook(
241
+ order_id,
242
+ request.app.state.storage,
243
+ _provider_config(request),
244
+ request.app.state.webhook_transport,
245
+ )
246
+ if isawaitable(callback):
247
+ await callback
248
+
249
+ return Response(
250
+ content={"status": {"statusCode": "SUCCESS"}, "orderId": order_id},
251
+ status_code=200,
252
+ media_type=MediaType.JSON,
253
+ )
254
+
255
+
256
+ @post("/payu/api/v2_1/orders/{order_id:str}/refunds")
257
+ async def create_refund(
258
+ request: Request[Any, Any, Any],
259
+ order_id: str,
260
+ ) -> Response[dict[str, Any]]:
261
+ if not _is_authorized(request):
262
+ return _unauthorized_response()
263
+
264
+ order = request.app.state.storage.get_order(order_id)
265
+ if order is None:
266
+ return _not_found_response(order_id)
267
+
268
+ payload = await request.json()
269
+ if not isinstance(payload, dict):
270
+ payload = {}
271
+
272
+ refund_info = payload.get("refund", {})
273
+
274
+ amount = refund_info.get("amount")
275
+ if amount is None:
276
+ amount = order.get("totalAmount", "0")
277
+
278
+ description = refund_info.get("description", "Refund")
279
+ ext_refund_id = refund_info.get("extRefundId")
280
+ currency_code = refund_info.get(
281
+ "currencyCode", order.get("currencyCode", "PLN")
282
+ )
283
+
284
+ now = datetime.now(UTC).isoformat()
285
+ refund_data = {
286
+ "amount": amount,
287
+ "currencyCode": currency_code,
288
+ "description": description,
289
+ "status": "FINALIZED",
290
+ "creationDateTime": now,
291
+ "statusDateTime": now,
292
+ }
293
+
294
+ if ext_refund_id is not None:
295
+ refund_data["extRefundId"] = ext_refund_id
296
+
297
+ refund_id = request.app.state.storage.create_refund(order_id, refund_data)
298
+
299
+ response_body = {
300
+ "status": {"statusCode": "SUCCESS"},
301
+ "orderId": order_id,
302
+ "refund": {
303
+ "refundId": refund_id,
304
+ "extRefundId": ext_refund_id,
305
+ "amount": amount,
306
+ "currencyCode": currency_code,
307
+ "description": description,
308
+ "status": "FINALIZED",
309
+ "statusDateTime": now,
310
+ },
311
+ }
312
+
313
+ return Response(
314
+ content=response_body,
315
+ status_code=200,
316
+ media_type=MediaType.JSON,
317
+ )
318
+
319
+
320
+ @get("/sim/payu/authorize/{order_id:str}")
321
+ async def payu_authorize_get(
322
+ order_id: str,
323
+ request: Request[Any, Any, Any],
324
+ ) -> Template:
325
+ order = request.app.state.storage.get_order(order_id)
326
+ if not order:
327
+ raise NotFoundException("Payment not found")
328
+
329
+ if order.get("status") in ("COMPLETED", "CANCELED"):
330
+ raise HTTPException(status_code=400, detail="Payment already processed")
331
+
332
+ amount_raw = order.get("totalAmount", 0)
333
+ try:
334
+ amount_value = float(amount_raw) / 100
335
+ formatted_amount = (
336
+ f"{amount_value:.2f} {order.get('currencyCode', 'PLN')}"
337
+ )
338
+ except (ValueError, TypeError):
339
+ formatted_amount = str(amount_raw)
340
+
341
+ return Template(
342
+ template_name="authorize.html",
343
+ context={
344
+ "provider": "PayU",
345
+ "payment": order,
346
+ "order_id": order_id,
347
+ "amount": formatted_amount,
348
+ "status": order.get("status", "NEW"),
349
+ },
350
+ )
351
+
352
+
353
+ @post("/sim/payu/authorize/{order_id:str}")
354
+ async def payu_authorize_post(
355
+ order_id: str,
356
+ request: Request[Any, Any, Any],
357
+ data: dict[str, str] = URL_ENCODED_BODY,
358
+ ) -> Redirect:
359
+ order = request.app.state.storage.get_order(order_id)
360
+ if not order:
361
+ raise NotFoundException("Payment not found")
362
+
363
+ current_status = order.get("status", "NEW")
364
+ if current_status in ("COMPLETED", "CANCELED"):
365
+ raise HTTPException(status_code=400, detail="Payment already processed")
366
+
367
+ action = data.get("action")
368
+ if action == "approve":
369
+ if current_status == "NEW":
370
+ request.app.state.state_machine.transition(order_id, "PENDING")
371
+ request.app.state.state_machine.transition(
372
+ order_id, "WAITING_FOR_CONFIRMATION"
373
+ )
374
+ elif current_status == "PENDING":
375
+ request.app.state.state_machine.transition(
376
+ order_id, "WAITING_FOR_CONFIRMATION"
377
+ )
378
+ request.app.state.state_machine.transition(order_id, "COMPLETED")
379
+ elif action == "reject":
380
+ if current_status == "NEW":
381
+ request.app.state.state_machine.transition(order_id, "PENDING")
382
+ request.app.state.state_machine.transition(order_id, "CANCELED")
383
+ else:
384
+ raise HTTPException(status_code=400, detail="Invalid action")
385
+
386
+ await trigger_payu_webhook(
387
+ order_id,
388
+ request.app.state.storage,
389
+ _provider_config(request),
390
+ request.app.state.webhook_transport,
391
+ )
392
+
393
+ continue_url = order.get("continueUrl", "/")
394
+ return Redirect(path=str(continue_url))
@@ -0,0 +1,14 @@
1
+ """PayU webhook signing helpers for the simulator plugin."""
2
+
3
+ from hashlib import sha256
4
+
5
+
6
+ def compute_signature(body: bytes, second_key: str) -> str:
7
+ """Return the PayU callback signature."""
8
+ return sha256(body + second_key.encode()).hexdigest()
9
+
10
+
11
+ def sign_payload(body: bytes, second_key: str) -> str:
12
+ """Return the full PayU signature header value."""
13
+ signature = compute_signature(body, second_key)
14
+ return f"signature={signature};algorithm=SHA-256;sender=checkout"
@@ -0,0 +1,9 @@
1
+ """PayU simulator state transitions."""
2
+
3
+ PAYU_TRANSITIONS: dict[str, set[str]] = {
4
+ "NEW": {"PENDING"},
5
+ "PENDING": {"WAITING_FOR_CONFIRMATION", "CANCELED"},
6
+ "WAITING_FOR_CONFIRMATION": {"COMPLETED", "CANCELED"},
7
+ "COMPLETED": set(),
8
+ "CANCELED": set(),
9
+ }
@@ -0,0 +1,77 @@
1
+ """PayU 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_payu.simulator.signing import sign_payload
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_order_notification(
20
+ order_id: str,
21
+ order: dict[str, Any],
22
+ ) -> dict[str, Any]:
23
+ """Build the PayU OrderNotification payload."""
24
+ order_data: dict[str, Any] = {
25
+ "orderId": order_id,
26
+ "orderCreateDate": datetime.now(UTC).isoformat(),
27
+ "extOrderId": order.get("extOrderId"),
28
+ "notifyUrl": order.get("notifyUrl"),
29
+ "customerIp": order.get("customerIp", "127.0.0.1"),
30
+ "merchantPosId": order.get("merchantPosId"),
31
+ "description": order.get("description"),
32
+ "currencyCode": order.get("currencyCode"),
33
+ "totalAmount": order.get("totalAmount"),
34
+ "status": order.get("status"),
35
+ "buyer": order.get("buyer", {}),
36
+ "products": order.get("products", []),
37
+ }
38
+ return {
39
+ "order": order_data,
40
+ "localReceiptDateTime": datetime.now(UTC).isoformat(),
41
+ "properties": None,
42
+ }
43
+
44
+
45
+ async def trigger_payu_webhook(
46
+ order_id: str,
47
+ storage: SimulatorStorage,
48
+ provider_config: dict[str, Any],
49
+ transport: WebhookTransport,
50
+ ) -> bool | None:
51
+ """Send a PayU order notification to the merchant callback URL."""
52
+ order = storage.get_order(order_id)
53
+ if order is None:
54
+ return None
55
+
56
+ notify_url = order.get("notifyUrl")
57
+ if not notify_url:
58
+ return None
59
+
60
+ payload = build_order_notification(order_id, order)
61
+ body = json.dumps(payload).encode("utf-8")
62
+ headers = {
63
+ "Content-Type": "application/json",
64
+ "OpenPayU-Signature": sign_payload(
65
+ body,
66
+ str(provider_config["second_key"]),
67
+ ),
68
+ }
69
+
70
+ result = await transport.deliver(
71
+ url=str(notify_url), body=body, headers=headers
72
+ )
73
+ storage.update_order(
74
+ order_id,
75
+ webhook_status="success" if result else "failed",
76
+ )
77
+ return result
@@ -4,4 +4,4 @@ import getpaid_payu
4
4
 
5
5
 
6
6
  def test_version() -> None:
7
- assert getpaid_payu.__version__ == "3.0.0a3"
7
+ assert getpaid_payu.__version__ == "3.0.0a4"