python-getpaid-simulator 3.0.0__py3-none-any.whl
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.
- getpaid_simulator/__init__.py +3 -0
- getpaid_simulator/__main__.py +112 -0
- getpaid_simulator/app.py +132 -0
- getpaid_simulator/core/__init__.py +1 -0
- getpaid_simulator/core/config.py +57 -0
- getpaid_simulator/core/discovery.py +12 -0
- getpaid_simulator/core/state.py +58 -0
- getpaid_simulator/core/storage.py +155 -0
- getpaid_simulator/core/webhooks.py +60 -0
- getpaid_simulator/plugins.py +216 -0
- getpaid_simulator/py.typed +0 -0
- getpaid_simulator/spi.py +31 -0
- getpaid_simulator/ui/__init__.py +1 -0
- getpaid_simulator/ui/routes.py +104 -0
- getpaid_simulator/ui/static/.gitkeep +0 -0
- getpaid_simulator/ui/static/style.css +262 -0
- getpaid_simulator/ui/templates/.gitkeep +0 -0
- getpaid_simulator/ui/templates/authorize.html +24 -0
- getpaid_simulator/ui/templates/base.html +31 -0
- getpaid_simulator/ui/templates/components/dashboard_payment_card.html +9 -0
- getpaid_simulator/ui/templates/components/payment_card.html +17 -0
- getpaid_simulator/ui/templates/components/status_badge.html +3 -0
- getpaid_simulator/ui/templates/dashboard.html +45 -0
- python_getpaid_simulator-3.0.0.dist-info/METADATA +115 -0
- python_getpaid_simulator-3.0.0.dist-info/RECORD +28 -0
- python_getpaid_simulator-3.0.0.dist-info/WHEEL +4 -0
- python_getpaid_simulator-3.0.0.dist-info/entry_points.txt +2 -0
- python_getpaid_simulator-3.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""CLI entry point for getpaid-simulator."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from getpaid_simulator import __version__
|
|
9
|
+
from getpaid_simulator.app import create_app
|
|
10
|
+
from getpaid_simulator.core.config import SimulatorConfig
|
|
11
|
+
from getpaid_simulator.plugins import load_provider_plugins
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _print_startup_banner(
|
|
15
|
+
config: SimulatorConfig,
|
|
16
|
+
loaded_providers: list[str],
|
|
17
|
+
failed_providers: list[str],
|
|
18
|
+
) -> None:
|
|
19
|
+
"""Print startup banner with discovered providers and dashboard URL.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
config: Simulator configuration.
|
|
23
|
+
loaded_providers: Display names for loaded simulator plugins.
|
|
24
|
+
failed_providers: Slugs for failed plugins.
|
|
25
|
+
"""
|
|
26
|
+
providers_str = ", ".join(loaded_providers) if loaded_providers else "none"
|
|
27
|
+
failed_str = ", ".join(failed_providers) if failed_providers else "none"
|
|
28
|
+
|
|
29
|
+
dashboard_url = f"http://{config.host}:{config.port}/sim/"
|
|
30
|
+
status_label = "DEGRADED" if failed_providers else "READY"
|
|
31
|
+
|
|
32
|
+
banner = f"""
|
|
33
|
+
╔══════════════════════════════════════╗
|
|
34
|
+
║ 🔶 getpaid-simulator v{__version__:<18}║
|
|
35
|
+
║ ⚠ SIMULATOR — NOT REAL PAYMENTS ║
|
|
36
|
+
║ Status: {status_label:<27}║
|
|
37
|
+
║ Providers: {providers_str:<23}║
|
|
38
|
+
║ Failed: {failed_str:<26}║
|
|
39
|
+
║ Dashboard: {dashboard_url:<23}║
|
|
40
|
+
╚══════════════════════════════════════╝
|
|
41
|
+
"""
|
|
42
|
+
print(banner)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main() -> None:
|
|
46
|
+
"""CLI entry point for getpaid-simulator."""
|
|
47
|
+
parser = argparse.ArgumentParser(
|
|
48
|
+
description="GetPaid Payment Gateway Simulator",
|
|
49
|
+
prog="getpaid-simulator",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--host",
|
|
53
|
+
default=None,
|
|
54
|
+
help="Server host (default: 0.0.0.0)",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--port",
|
|
58
|
+
type=int,
|
|
59
|
+
default=None,
|
|
60
|
+
help="Server port (default: 9000)",
|
|
61
|
+
)
|
|
62
|
+
parser.add_argument(
|
|
63
|
+
"--log-level",
|
|
64
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
65
|
+
default=None,
|
|
66
|
+
help="Logging level (default: INFO)",
|
|
67
|
+
)
|
|
68
|
+
parser.add_argument(
|
|
69
|
+
"--plugin-failure-mode",
|
|
70
|
+
choices=["strict", "warn"],
|
|
71
|
+
default=None,
|
|
72
|
+
help="Plugin failure mode (default: warn)",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
args = parser.parse_args()
|
|
76
|
+
|
|
77
|
+
# Load config from environment variables
|
|
78
|
+
config = SimulatorConfig.from_env()
|
|
79
|
+
|
|
80
|
+
# Override with CLI arguments if provided
|
|
81
|
+
if args.host is not None:
|
|
82
|
+
config.host = args.host
|
|
83
|
+
if args.port is not None:
|
|
84
|
+
config.port = args.port
|
|
85
|
+
if args.log_level is not None:
|
|
86
|
+
config.log_level = args.log_level
|
|
87
|
+
if args.plugin_failure_mode is not None:
|
|
88
|
+
config.plugin_failure_mode = args.plugin_failure_mode
|
|
89
|
+
|
|
90
|
+
plugin_load_result = load_provider_plugins(config)
|
|
91
|
+
_print_startup_banner(
|
|
92
|
+
config,
|
|
93
|
+
loaded_providers=[
|
|
94
|
+
plugin.display_name for plugin in plugin_load_result.loaded_plugins
|
|
95
|
+
],
|
|
96
|
+
failed_providers=[
|
|
97
|
+
failure.slug for failure in plugin_load_result.failed_plugins
|
|
98
|
+
],
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# Create and run app
|
|
102
|
+
app = create_app(config, plugin_load_result)
|
|
103
|
+
uvicorn.run(
|
|
104
|
+
app,
|
|
105
|
+
host=config.host,
|
|
106
|
+
port=config.port,
|
|
107
|
+
log_level=config.log_level.lower(),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
getpaid_simulator/app.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Litestar application for payment gateway simulator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from litestar import Litestar
|
|
9
|
+
from litestar import get
|
|
10
|
+
from litestar.contrib.jinja import JinjaTemplateEngine
|
|
11
|
+
from litestar.datastructures import State
|
|
12
|
+
from litestar.static_files import create_static_files_router
|
|
13
|
+
from litestar.template.config import TemplateConfig
|
|
14
|
+
|
|
15
|
+
from getpaid_simulator.core.config import SimulatorConfig
|
|
16
|
+
from getpaid_simulator.core.state import InvalidTransitionError
|
|
17
|
+
from getpaid_simulator.core.state import PaymentStateMachine
|
|
18
|
+
from getpaid_simulator.core.storage import SimulatorStorage
|
|
19
|
+
from getpaid_simulator.core.webhooks import WebhookTransport
|
|
20
|
+
from getpaid_simulator.plugins import PluginLoadResult
|
|
21
|
+
from getpaid_simulator.plugins import load_provider_plugins
|
|
22
|
+
from getpaid_simulator.ui import routes as ui_routes
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@get("/")
|
|
29
|
+
async def health(state: State) -> dict[str, object]:
|
|
30
|
+
"""Health check endpoint."""
|
|
31
|
+
loaded_plugins = list(state.loaded_plugins)
|
|
32
|
+
failed_plugins = [failure.slug for failure in state.failed_plugins]
|
|
33
|
+
status = "degraded" if failed_plugins else "ok"
|
|
34
|
+
return {
|
|
35
|
+
"status": status,
|
|
36
|
+
"service": "getpaid-simulator",
|
|
37
|
+
"loadedProviders": loaded_plugins,
|
|
38
|
+
"failedProviders": failed_plugins,
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@get("/sim/status")
|
|
43
|
+
async def simulator_status(state: State) -> dict[str, object]:
|
|
44
|
+
"""Detailed status endpoint for the simulator host."""
|
|
45
|
+
return {
|
|
46
|
+
"status": "degraded" if state.failed_plugins else "ok",
|
|
47
|
+
"loadedProviders": [
|
|
48
|
+
{
|
|
49
|
+
"slug": slug,
|
|
50
|
+
"displayName": plugin.display_name,
|
|
51
|
+
}
|
|
52
|
+
for slug, plugin in state.loaded_plugins.items()
|
|
53
|
+
],
|
|
54
|
+
"failedProviders": [
|
|
55
|
+
{
|
|
56
|
+
"slug": failure.slug,
|
|
57
|
+
"stage": failure.stage,
|
|
58
|
+
"error": failure.error,
|
|
59
|
+
}
|
|
60
|
+
for failure in state.failed_plugins
|
|
61
|
+
],
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def create_app(
|
|
66
|
+
config: SimulatorConfig | None = None,
|
|
67
|
+
plugin_load_result: PluginLoadResult | None = None,
|
|
68
|
+
) -> Litestar:
|
|
69
|
+
config = config or SimulatorConfig.from_env()
|
|
70
|
+
plugin_load_result = plugin_load_result or load_provider_plugins(config)
|
|
71
|
+
loaded_plugins = {
|
|
72
|
+
plugin.slug: plugin for plugin in plugin_load_result.loaded_plugins
|
|
73
|
+
}
|
|
74
|
+
state_machine = PaymentStateMachine(SimulatorStorage())
|
|
75
|
+
for plugin in plugin_load_result.loaded_plugins:
|
|
76
|
+
state_machine.register_provider(plugin.slug, plugin.transitions)
|
|
77
|
+
|
|
78
|
+
route_handlers = [health, simulator_status, ui_routes.dashboard]
|
|
79
|
+
for plugin in plugin_load_result.loaded_plugins:
|
|
80
|
+
route_handlers.extend(plugin.api_handlers)
|
|
81
|
+
route_handlers.extend(plugin.ui_handlers)
|
|
82
|
+
|
|
83
|
+
loaded_display_names = [
|
|
84
|
+
plugin.display_name for plugin in plugin_load_result.loaded_plugins
|
|
85
|
+
]
|
|
86
|
+
failed_slugs = [
|
|
87
|
+
failure.slug for failure in plugin_load_result.failed_plugins
|
|
88
|
+
]
|
|
89
|
+
if loaded_display_names:
|
|
90
|
+
logger.info(
|
|
91
|
+
"Loaded simulator plugins: %s",
|
|
92
|
+
", ".join(loaded_display_names),
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
logger.info("Loaded simulator plugins: none")
|
|
96
|
+
if failed_slugs:
|
|
97
|
+
logger.warning(
|
|
98
|
+
"Failed simulator plugins: %s",
|
|
99
|
+
", ".join(failed_slugs),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
storage = state_machine.storage
|
|
103
|
+
webhook_transport = WebhookTransport(
|
|
104
|
+
timeout=config.webhook_timeout,
|
|
105
|
+
max_retries=config.webhook_max_retries,
|
|
106
|
+
)
|
|
107
|
+
state = State(
|
|
108
|
+
{
|
|
109
|
+
"storage": storage,
|
|
110
|
+
"state_machine": state_machine,
|
|
111
|
+
"webhook_transport": webhook_transport,
|
|
112
|
+
"config": config,
|
|
113
|
+
"loaded_plugins": loaded_plugins,
|
|
114
|
+
"provider_configs": plugin_load_result.provider_configs,
|
|
115
|
+
"failed_plugins": plugin_load_result.failed_plugins,
|
|
116
|
+
"invalid_transition_error": InvalidTransitionError,
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
static_files_router = create_static_files_router(
|
|
120
|
+
path="/static",
|
|
121
|
+
directories=[Path(__file__).parent / "ui" / "static"],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return Litestar(
|
|
125
|
+
route_handlers=[*route_handlers, static_files_router],
|
|
126
|
+
state=state,
|
|
127
|
+
template_config=TemplateConfig(
|
|
128
|
+
engine=JinjaTemplateEngine(
|
|
129
|
+
directory=Path(__file__).parent / "ui" / "templates"
|
|
130
|
+
)
|
|
131
|
+
),
|
|
132
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core simulator components."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Configuration system for getpaid-simulator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
PluginFailureMode = Literal["strict", "warn"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SimulatorConfig:
|
|
15
|
+
"""Configuration for the payment simulator.
|
|
16
|
+
|
|
17
|
+
All configuration values can be overridden via environment variables
|
|
18
|
+
with the SIMULATOR_ prefix.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
host: str = "0.0.0.0"
|
|
22
|
+
port: int = 9000
|
|
23
|
+
webhook_timeout: float = 5.0
|
|
24
|
+
webhook_max_retries: int = 3
|
|
25
|
+
log_level: str = "INFO"
|
|
26
|
+
plugin_failure_mode: PluginFailureMode = "warn"
|
|
27
|
+
|
|
28
|
+
@classmethod
|
|
29
|
+
def from_env(cls) -> "SimulatorConfig":
|
|
30
|
+
"""Load configuration from environment variables.
|
|
31
|
+
|
|
32
|
+
Environment variables use the SIMULATOR_ prefix (e.g.,
|
|
33
|
+
SIMULATOR_PORT=8080 overrides the port default).
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
SimulatorConfig: Configuration instance with env var overrides.
|
|
37
|
+
"""
|
|
38
|
+
plugin_failure_mode = os.environ.get(
|
|
39
|
+
"SIMULATOR_PLUGIN_FAILURE_MODE", "warn"
|
|
40
|
+
)
|
|
41
|
+
if plugin_failure_mode not in {"strict", "warn"}:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
"SIMULATOR_PLUGIN_FAILURE_MODE must be 'strict' or 'warn'"
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return cls(
|
|
47
|
+
host=os.environ.get("SIMULATOR_HOST", "0.0.0.0"),
|
|
48
|
+
port=int(os.environ.get("SIMULATOR_PORT", "9000")),
|
|
49
|
+
webhook_timeout=float(
|
|
50
|
+
os.environ.get("SIMULATOR_WEBHOOK_TIMEOUT", "5.0")
|
|
51
|
+
),
|
|
52
|
+
webhook_max_retries=int(
|
|
53
|
+
os.environ.get("SIMULATOR_WEBHOOK_MAX_RETRIES", "3")
|
|
54
|
+
),
|
|
55
|
+
log_level=os.environ.get("SIMULATOR_LOG_LEVEL", "INFO"),
|
|
56
|
+
plugin_failure_mode=plugin_failure_mode,
|
|
57
|
+
)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from getpaid_simulator.core.config import SimulatorConfig
|
|
4
|
+
from getpaid_simulator.plugins import load_provider_plugins
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ENTRY_POINT_GROUP = "getpaid.simulator.providers"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def discover_providers() -> list[str]:
|
|
11
|
+
result = load_provider_plugins(SimulatorConfig())
|
|
12
|
+
return [plugin.slug for plugin in result.loaded_plugins]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from getpaid_simulator.core.storage import SimulatorStorage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class InvalidTransitionError(Exception):
|
|
9
|
+
def __init__(self, current: str, requested: str):
|
|
10
|
+
self.current = current
|
|
11
|
+
self.requested = requested
|
|
12
|
+
self.error_response = {
|
|
13
|
+
"status": {
|
|
14
|
+
"statusCode": "ERROR_VALUE_INVALID",
|
|
15
|
+
"statusDesc": (
|
|
16
|
+
f"Cannot transition from {current} to {requested}"
|
|
17
|
+
),
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
super().__init__(self.error_response["status"]["statusDesc"])
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class PaymentStateMachine:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
storage: SimulatorStorage,
|
|
27
|
+
transitions: dict[str, set[str]] | None = None,
|
|
28
|
+
):
|
|
29
|
+
self.storage = storage
|
|
30
|
+
self._default_transitions = transitions or {}
|
|
31
|
+
self._provider_transitions: dict[str, dict[str, set[str]]] = {}
|
|
32
|
+
|
|
33
|
+
def register_provider(
|
|
34
|
+
self,
|
|
35
|
+
provider_slug: str,
|
|
36
|
+
transitions: dict[str, set[str]],
|
|
37
|
+
) -> None:
|
|
38
|
+
self._provider_transitions[provider_slug] = transitions
|
|
39
|
+
|
|
40
|
+
def transition(self, order_id: str, new_status: str) -> dict[str, Any]:
|
|
41
|
+
order = self.storage.get_order(order_id)
|
|
42
|
+
if order is None:
|
|
43
|
+
raise KeyError(order_id)
|
|
44
|
+
|
|
45
|
+
provider = order.get("provider", "payu")
|
|
46
|
+
transitions = self._provider_transitions.get(
|
|
47
|
+
provider, self._default_transitions
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
current_status = str(order.get("status", "NEW"))
|
|
51
|
+
if new_status not in transitions.get(current_status, set()):
|
|
52
|
+
raise InvalidTransitionError(current_status, new_status)
|
|
53
|
+
|
|
54
|
+
self.storage.update_order(order_id, status=new_status)
|
|
55
|
+
updated_order = self.storage.get_order(order_id)
|
|
56
|
+
if updated_order is None:
|
|
57
|
+
raise KeyError(order_id)
|
|
58
|
+
return updated_order
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import defaultdict
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from datetime import UTC
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from datetime import timedelta
|
|
8
|
+
from decimal import Decimal
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _centify(value: Any) -> Any:
|
|
14
|
+
if isinstance(value, bool):
|
|
15
|
+
return value
|
|
16
|
+
if isinstance(value, int):
|
|
17
|
+
return str(value)
|
|
18
|
+
if isinstance(value, Decimal):
|
|
19
|
+
return str(int(value))
|
|
20
|
+
if isinstance(value, list):
|
|
21
|
+
return [_centify(item) for item in value]
|
|
22
|
+
if isinstance(value, dict):
|
|
23
|
+
return {key: _centify(item) for key, item in value.items()}
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _centify_dict(data: dict[str, Any]) -> dict[str, Any]:
|
|
28
|
+
return _centify(data)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SimulatorStorage:
|
|
32
|
+
def __init__(self) -> None:
|
|
33
|
+
self._orders: dict[str, dict[str, Any]] = {}
|
|
34
|
+
self._tokens: dict[str, dict[str, Any]] = {}
|
|
35
|
+
self._refunds: defaultdict[str, list[dict[str, Any]]] = defaultdict(
|
|
36
|
+
list
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def create_order(
|
|
40
|
+
self,
|
|
41
|
+
data: dict[str, Any] | None = None,
|
|
42
|
+
*,
|
|
43
|
+
provider: str | None = None,
|
|
44
|
+
total_amount: int | str | Decimal | None = None,
|
|
45
|
+
currency: str | None = None,
|
|
46
|
+
description: str | None = None,
|
|
47
|
+
notify_url: str = "",
|
|
48
|
+
continue_url: str | None = None,
|
|
49
|
+
buyer_email: str | None = None,
|
|
50
|
+
) -> str:
|
|
51
|
+
order_id = uuid4().hex
|
|
52
|
+
if data is None:
|
|
53
|
+
if provider is None:
|
|
54
|
+
raise TypeError(
|
|
55
|
+
"provider is required when data is not provided"
|
|
56
|
+
)
|
|
57
|
+
if total_amount is None:
|
|
58
|
+
raise TypeError(
|
|
59
|
+
"total_amount is required when data is not provided"
|
|
60
|
+
)
|
|
61
|
+
if currency is None:
|
|
62
|
+
raise TypeError(
|
|
63
|
+
"currency is required when data is not provided"
|
|
64
|
+
)
|
|
65
|
+
if description is None:
|
|
66
|
+
raise TypeError(
|
|
67
|
+
"description is required when data is not provided"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
order_payload: dict[str, Any] = {
|
|
71
|
+
"provider": provider,
|
|
72
|
+
"status": "NEW",
|
|
73
|
+
"totalAmount": total_amount,
|
|
74
|
+
"currencyCode": currency,
|
|
75
|
+
"description": description,
|
|
76
|
+
"continueUrl": continue_url,
|
|
77
|
+
"buyer": {},
|
|
78
|
+
}
|
|
79
|
+
if notify_url:
|
|
80
|
+
order_payload["notifyUrl"] = notify_url
|
|
81
|
+
if buyer_email:
|
|
82
|
+
order_payload["buyer"] = {"email": buyer_email}
|
|
83
|
+
else:
|
|
84
|
+
order_payload = deepcopy(data)
|
|
85
|
+
order_payload["provider"] = provider or str(
|
|
86
|
+
order_payload.get("provider", "payu")
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
order_data = _centify_dict(order_payload)
|
|
90
|
+
order_data["id"] = order_id
|
|
91
|
+
self._orders[order_id] = order_data
|
|
92
|
+
return order_id
|
|
93
|
+
|
|
94
|
+
def get_order(self, order_id: str) -> dict[str, Any] | None:
|
|
95
|
+
order = self._orders.get(order_id)
|
|
96
|
+
if order is None:
|
|
97
|
+
return None
|
|
98
|
+
return deepcopy(order)
|
|
99
|
+
|
|
100
|
+
def update_order(self, order_id: str, **updates) -> None:
|
|
101
|
+
if order_id not in self._orders:
|
|
102
|
+
raise KeyError(order_id)
|
|
103
|
+
self._orders[order_id].update(_centify(deepcopy(updates)))
|
|
104
|
+
|
|
105
|
+
def list_orders(self) -> list[dict[str, Any]]:
|
|
106
|
+
return [deepcopy(order) for order in self._orders.values()]
|
|
107
|
+
|
|
108
|
+
def list_orders_by_provider(self, provider: str) -> list[dict[str, Any]]:
|
|
109
|
+
return [
|
|
110
|
+
deepcopy(order)
|
|
111
|
+
for order in self._orders.values()
|
|
112
|
+
if order.get("provider") == provider
|
|
113
|
+
]
|
|
114
|
+
|
|
115
|
+
def create_token(
|
|
116
|
+
self, pos_id: str, expires_in: int = 3600
|
|
117
|
+
) -> dict[str, Any]:
|
|
118
|
+
access_token = uuid4().hex
|
|
119
|
+
self._tokens[access_token] = {
|
|
120
|
+
"pos_id": pos_id,
|
|
121
|
+
"expires_at": datetime.now(UTC) + timedelta(seconds=expires_in),
|
|
122
|
+
}
|
|
123
|
+
return {"access_token": access_token, "expires_in": expires_in}
|
|
124
|
+
|
|
125
|
+
def validate_token(self, token: str) -> bool:
|
|
126
|
+
token_data = self._tokens.get(token)
|
|
127
|
+
if token_data is None:
|
|
128
|
+
return False
|
|
129
|
+
return datetime.now(UTC) < token_data["expires_at"]
|
|
130
|
+
|
|
131
|
+
def create_refund(self, order_id: str, data: dict[str, Any]) -> str:
|
|
132
|
+
refund_id = uuid4().hex
|
|
133
|
+
refund_data = _centify_dict(deepcopy(data))
|
|
134
|
+
refund_data["id"] = refund_id
|
|
135
|
+
refund_data["order_id"] = order_id
|
|
136
|
+
self._refunds[order_id].append(refund_data)
|
|
137
|
+
return refund_id
|
|
138
|
+
|
|
139
|
+
def get_refunds(self, order_id: str) -> list[dict[str, Any]]:
|
|
140
|
+
return [deepcopy(refund) for refund in self._refunds.get(order_id, [])]
|
|
141
|
+
|
|
142
|
+
def get_refund(self, refund_id: str) -> dict[str, Any] | None:
|
|
143
|
+
for refunds in self._refunds.values():
|
|
144
|
+
for refund in refunds:
|
|
145
|
+
if refund.get("id") == refund_id:
|
|
146
|
+
return deepcopy(refund)
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def update_refund(self, refund_id: str, **updates: Any) -> None:
|
|
150
|
+
for refunds in self._refunds.values():
|
|
151
|
+
for refund in refunds:
|
|
152
|
+
if refund.get("id") == refund_id:
|
|
153
|
+
refund.update(_centify(deepcopy(updates)))
|
|
154
|
+
return
|
|
155
|
+
raise KeyError(refund_id)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Generic webhook delivery helpers for simulator plugins."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class WebhookTransport:
|
|
16
|
+
"""Generic webhook transport with retry handling."""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
timeout: float = 5.0,
|
|
21
|
+
retry_delay: float = 0.0,
|
|
22
|
+
max_retries: int = 1,
|
|
23
|
+
) -> None:
|
|
24
|
+
self.timeout = timeout
|
|
25
|
+
self.retry_delay = retry_delay
|
|
26
|
+
self.max_retries = max_retries
|
|
27
|
+
|
|
28
|
+
async def deliver(
|
|
29
|
+
self,
|
|
30
|
+
*,
|
|
31
|
+
url: str,
|
|
32
|
+
body: bytes,
|
|
33
|
+
headers: Mapping[str, str],
|
|
34
|
+
) -> bool:
|
|
35
|
+
"""Send webhook payload with retry handling."""
|
|
36
|
+
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
|
37
|
+
attempts = self.max_retries + 1
|
|
38
|
+
for attempt in range(attempts):
|
|
39
|
+
try:
|
|
40
|
+
response = await client.post(
|
|
41
|
+
url, content=body, headers=headers
|
|
42
|
+
)
|
|
43
|
+
if 400 <= response.status_code < 500:
|
|
44
|
+
return False
|
|
45
|
+
if 200 <= response.status_code < 300:
|
|
46
|
+
return True
|
|
47
|
+
if attempt < attempts - 1:
|
|
48
|
+
await asyncio.sleep(self.retry_delay)
|
|
49
|
+
continue
|
|
50
|
+
return False
|
|
51
|
+
except (
|
|
52
|
+
httpx.ConnectError,
|
|
53
|
+
httpx.TimeoutException,
|
|
54
|
+
httpx.RequestError,
|
|
55
|
+
):
|
|
56
|
+
if attempt < attempts - 1:
|
|
57
|
+
await asyncio.sleep(self.retry_delay)
|
|
58
|
+
continue
|
|
59
|
+
return False
|
|
60
|
+
return False
|