nylonpay-py 0.1.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.
nylonpay/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """Nylon Pay SDK for merchant integrations.
2
+
3
+ Server-side SDK for collecting payments, making payouts, verifying phones,
4
+ creating invoices, checking transaction status, and verifying webhooks.
5
+
6
+ Example:
7
+ from nylonpay import create_nylon_pay
8
+
9
+ nylonpay = create_nylon_pay(
10
+ api_key="npk_live_...",
11
+ api_secret="nps_live_...",
12
+ )
13
+
14
+ payment = nylonpay.collect_payment(
15
+ amount=10000,
16
+ currency="UGX",
17
+ customer={"name": "Jane", "phone_number": "+256700000000"},
18
+ description="Order #1234",
19
+ )
20
+ payment.on("success", lambda data: print("Paid:", data.transaction))
21
+ """
22
+
23
+ from .factory import create_nylon_pay
24
+ from .slang import Err, Ok, Result
25
+ from .transport import SdkException, create_sdk_error, parse_error
26
+ from .types import (
27
+ AfterCollectHook,
28
+ AfterHookInput,
29
+ AfterPayoutHook,
30
+ BankDetails,
31
+ BeforeCollectHook,
32
+ BeforePayoutHook,
33
+ CollectPaymentInput,
34
+ CreateInvoiceInput,
35
+ Currency,
36
+ Customer,
37
+ Destination,
38
+ EventData,
39
+ GetStatusInput,
40
+ GetTransactionInput,
41
+ InitiationResult,
42
+ InvoiceItem,
43
+ InvoiceResponse,
44
+ MakePayoutInput,
45
+ NylonPayConfig,
46
+ NylonPaySdk,
47
+ PaymentEvent,
48
+ PaymentEventHandler,
49
+ PaymentInstance,
50
+ PaymentMethod,
51
+ PhoneVerification,
52
+ SdkError,
53
+ SdkErrorCategory,
54
+ SdkHook,
55
+ SdkHooks,
56
+ StatusResponse,
57
+ Transaction,
58
+ TransactionMode,
59
+ TransactionStatus,
60
+ TransactionType,
61
+ VerifyPhoneInput,
62
+ VerifyWebhookInput,
63
+ WebhookEventType,
64
+ WebhookPayload,
65
+ )
66
+ from .verify_webhook import verify_webhook_signature
67
+
68
+ __all__ = [
69
+ # Types
70
+ "AfterCollectHook",
71
+ "AfterHookInput",
72
+ "AfterPayoutHook",
73
+ "BankDetails",
74
+ "BeforeCollectHook",
75
+ "BeforePayoutHook",
76
+ "CollectPaymentInput",
77
+ "CreateInvoiceInput",
78
+ "Currency",
79
+ "Customer",
80
+ "Destination",
81
+ "Err",
82
+ "EventData",
83
+ "GetStatusInput",
84
+ "GetTransactionInput",
85
+ "InitiationResult",
86
+ "InvoiceItem",
87
+ "InvoiceResponse",
88
+ "MakePayoutInput",
89
+ "NylonPayConfig",
90
+ "NylonPaySdk",
91
+ "Ok",
92
+ "PaymentEvent",
93
+ "PaymentEventHandler",
94
+ "PaymentInstance",
95
+ "PaymentMethod",
96
+ "PhoneVerification",
97
+ "Result",
98
+ "SdkError",
99
+ "SdkErrorCategory",
100
+ "SdkException",
101
+ "SdkHook",
102
+ "SdkHooks",
103
+ "StatusResponse",
104
+ "Transaction",
105
+ "TransactionMode",
106
+ "TransactionStatus",
107
+ "TransactionType",
108
+ "VerifyPhoneInput",
109
+ "VerifyWebhookInput",
110
+ "WebhookEventType",
111
+ "WebhookPayload",
112
+ "create_nylon_pay",
113
+ "create_sdk_error",
114
+ "parse_error",
115
+ "verify_webhook_signature",
116
+ ]
nylonpay/coerce.py ADDED
@@ -0,0 +1,104 @@
1
+ """Coercion helpers for accepting dicts at SDK boundaries.
2
+
3
+ Lets merchants pass plain dicts instead of importing and constructing
4
+ dataclasses for every nested type (Customer, Destination, BankDetails,
5
+ InvoiceItem). The coercion runs at the operation boundary so the rest
6
+ of the SDK always works with typed dataclass instances.
7
+
8
+ Example::
9
+
10
+ # Instead of this:
11
+ from nylonpay import Customer, CollectPaymentInput
12
+ nylonpay.collect_payment(CollectPaymentInput(
13
+ amount=5000, currency="UGX",
14
+ customer=Customer(name="John", phone_number="256700000000"),
15
+ description="Order",
16
+ ))
17
+
18
+ # Merchants can do this:
19
+ nylonpay.collect_payment(
20
+ amount=5000, currency="UGX",
21
+ customer={"name": "John", "phone_number": "256700000000"},
22
+ description="Order",
23
+ )
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import dataclasses
29
+ import types
30
+ from typing import Any, get_args, get_origin, get_type_hints
31
+
32
+ _MAX_DEPTH = 32
33
+
34
+
35
+ class _DepthExceededError(Exception):
36
+ """Raised when dict nesting exceeds the safety cap during coercion."""
37
+
38
+
39
+ def coerce_dataclass(cls: type, data: Any, depth: int = 0) -> Any:
40
+ """Construct a dataclass from a dict, coercing nested fields recursively.
41
+
42
+ If *data* is already a dataclass instance, returns it unchanged.
43
+ If *data* is a dict, constructs *cls* from it, coercing any nested
44
+ dict fields to their declared dataclass types.
45
+ Otherwise, returns *data* unchanged.
46
+
47
+ A depth cap prevents stack overflow on pathologically nested input.
48
+ """
49
+ if dataclasses.is_dataclass(data) and not isinstance(data, type):
50
+ return data
51
+ if not isinstance(data, dict):
52
+ return data
53
+
54
+ if depth > _MAX_DEPTH:
55
+ raise _DepthExceededError(_MAX_DEPTH)
56
+
57
+ hints = get_type_hints(cls)
58
+ kwargs: dict[str, Any] = {}
59
+
60
+ for key, value in data.items():
61
+ field_type = hints.get(key)
62
+ kwargs[key] = _coerce_field(field_type, value, depth + 1)
63
+
64
+ return cls(**kwargs)
65
+
66
+
67
+ def _coerce_field(field_type: Any, value: Any, depth: int = 0) -> Any:
68
+ """Coerce a value based on its declared field type."""
69
+ if value is None or field_type is None:
70
+ return value
71
+
72
+ if isinstance(field_type, types.UnionType):
73
+ for arg in get_args(field_type):
74
+ if arg is type(None):
75
+ continue
76
+ if dataclasses.is_dataclass(arg) and isinstance(value, dict):
77
+ return coerce_dataclass(arg, value, depth)
78
+ if get_origin(arg) is list:
79
+ return _coerce_list(arg, value, depth)
80
+ return value
81
+
82
+ if get_origin(field_type) is list:
83
+ return _coerce_list(field_type, value, depth)
84
+
85
+ if dataclasses.is_dataclass(field_type) and isinstance(value, dict):
86
+ return coerce_dataclass(field_type, value, depth)
87
+
88
+ return value
89
+
90
+
91
+ def _coerce_list(list_type: Any, value: Any, depth: int = 0) -> Any:
92
+ """Coerce a list of dicts to a list of dataclass instances."""
93
+ if not isinstance(value, list):
94
+ return value
95
+
96
+ args = get_args(list_type)
97
+ element_type = args[0] if args else None
98
+ if element_type is None or not dataclasses.is_dataclass(element_type):
99
+ return value
100
+
101
+ return [
102
+ coerce_dataclass(element_type, item, depth) if isinstance(item, dict) else item
103
+ for item in value
104
+ ]
nylonpay/config.py ADDED
@@ -0,0 +1,51 @@
1
+ """Constants and defaults for the Nylon Pay SDK.
2
+
3
+ These values define the SDK's operational envelope — timeouts, retry limits,
4
+ polling cadence, and validation thresholds. They're derived from the SDK
5
+ spec (v1.3.0) which sets these as cross-language invariants: every SDK
6
+ implementation (TypeScript, Python, future languages) must enforce the same
7
+ minimums and defaults so backend behavior is predictable regardless of
8
+ which language the merchant integrates with.
9
+
10
+ Defaults are overridable via ``NylonPayConfig`` — merchants with slow
11
+ networks or long-running payment flows can widen timeouts and poll limits
12
+ without touching the spec-mandated validation thresholds (min amounts,
13
+ reference length).
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ # --- Defaults (overridable via NylonPayConfig) ---
19
+
20
+ DEFAULT_BASE_URL = "https://api.nylonpay.nilesquad.com/api/services"
21
+ DEFAULT_TIMEOUT_MS = 30_000
22
+ DEFAULT_MAX_RETRIES = 3
23
+ DEFAULT_MAX_POLL_INTERVAL_MS = 2_000
24
+ DEFAULT_MAX_POLL_DURATION_MS = 300_000
25
+ DEFAULT_MAX_POLL_ATTEMPTS = 150
26
+
27
+ POLL_JITTER_MS = 250
28
+
29
+ # --- Internal constants ---
30
+
31
+ SDK_SERVICE = "sdk"
32
+
33
+ SDK_ACTIONS = {
34
+ "collect_payment": "sdk-collect-payment",
35
+ "collect_payment_and_resolve": "sdk-collect-payment-and-resolve",
36
+ "make_payout": "sdk-make-payout",
37
+ "make_payout_and_resolve": "sdk-make-payout-and-resolve",
38
+ "get_status": "sdk-get-status",
39
+ "get_transaction": "sdk-get-transaction",
40
+ "verify_phone": "sdk-verify-phone",
41
+ "create_invoice": "sdk-create-invoice",
42
+ }
43
+
44
+ RETRYABLE_STATUS_CODES = frozenset({408, 429, 500, 502, 503, 504})
45
+
46
+ # --- Validation constants ---
47
+
48
+ MIN_COLLECTION_AMOUNT = 500
49
+ MIN_DISBURSEMENT_AMOUNT = 5000
50
+ MIN_REFERENCE_LENGTH = 13
51
+ MAX_REFERENCE_LENGTH = 15
nylonpay/factory.py ADDED
@@ -0,0 +1,78 @@
1
+ """Factory function to create a Nylon Pay SDK instance.
2
+
3
+ Main entry point for merchants. Singleton-caches instances by
4
+ ``api_key + base_url + sha256(api_secret)`` so the same credentials
5
+ return the same instance — rotating the secret yields a fresh one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import threading
12
+ from typing import Any
13
+
14
+ from .config import (
15
+ DEFAULT_BASE_URL,
16
+ DEFAULT_MAX_POLL_ATTEMPTS,
17
+ DEFAULT_MAX_POLL_DURATION_MS,
18
+ DEFAULT_MAX_POLL_INTERVAL_MS,
19
+ DEFAULT_MAX_RETRIES,
20
+ DEFAULT_TIMEOUT_MS,
21
+ )
22
+ from .sdk import create_sdk_instance
23
+ from .types import NylonPayConfig, NylonPaySdk
24
+
25
+ _instances: dict[str, NylonPaySdk] = {}
26
+ _instances_lock = threading.Lock()
27
+
28
+
29
+ def create_nylon_pay(**kwargs: Any) -> NylonPaySdk:
30
+ """Create a Nylon Pay SDK instance.
31
+
32
+ Accepts keyword arguments matching :class:`NylonPayConfig` fields.
33
+ Returns the same instance for the same ``api_key`` + ``api_secret`` +
34
+ ``base_url`` combination unless ``force=True`` is passed. Rotating the
35
+ secret yields a fresh instance (secret-aware cache key).
36
+
37
+ Raises ``ValueError`` if ``api_key`` is missing or doesn't start with
38
+ ``npk_``, or ``api_secret`` is missing or doesn't start with ``nps_``.
39
+ """
40
+ config = NylonPayConfig(**kwargs)
41
+ if not config.api_key:
42
+ raise ValueError("api_key is required")
43
+ if not config.api_key.startswith("npk_"):
44
+ raise ValueError('api_key must start with "npk_"')
45
+ if not config.api_secret:
46
+ raise ValueError("api_secret is required")
47
+ if not config.api_secret.startswith("nps_"):
48
+ raise ValueError('api_secret must start with "nps_"')
49
+
50
+ base_url = config.base_url or DEFAULT_BASE_URL
51
+ # Hash the secret so it never sits raw in a dict key; rotating the
52
+ # secret produces a different cache key → fresh instance.
53
+ secret_hash = hashlib.sha256(config.api_secret.encode()).hexdigest()[:16]
54
+ instance_key = f"{config.api_key}:{base_url}:{secret_hash}"
55
+
56
+ if not config.force:
57
+ with _instances_lock:
58
+ existing = _instances.get(instance_key)
59
+ if existing is not None:
60
+ return existing
61
+
62
+ resolved: dict[str, Any] = {
63
+ "api_key": config.api_key,
64
+ "api_secret": config.api_secret,
65
+ "base_url": base_url,
66
+ "timeout_ms": config.timeout_ms or DEFAULT_TIMEOUT_MS,
67
+ "max_retries": config.max_retries or DEFAULT_MAX_RETRIES,
68
+ "max_poll_interval_ms": config.max_poll_interval_ms or DEFAULT_MAX_POLL_INTERVAL_MS,
69
+ "max_poll_duration_ms": config.max_poll_duration_ms or DEFAULT_MAX_POLL_DURATION_MS,
70
+ "max_poll_attempts": config.max_poll_attempts or DEFAULT_MAX_POLL_ATTEMPTS,
71
+ "http_client": config.http_client,
72
+ "hooks": config.hooks,
73
+ }
74
+
75
+ instance = create_sdk_instance(resolved)
76
+ with _instances_lock:
77
+ _instances[instance_key] = instance
78
+ return instance
@@ -0,0 +1,41 @@
1
+ """Stable server fingerprint based on the runtime environment.
2
+
3
+ WHY fingerprinting: the backend binds each signed request to the
4
+ originating server so that a leaked signature cannot be replayed from
5
+ a different machine. The fingerprint must be stable within a process
6
+ but differ across hosts, OS versions, and Python runtimes.
7
+
8
+ The components (OS type, platform, arch, release, hostname, runtime
9
+ versions) are derived from Python's ``platform`` and ``sys`` stdlib
10
+ modules. The same conceptual inputs are used across all SDK
11
+ implementations so a merchant running both TS and Python SDKs on the
12
+ same server produces comparable fingerprints.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import platform
19
+ import socket
20
+ from functools import lru_cache
21
+
22
+
23
+ @lru_cache(maxsize=1)
24
+ def generate_fingerprint() -> str:
25
+ """Derive a SHA-256 hex digest from OS and runtime metadata.
26
+
27
+ Cached after first call — the environment cannot change within a
28
+ running process, so recomputing is wasteful.
29
+ """
30
+ components = "|".join(
31
+ [
32
+ f"type:{platform.system()}",
33
+ f"platform:{platform.platform()}",
34
+ f"arch:{platform.machine()}",
35
+ f"release:{platform.release()}",
36
+ f"hostname:{socket.gethostname()}",
37
+ f"python:{platform.python_version()}",
38
+ f"implementation:{platform.python_implementation()}",
39
+ ]
40
+ )
41
+ return hashlib.sha256(components.encode()).hexdigest()
nylonpay/nonce.py ADDED
@@ -0,0 +1,21 @@
1
+ """Cryptographic nonce generation for SDK request deduplication.
2
+
3
+ WHY a separate module: the nonce is a core security primitive used in
4
+ every signed request. Isolating it keeps the generation strategy
5
+ (swappable for testing) and the byte-length → hex-length relationship
6
+ in one place.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import secrets
12
+
13
+
14
+ def generate_nonce(length: int = 16) -> str:
15
+ """Produce a cryptographically secure random hex string.
16
+
17
+ The default 16-byte (32 hex char) nonce provides sufficient entropy
18
+ for replay-attack prevention within the backend's freshness window.
19
+ Uses ``secrets.token_hex`` which delegates to the OS CSPRNG.
20
+ """
21
+ return secrets.token_hex(length)
nylonpay/payment.py ADDED
@@ -0,0 +1,281 @@
1
+ """Payment instance with event emission for transaction lifecycle.
2
+
3
+ Creates an event-driven payment instance that polls for status updates,
4
+ emits lifecycle events, and supports blocking until terminal state.
5
+
6
+ Fully synchronous — no asyncio. Events fire as callbacks during ``wait()``,
7
+ which runs a polling loop with ``time.sleep`` between polls.
8
+
9
+ Factory pattern — :func:`create_payment_instance` returns a plain object
10
+ (``SimpleNamespace``) with closure-based methods. No classes.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import random
16
+ import time
17
+ from datetime import datetime, timezone
18
+ from types import SimpleNamespace
19
+ from typing import Any, cast
20
+
21
+ from .config import (
22
+ DEFAULT_MAX_POLL_ATTEMPTS,
23
+ DEFAULT_MAX_POLL_DURATION_MS,
24
+ DEFAULT_MAX_POLL_INTERVAL_MS,
25
+ POLL_JITTER_MS,
26
+ )
27
+ from .pubsub import create_emitter
28
+ from .transport import parse_error
29
+ from .types import (
30
+ EventData,
31
+ GetStatusInput,
32
+ GetTransactionInput,
33
+ PaymentEvent,
34
+ PaymentEventHandler,
35
+ PaymentInstance,
36
+ SdkError,
37
+ SdkErrorCategory,
38
+ StatusResponse,
39
+ Transaction,
40
+ TransactionStatus,
41
+ )
42
+
43
+ # --- Status → Event mapping ---
44
+
45
+ STATUS_TO_EVENT: dict[str, PaymentEvent] = {
46
+ "pending": "processing",
47
+ "processing": "processing",
48
+ "successful": "success",
49
+ "failed": "failed",
50
+ "cancelled": "cancelled",
51
+ }
52
+
53
+ TERMINAL_STATES: frozenset[str] = frozenset({"successful", "failed", "cancelled"})
54
+
55
+
56
+ def _status_to_event(status: str) -> PaymentEvent | None:
57
+ """Map a transaction status to its corresponding payment event."""
58
+ return STATUS_TO_EVENT.get(status)
59
+
60
+
61
+ def _normalize_status(raw: str) -> TransactionStatus:
62
+ """Normalise raw backend status strings. ``'completed'`` → ``'successful'``."""
63
+ if raw == "completed":
64
+ return "successful"
65
+ return raw # ty: ignore[invalid-return-type]
66
+
67
+
68
+ def create_payment_instance(
69
+ initial_response: dict[str, Any],
70
+ deps: dict[str, Any],
71
+ ) -> PaymentInstance:
72
+ """Create a payment instance with polling and event emission.
73
+
74
+ ``initial_response`` carries ``reference`` and ``status`` from the
75
+ initiation call. ``deps`` injects ``fetch_status``, ``fetch_transaction``,
76
+ optional poll config, and an optional ``initial_error`` for backend
77
+ rejections that surface as events rather than exceptions.
78
+
79
+ Events fire during ``wait()`` — the polling loop runs inside it and
80
+ calls registered callbacks as the status changes. If ``wait()`` is
81
+ never called, no polling occurs.
82
+
83
+ Returns a ``SimpleNamespace`` with ``reference``, ``status``,
84
+ ``on``, ``once``, ``off``, ``wait`` — satisfies ``PaymentInstance``
85
+ protocol without a class.
86
+ """
87
+ emitter = create_emitter()
88
+
89
+ state: dict[str, Any] = {
90
+ "reference": initial_response["reference"],
91
+ "status": _normalize_status(initial_response["status"]),
92
+ "transaction": None,
93
+ "last_status_event": None,
94
+ "resolved": False,
95
+ "poll_attempts": 0,
96
+ "poll_start_time": time.time() * 1000,
97
+ "emitter": emitter,
98
+ "fetch_status": deps["fetch_status"],
99
+ "fetch_transaction": deps["fetch_transaction"],
100
+ "poll_interval_ms": deps.get("poll_interval_ms", DEFAULT_MAX_POLL_INTERVAL_MS),
101
+ "max_poll_duration": deps.get("max_poll_duration", DEFAULT_MAX_POLL_DURATION_MS),
102
+ "max_poll_attempts": deps.get("max_poll_attempts", DEFAULT_MAX_POLL_ATTEMPTS),
103
+ "pending_error": None,
104
+ }
105
+
106
+ def set_status(new_status: TransactionStatus) -> None:
107
+ """Update status in both state dict and on the public object."""
108
+ state["status"] = new_status
109
+ payment_instance.status = new_status
110
+
111
+ def emit_event(
112
+ event: PaymentEvent,
113
+ error: str | None = None,
114
+ category: SdkErrorCategory | None = None,
115
+ retryable: bool | None = None,
116
+ ) -> None:
117
+ """Emit a lifecycle event with current transaction data."""
118
+ data = EventData(
119
+ event=event,
120
+ reference=state["reference"],
121
+ timestamp=datetime.now(timezone.utc).isoformat(),
122
+ transaction=state["transaction"],
123
+ error=error,
124
+ category=category,
125
+ retryable=retryable,
126
+ )
127
+ emitter["emit"](event, data)
128
+
129
+ def handle_terminal_state(status: TransactionStatus) -> None:
130
+ """Fetch full transaction record and emit terminal event."""
131
+ tx_result = state["fetch_transaction"](GetTransactionInput(reference=state["reference"]))
132
+ if tx_result.is_ok:
133
+ state["transaction"] = tx_result.value
134
+ event = _status_to_event(status)
135
+ if event is not None:
136
+ error_msg = None
137
+ if status == "failed" and state["transaction"] is not None:
138
+ error_msg = state["transaction"].failure_reason
139
+ emit_event(event, error=error_msg)
140
+ else:
141
+ emit_event("error", error="Could not retrieve the transaction details")
142
+ state["resolved"] = True
143
+
144
+ def handle_status_update(response: StatusResponse) -> None:
145
+ """Process a status response from polling."""
146
+ if state["resolved"]:
147
+ return
148
+
149
+ if response.reference != state["reference"]:
150
+ emit_event(
151
+ "error",
152
+ error="Received a status update for a different transaction",
153
+ category="internal",
154
+ )
155
+ state["resolved"] = True
156
+ return
157
+
158
+ new_status = _normalize_status(response.status)
159
+ set_status(new_status)
160
+
161
+ # Dedupe by event, not raw status
162
+ event = _status_to_event(new_status)
163
+ if event is None or event == state["last_status_event"]:
164
+ return
165
+ state["last_status_event"] = event
166
+
167
+ if new_status in TERMINAL_STATES:
168
+ handle_terminal_state(new_status)
169
+ return
170
+
171
+ emit_event(event)
172
+
173
+ def poll_once() -> None:
174
+ """Execute one polling cycle."""
175
+ if state["resolved"]:
176
+ return
177
+
178
+ if state["poll_attempts"] >= state["max_poll_attempts"]:
179
+ emit_event(
180
+ "error",
181
+ error="Timed out waiting for the transaction status to update",
182
+ category="timeout",
183
+ )
184
+ state["resolved"] = True
185
+ return
186
+
187
+ current_ms = time.time() * 1000
188
+ if current_ms - state["poll_start_time"] >= state["max_poll_duration"]:
189
+ emit_event(
190
+ "error",
191
+ error="Timed out waiting for the transaction status to update",
192
+ category="timeout",
193
+ )
194
+ state["resolved"] = True
195
+ return
196
+
197
+ state["poll_attempts"] += 1
198
+
199
+ result = state["fetch_status"](GetStatusInput(reference=state["reference"]))
200
+
201
+ if result.is_ok:
202
+ handle_status_update(result.value)
203
+ else:
204
+ parsed = parse_error(result.error)
205
+ if parsed.category == "not_found":
206
+ return
207
+ emit_event("error", parsed.message, parsed.category, parsed.retryable)
208
+ state["resolved"] = True
209
+
210
+ def wait_fn() -> Transaction | None:
211
+ """Block until terminal state.
212
+
213
+ Emits any pending initial error, then polls at jittered intervals
214
+ until the transaction reaches a terminal state. Event callbacks
215
+ fire as the status changes during this loop.
216
+ """
217
+ # Handle pending initial error (backend rejection at initiation)
218
+ if state["pending_error"] is not None:
219
+ err: SdkError = state["pending_error"]
220
+ state["pending_error"] = None
221
+ emit_event("error", err.message, err.category, err.retryable)
222
+ return None
223
+
224
+ if state["resolved"]:
225
+ return state["transaction"] if state["status"] == "successful" else None
226
+
227
+ # Emit initial event if status maps to one
228
+ initial_event = _status_to_event(state["status"])
229
+ if initial_event is not None and initial_event != state["last_status_event"]:
230
+ state["last_status_event"] = initial_event
231
+ emit_event(initial_event)
232
+
233
+ # If already in terminal state at creation, handle it
234
+ if state["status"] in TERMINAL_STATES:
235
+ handle_terminal_state(state["status"])
236
+ else:
237
+ # Poll until resolved
238
+ while not state["resolved"]:
239
+ poll_once()
240
+ if state["resolved"]:
241
+ break
242
+ delay = (state["poll_interval_ms"] + random.random() * POLL_JITTER_MS) / 1000
243
+ time.sleep(delay)
244
+
245
+ return state["transaction"] if state["status"] == "successful" else None
246
+
247
+ # --- Public API closures ---
248
+
249
+ def on_fn(event: PaymentEvent, handler: PaymentEventHandler) -> PaymentInstance:
250
+ emitter["on"](event, handler)
251
+ return payment_instance
252
+
253
+ def once_fn(event: PaymentEvent, handler: PaymentEventHandler) -> PaymentInstance:
254
+ emitter["once"](event, handler)
255
+ return payment_instance
256
+
257
+ def off_fn(event: PaymentEvent, handler: PaymentEventHandler) -> PaymentInstance:
258
+ emitter["off"](event, handler)
259
+ return payment_instance
260
+
261
+ # --- Build the public object (no class, just closures on SimpleNamespace) ---
262
+
263
+ payment_instance = cast(
264
+ "PaymentInstance",
265
+ SimpleNamespace(
266
+ reference=state["reference"],
267
+ status=state["status"],
268
+ on=on_fn,
269
+ once=once_fn,
270
+ off=off_fn,
271
+ wait=wait_fn,
272
+ ),
273
+ )
274
+
275
+ # Store initial error for wait() to emit (handlers aren't registered yet)
276
+ initial_error: SdkError | None = deps.get("initial_error")
277
+ if initial_error is not None:
278
+ state["resolved"] = True
279
+ state["pending_error"] = initial_error
280
+
281
+ return payment_instance