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.
@@ -0,0 +1,3 @@
1
+ """Payment gateway simulator for testing the python-getpaid ecosystem."""
2
+
3
+ __version__ = "3.0.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()
@@ -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