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 +116 -0
- nylonpay/coerce.py +104 -0
- nylonpay/config.py +51 -0
- nylonpay/factory.py +78 -0
- nylonpay/fingerprint.py +41 -0
- nylonpay/nonce.py +21 -0
- nylonpay/payment.py +281 -0
- nylonpay/phone.py +39 -0
- nylonpay/pubsub.py +114 -0
- nylonpay/sdk.py +499 -0
- nylonpay/signature.py +96 -0
- nylonpay/slang.py +116 -0
- nylonpay/transport.py +449 -0
- nylonpay/types.py +572 -0
- nylonpay/verify_response.py +35 -0
- nylonpay/verify_webhook.py +116 -0
- nylonpay/wire.py +88 -0
- nylonpay_py-0.1.0.dist-info/METADATA +416 -0
- nylonpay_py-0.1.0.dist-info/RECORD +21 -0
- nylonpay_py-0.1.0.dist-info/WHEEL +4 -0
- nylonpay_py-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|
nylonpay/fingerprint.py
ADDED
|
@@ -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
|