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,216 @@
1
+ """Plugin loading for simulator provider integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from dataclasses import field
9
+ from importlib.metadata import entry_points
10
+ from typing import Any
11
+
12
+ from getpaid_simulator.core.config import SimulatorConfig
13
+ from getpaid_simulator.spi import SIMULATOR_PLUGIN_API_VERSION
14
+ from getpaid_simulator.spi import SimulatorProviderPlugin
15
+
16
+
17
+ ENTRY_POINT_GROUP = "getpaid.simulator.providers"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ProviderLoadFailure:
22
+ """Structured plugin load failure for logging and UI reporting."""
23
+
24
+ slug: str
25
+ stage: str
26
+ error: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class PluginLoadResult:
31
+ """Loaded plugins, failures, and validated provider configs."""
32
+
33
+ loaded_plugins: tuple[SimulatorProviderPlugin, ...] = ()
34
+ failed_plugins: tuple[ProviderLoadFailure, ...] = ()
35
+ provider_configs: dict[str, Mapping[str, Any]] = field(default_factory=dict)
36
+
37
+
38
+ class PluginLoadError(RuntimeError):
39
+ """Raised in strict mode when a simulator plugin cannot be loaded."""
40
+
41
+ def __init__(self, failure: ProviderLoadFailure):
42
+ self.failure = failure
43
+ super().__init__(
44
+ f"Failed to load simulator plugin {failure.slug!r} during "
45
+ f"{failure.stage}: {failure.error}"
46
+ )
47
+
48
+
49
+ def load_provider_plugins(
50
+ config: SimulatorConfig,
51
+ env: Mapping[str, str] | None = None,
52
+ ) -> PluginLoadResult:
53
+ """Load simulator provider plugins from entry points."""
54
+ environment = env or os.environ
55
+ loaded_plugins: list[SimulatorProviderPlugin] = []
56
+ failed_plugins: list[ProviderLoadFailure] = []
57
+ provider_configs: dict[str, Mapping[str, Any]] = {}
58
+
59
+ for entry_point in sorted(
60
+ entry_points(group=ENTRY_POINT_GROUP),
61
+ key=lambda candidate: candidate.name,
62
+ ):
63
+ slug = entry_point.name or "<unknown>"
64
+
65
+ try:
66
+ plugin_factory = entry_point.load()
67
+ except Exception as exc:
68
+ _handle_failure(
69
+ config=config,
70
+ failed_plugins=failed_plugins,
71
+ failure=ProviderLoadFailure(
72
+ slug=slug,
73
+ stage="import",
74
+ error=str(exc),
75
+ ),
76
+ )
77
+ continue
78
+
79
+ try:
80
+ plugin = (
81
+ plugin_factory() if callable(plugin_factory) else plugin_factory
82
+ )
83
+ except Exception as exc:
84
+ _handle_failure(
85
+ config=config,
86
+ failed_plugins=failed_plugins,
87
+ failure=ProviderLoadFailure(
88
+ slug=slug,
89
+ stage="factory",
90
+ error=str(exc),
91
+ ),
92
+ )
93
+ continue
94
+
95
+ failure = _validate_plugin(slug, plugin)
96
+ if failure is not None:
97
+ _handle_failure(
98
+ config=config,
99
+ failed_plugins=failed_plugins,
100
+ failure=failure,
101
+ )
102
+ continue
103
+
104
+ normalized_plugin = _normalize_plugin(plugin)
105
+
106
+ try:
107
+ provider_configs[normalized_plugin.slug] = dict(
108
+ normalized_plugin.load_config(environment)
109
+ )
110
+ except Exception as exc:
111
+ _handle_failure(
112
+ config=config,
113
+ failed_plugins=failed_plugins,
114
+ failure=ProviderLoadFailure(
115
+ slug=normalized_plugin.slug,
116
+ stage="config",
117
+ error=str(exc),
118
+ ),
119
+ )
120
+ continue
121
+
122
+ loaded_plugins.append(normalized_plugin)
123
+
124
+ return PluginLoadResult(
125
+ loaded_plugins=tuple(loaded_plugins),
126
+ failed_plugins=tuple(failed_plugins),
127
+ provider_configs=provider_configs,
128
+ )
129
+
130
+
131
+ def _validate_plugin(
132
+ entry_point_slug: str,
133
+ plugin: object,
134
+ ) -> ProviderLoadFailure | None:
135
+ required_fields = {
136
+ "api_version",
137
+ "slug",
138
+ "display_name",
139
+ "api_handlers",
140
+ "ui_handlers",
141
+ "transitions",
142
+ "load_config",
143
+ }
144
+ missing_fields = [
145
+ field_name
146
+ for field_name in sorted(required_fields)
147
+ if not hasattr(plugin, field_name)
148
+ ]
149
+ if missing_fields:
150
+ return ProviderLoadFailure(
151
+ slug=entry_point_slug,
152
+ stage="factory",
153
+ error=(
154
+ "plugin object is missing required fields: "
155
+ f"{', '.join(missing_fields)}"
156
+ ),
157
+ )
158
+
159
+ plugin_api_version = str(getattr(plugin, "api_version"))
160
+ plugin_slug = str(getattr(plugin, "slug"))
161
+ if plugin_api_version != SIMULATOR_PLUGIN_API_VERSION:
162
+ return ProviderLoadFailure(
163
+ slug=plugin_slug,
164
+ stage="compatibility",
165
+ error=(
166
+ "plugin API version "
167
+ f"{plugin_api_version!r} is incompatible with host "
168
+ f"{SIMULATOR_PLUGIN_API_VERSION!r}"
169
+ ),
170
+ )
171
+
172
+ if plugin_slug != entry_point_slug:
173
+ return ProviderLoadFailure(
174
+ slug=entry_point_slug,
175
+ stage="compatibility",
176
+ error=(
177
+ f"plugin slug {plugin_slug!r} does not match entry point "
178
+ f"name {entry_point_slug!r}"
179
+ ),
180
+ )
181
+
182
+ return None
183
+
184
+
185
+ def _normalize_plugin(plugin: object) -> SimulatorProviderPlugin:
186
+ transitions = {
187
+ str(status): set(next_statuses)
188
+ for status, next_statuses in dict(
189
+ getattr(plugin, "transitions")
190
+ ).items()
191
+ }
192
+ authorize_path_template = getattr(plugin, "authorize_path_template", None)
193
+ if authorize_path_template is not None:
194
+ authorize_path_template = str(authorize_path_template)
195
+
196
+ return SimulatorProviderPlugin(
197
+ api_version=str(getattr(plugin, "api_version")),
198
+ slug=str(getattr(plugin, "slug")),
199
+ display_name=str(getattr(plugin, "display_name")),
200
+ api_handlers=tuple(getattr(plugin, "api_handlers")),
201
+ ui_handlers=tuple(getattr(plugin, "ui_handlers")),
202
+ transitions=transitions,
203
+ load_config=getattr(plugin, "load_config"),
204
+ authorize_path_template=authorize_path_template,
205
+ )
206
+
207
+
208
+ def _handle_failure(
209
+ *,
210
+ config: SimulatorConfig,
211
+ failed_plugins: list[ProviderLoadFailure],
212
+ failure: ProviderLoadFailure,
213
+ ) -> None:
214
+ if config.plugin_failure_mode == "strict":
215
+ raise PluginLoadError(failure)
216
+ failed_plugins.append(failure)
File without changes
@@ -0,0 +1,31 @@
1
+ """Stable plugin API for simulator provider integrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any
9
+
10
+
11
+ SIMULATOR_PLUGIN_API_VERSION = "1"
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SimulatorProviderPlugin:
16
+ """Declarative simulator provider plugin descriptor."""
17
+
18
+ api_version: str
19
+ slug: str
20
+ display_name: str
21
+ api_handlers: tuple[Any, ...]
22
+ ui_handlers: tuple[Any, ...]
23
+ transitions: dict[str, set[str]]
24
+ load_config: Callable[[Mapping[str, str]], Mapping[str, Any]]
25
+ authorize_path_template: str | None = None
26
+
27
+ def build_authorize_path(self, entity_id: str) -> str | None:
28
+ """Return the provider-specific authorize URL for dashboard links."""
29
+ if self.authorize_path_template is None:
30
+ return None
31
+ return self.authorize_path_template.format(entity_id=entity_id)
@@ -0,0 +1 @@
1
+ """UI components."""
@@ -0,0 +1,104 @@
1
+ from decimal import Decimal
2
+ from decimal import InvalidOperation
3
+ from typing import Any
4
+ from typing import Optional
5
+
6
+ from litestar import get
7
+ from litestar.datastructures import State
8
+ from litestar.response import Template
9
+
10
+ from getpaid_simulator.core.storage import SimulatorStorage
11
+ from getpaid_simulator.plugins import ProviderLoadFailure
12
+ from getpaid_simulator.spi import SimulatorProviderPlugin
13
+
14
+
15
+ def _format_amount_for_display(
16
+ order: dict[str, Any],
17
+ provider_config: dict[str, Any] | None,
18
+ ) -> str:
19
+ amount_raw = order.get("amount", order.get("totalAmount", 0))
20
+ currency = str(order.get("currency", order.get("currencyCode", "PLN")))
21
+ try:
22
+ amount_value = Decimal(str(amount_raw))
23
+ except (InvalidOperation, TypeError, ValueError):
24
+ return str(amount_raw)
25
+
26
+ minor_unit_places = _minor_unit_places(provider_config)
27
+ if minor_unit_places is not None:
28
+ amount_value /= Decimal(10) ** minor_unit_places
29
+
30
+ return f"{amount_value:.2f} {currency}"
31
+
32
+
33
+ def _minor_unit_places(provider_config: dict[str, Any] | None) -> int | None:
34
+ if provider_config is None:
35
+ return None
36
+
37
+ raw_value = provider_config.get("amount_minor_unit_places")
38
+ if raw_value is None:
39
+ return None
40
+
41
+ try:
42
+ places = int(raw_value)
43
+ except (TypeError, ValueError):
44
+ return None
45
+
46
+ if places < 0:
47
+ return None
48
+
49
+ return places
50
+
51
+
52
+ @get(["/sim/", "/sim/dashboard"])
53
+ async def dashboard(state: State, provider: Optional[str] = None) -> Template:
54
+ """Render payments dashboard."""
55
+ storage: SimulatorStorage = state.storage
56
+ loaded_plugins: dict[str, SimulatorProviderPlugin] = state.loaded_plugins
57
+ failed_plugins: tuple[ProviderLoadFailure, ...] = state.failed_plugins
58
+ if provider:
59
+ orders_data = storage.list_orders_by_provider(provider)
60
+ else:
61
+ orders_data = storage.list_orders()
62
+
63
+ provider_filters = [
64
+ {
65
+ "slug": slug,
66
+ "display_name": plugin.display_name,
67
+ }
68
+ for slug, plugin in loaded_plugins.items()
69
+ ]
70
+
71
+ orders = []
72
+ for order in orders_data:
73
+ provider_slug = str(order.get("provider", "unknown"))
74
+ plugin = loaded_plugins.get(provider_slug)
75
+ provider_config = state.provider_configs.get(provider_slug)
76
+ orders.append(
77
+ {
78
+ "id": order["id"],
79
+ "provider": provider_slug,
80
+ "provider_display_name": (
81
+ plugin.display_name if plugin is not None else provider_slug
82
+ ),
83
+ "status": order.get("status", "NEW"),
84
+ "formatted_amount": _format_amount_for_display(
85
+ order,
86
+ provider_config,
87
+ ),
88
+ "authorize_url": (
89
+ plugin.build_authorize_path(str(order["id"]))
90
+ if plugin is not None
91
+ else None
92
+ ),
93
+ }
94
+ )
95
+
96
+ return Template(
97
+ template_name="dashboard.html",
98
+ context={
99
+ "orders": orders,
100
+ "current_provider": provider,
101
+ "provider_filters": provider_filters,
102
+ "failed_plugins": failed_plugins,
103
+ },
104
+ )
File without changes
@@ -0,0 +1,262 @@
1
+ :root {
2
+ --bg-dark: #0d0d0d;
3
+ --bg-card: #1a1a1a;
4
+ --text-main: #e0e0e0;
5
+ --text-muted: #888888;
6
+
7
+ --amber: #ff9900;
8
+ --amber-dim: rgba(255, 153, 0, 0.2);
9
+
10
+ --neon-green: #00ff88;
11
+ --neon-green-dim: rgba(0, 255, 136, 0.2);
12
+
13
+ --neon-red: #ff3344;
14
+ --neon-red-dim: rgba(255, 51, 68, 0.2);
15
+
16
+ --border-radius: 4px;
17
+ --font-mono: 'Courier New', Courier, monospace, system-ui;
18
+ }
19
+
20
+ * {
21
+ box-sizing: border-box;
22
+ margin: 0;
23
+ padding: 0;
24
+ }
25
+
26
+ body {
27
+ background-color: var(--bg-dark);
28
+ color: var(--text-main);
29
+ font-family: var(--font-mono);
30
+ line-height: 1.6;
31
+ min-height: 100vh;
32
+ display: flex;
33
+ flex-direction: column;
34
+ }
35
+
36
+ /* Layout */
37
+ .container {
38
+ max-width: 800px;
39
+ margin: 0 auto;
40
+ padding: 2rem 1rem;
41
+ width: 100%;
42
+ flex: 1;
43
+ }
44
+
45
+ /* Header & Footer */
46
+ .simulator-header {
47
+ border-bottom: 2px solid var(--amber);
48
+ padding: 1rem;
49
+ text-align: center;
50
+ background-color: var(--bg-card);
51
+ box-shadow: 0 4px 12px var(--amber-dim);
52
+ }
53
+
54
+ .simulator-header h1 {
55
+ color: var(--amber);
56
+ font-size: 1.5rem;
57
+ text-transform: uppercase;
58
+ letter-spacing: 2px;
59
+ margin-bottom: 0.5rem;
60
+ }
61
+
62
+ .simulator-provider {
63
+ font-size: 0.9rem;
64
+ color: var(--text-muted);
65
+ }
66
+
67
+ .simulator-footer {
68
+ text-align: center;
69
+ padding: 1rem;
70
+ background-color: var(--neon-red-dim);
71
+ border-top: 1px solid var(--neon-red);
72
+ color: var(--neon-red);
73
+ font-weight: bold;
74
+ font-size: 0.9rem;
75
+ text-transform: uppercase;
76
+ letter-spacing: 1px;
77
+ }
78
+
79
+ /* Warning Badge */
80
+ .sim-badge {
81
+ display: inline-block;
82
+ background-color: var(--amber);
83
+ color: var(--bg-dark);
84
+ padding: 0.25rem 0.5rem;
85
+ font-weight: bold;
86
+ border-radius: var(--border-radius);
87
+ text-transform: uppercase;
88
+ font-size: 0.8rem;
89
+ }
90
+
91
+ /* Typography elements */
92
+ h2, h3 {
93
+ color: var(--text-main);
94
+ margin-bottom: 1rem;
95
+ font-weight: normal;
96
+ border-bottom: 1px dashed var(--text-muted);
97
+ padding-bottom: 0.5rem;
98
+ }
99
+
100
+ /* Card Component */
101
+ .payment-card {
102
+ background-color: var(--bg-card);
103
+ border: 1px solid var(--text-muted);
104
+ border-left: 4px solid var(--amber);
105
+ border-radius: var(--border-radius);
106
+ padding: 1.5rem;
107
+ margin-bottom: 1.5rem;
108
+ transition: all 0.2s ease-in-out;
109
+ }
110
+
111
+ .payment-card:hover {
112
+ box-shadow: 0 0 15px var(--amber-dim);
113
+ border-color: var(--amber);
114
+ }
115
+
116
+ .payment-card-header {
117
+ display: flex;
118
+ justify-content: space-between;
119
+ align-items: center;
120
+ margin-bottom: 1rem;
121
+ padding-bottom: 0.5rem;
122
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
123
+ }
124
+
125
+ .payment-amount {
126
+ font-size: 1.5rem;
127
+ color: var(--text-main);
128
+ font-weight: bold;
129
+ }
130
+
131
+ .payment-details {
132
+ display: grid;
133
+ grid-template-columns: auto 1fr;
134
+ gap: 0.5rem 1rem;
135
+ font-size: 0.9rem;
136
+ }
137
+
138
+ .detail-label {
139
+ color: var(--text-muted);
140
+ }
141
+
142
+ .detail-value {
143
+ color: var(--text-main);
144
+ }
145
+
146
+ /* Status Badges */
147
+ .status-badge {
148
+ display: inline-block;
149
+ padding: 0.25rem 0.75rem;
150
+ border-radius: 1rem;
151
+ font-size: 0.8rem;
152
+ font-weight: bold;
153
+ text-transform: uppercase;
154
+ letter-spacing: 1px;
155
+ }
156
+
157
+ .status-badge.status-pending {
158
+ background-color: var(--amber-dim);
159
+ color: var(--amber);
160
+ border: 1px solid var(--amber);
161
+ animation: pulse 2s infinite;
162
+ }
163
+
164
+ .status-badge.status-waiting_for_confirmation {
165
+ background-color: var(--amber-dim);
166
+ color: var(--amber);
167
+ border: 1px solid var(--amber);
168
+ }
169
+
170
+ .status-badge.status-completed {
171
+ background-color: var(--neon-green-dim);
172
+ color: var(--neon-green);
173
+ border: 1px solid var(--neon-green);
174
+ box-shadow: 0 0 8px var(--neon-green-dim);
175
+ }
176
+
177
+ .status-badge.status-canceled {
178
+ background-color: var(--neon-red-dim);
179
+ color: var(--neon-red);
180
+ border: 1px solid var(--neon-red);
181
+ }
182
+
183
+ /* Animations */
184
+ @keyframes pulse {
185
+ 0% { opacity: 0.8; box-shadow: 0 0 0 0 rgba(255, 153, 0, 0.4); }
186
+ 50% { opacity: 1; box-shadow: 0 0 0 4px rgba(255, 153, 0, 0); }
187
+ 100% { opacity: 0.8; box-shadow: 0 0 0 0 rgba(255, 153, 0, 0); }
188
+ }
189
+
190
+ /* Buttons */
191
+ .btn {
192
+ display: inline-block;
193
+ padding: 0.75rem 1.5rem;
194
+ border: 1px solid var(--text-muted);
195
+ border-radius: var(--border-radius);
196
+ background-color: transparent;
197
+ color: var(--text-main);
198
+ font-family: inherit;
199
+ font-size: 1rem;
200
+ cursor: pointer;
201
+ text-decoration: none;
202
+ text-transform: uppercase;
203
+ transition: all 0.2s;
204
+ text-align: center;
205
+ }
206
+
207
+ .btn:hover {
208
+ background-color: rgba(255, 255, 255, 0.05);
209
+ }
210
+
211
+ .btn-approve {
212
+ border-color: var(--neon-green);
213
+ color: var(--neon-green);
214
+ }
215
+
216
+ .btn-approve:hover {
217
+ background-color: var(--neon-green-dim);
218
+ box-shadow: 0 0 10px var(--neon-green-dim);
219
+ }
220
+
221
+ .btn-reject {
222
+ border-color: var(--neon-red);
223
+ color: var(--neon-red);
224
+ }
225
+
226
+ .btn-reject:hover {
227
+ background-color: var(--neon-red-dim);
228
+ box-shadow: 0 0 10px var(--neon-red-dim);
229
+ }
230
+
231
+ .btn-neutral {
232
+ border-color: var(--amber);
233
+ color: var(--amber);
234
+ }
235
+
236
+ .btn-neutral:hover {
237
+ background-color: var(--amber-dim);
238
+ box-shadow: 0 0 10px var(--amber-dim);
239
+ }
240
+
241
+ .action-bar {
242
+ display: flex;
243
+ gap: 1rem;
244
+ margin-top: 1.5rem;
245
+ padding-top: 1rem;
246
+ border-top: 1px solid rgba(255, 255, 255, 0.1);
247
+ }
248
+
249
+ /* Responsive */
250
+ @media (max-width: 480px) {
251
+ .simulator-header h1 {
252
+ font-size: 1.2rem;
253
+ }
254
+
255
+ .action-bar {
256
+ flex-direction: column;
257
+ }
258
+
259
+ .btn {
260
+ width: 100%;
261
+ }
262
+ }
File without changes
@@ -0,0 +1,24 @@
1
+ {% extends "base.html" %}
2
+
3
+ {% block content %}
4
+ <div class="authorization-container">
5
+ <div class="header">
6
+ <h1>Authorize Payment</h1>
7
+ <div class="provider-badge">{{ provider }}</div>
8
+ </div>
9
+
10
+ {% include "components/payment_card.html" %}
11
+
12
+ <form method="POST" action="" class="action-buttons">
13
+ <button type="submit" name="action" value="approve" class="btn btn-approve">
14
+ <span class="btn-text">Approve Payment</span>
15
+ <div class="btn-glow"></div>
16
+ </button>
17
+
18
+ <button type="submit" name="action" value="reject" class="btn btn-reject">
19
+ <span class="btn-text">Reject Payment</span>
20
+ <div class="btn-glow"></div>
21
+ </button>
22
+ </form>
23
+ </div>
24
+ {% endblock %}
@@ -0,0 +1,31 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="theme-color" content="#0d0d0d">
7
+ <meta name="color-scheme" content="dark">
8
+ <title>{% block title %}Simulator{% endblock %} | GetPaid Simulator</title>
9
+ <link rel="stylesheet" href="/static/style.css">
10
+ </head>
11
+ <body>
12
+ <header class="simulator-header">
13
+ <h1>⚡ GETPAID SIMULATOR</h1>
14
+ <div class="simulator-provider">
15
+ {% if provider %}
16
+ Simulating Provider: <strong>{{ provider }}</strong>
17
+ {% else %}
18
+ Test Environment
19
+ {% endif %}
20
+ </div>
21
+ </header>
22
+
23
+ <main class="container">
24
+ {% block content %}{% endblock %}
25
+ </main>
26
+
27
+ <footer class="simulator-footer">
28
+ SIMULATOR MODE — NOT A REAL PAYMENT GATEWAY
29
+ </footer>
30
+ </body>
31
+ </html>
@@ -0,0 +1,9 @@
1
+ {% extends "components/payment_card.html" %}
2
+
3
+ {% block card_actions %}
4
+ {% if authorize_url %}
5
+ <a href="{{ authorize_url }}" class="btn btn-neutral">Authorize</a>
6
+ {% else %}
7
+ <span style="color: var(--text-muted);">No UI flow</span>
8
+ {% endif %}
9
+ {% endblock %}
@@ -0,0 +1,17 @@
1
+ <div class="payment-card">
2
+ <div class="payment-card-header">
3
+ <div class="payment-amount">{{ amount }}</div>
4
+ {% include "components/status_badge.html" with context %}
5
+ </div>
6
+ <div class="payment-details">
7
+ <div class="detail-label">Order ID:</div>
8
+ <div class="detail-value">{{ order_id }}</div>
9
+
10
+ <div class="detail-label">Provider:</div>
11
+ <div class="detail-value">{{ provider }}</div>
12
+ </div>
13
+
14
+ <div class="action-bar">
15
+ {% block card_actions %}{% endblock %}
16
+ </div>
17
+ </div>