pay-engine 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.
- pay_engine/__init__.py +168 -0
- pay_engine/core/__init__.py +35 -0
- pay_engine/core/config.py +74 -0
- pay_engine/core/exceptions.py +30 -0
- pay_engine/core/models.py +51 -0
- pay_engine/integrations/__init__.py +14 -0
- pay_engine/integrations/fastapi.py +137 -0
- pay_engine/integrations/telegram.py +107 -0
- pay_engine/providers/__init__.py +16 -0
- pay_engine/providers/base.py +40 -0
- pay_engine/providers/click/__init__.py +6 -0
- pay_engine/providers/click/client.py +137 -0
- pay_engine/providers/click/handler.py +226 -0
- pay_engine/providers/payme/__init__.py +6 -0
- pay_engine/providers/payme/client.py +160 -0
- pay_engine/providers/payme/handler.py +255 -0
- pay_engine/providers/uzum/__init__.py +6 -0
- pay_engine/providers/uzum/client.py +61 -0
- pay_engine/providers/uzum/handler.py +82 -0
- pay_engine-0.1.0.dist-info/METADATA +186 -0
- pay_engine-0.1.0.dist-info/RECORD +23 -0
- pay_engine-0.1.0.dist-info/WHEEL +4 -0
- pay_engine-0.1.0.dist-info/licenses/LICENSE +21 -0
pay_engine/__init__.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pay-engine: All-in-one payment engine for Uzbekistan.
|
|
3
|
+
Supports Payme, Click, and Uzum Bank with one-liners, Telegram buttons, and auto-charge.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Any, Dict, List, Optional, Union
|
|
7
|
+
|
|
8
|
+
from .core.config import ClickConfig, PaymeConfig, UzumConfig
|
|
9
|
+
from .core.exceptions import (
|
|
10
|
+
CardTokenError,
|
|
11
|
+
ConfigurationError,
|
|
12
|
+
InvalidAmountError,
|
|
13
|
+
OrderNotFoundError,
|
|
14
|
+
PayEngineError,
|
|
15
|
+
SignatureVerificationError,
|
|
16
|
+
)
|
|
17
|
+
from .core.models import (
|
|
18
|
+
CardChargeRequest,
|
|
19
|
+
CardChargeResult,
|
|
20
|
+
PaymentRequest,
|
|
21
|
+
TransactionStatus,
|
|
22
|
+
WebhookResult,
|
|
23
|
+
)
|
|
24
|
+
from .providers.click.client import ClickClient
|
|
25
|
+
from .providers.click.handler import ClickHandler
|
|
26
|
+
from .providers.payme.client import PaymeClient
|
|
27
|
+
from .providers.payme.handler import PaymeHandler
|
|
28
|
+
from .providers.uzum.client import UzumClient
|
|
29
|
+
from .providers.uzum.handler import UzumHandler
|
|
30
|
+
|
|
31
|
+
__version__ = "0.1.0"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _PaymeProxy:
|
|
35
|
+
"""Convenience helper for Payme one-liners."""
|
|
36
|
+
|
|
37
|
+
@staticmethod
|
|
38
|
+
def link(
|
|
39
|
+
amount: float,
|
|
40
|
+
order_id: Union[str, int],
|
|
41
|
+
return_url: Optional[str] = None,
|
|
42
|
+
merchant_id: Optional[str] = None,
|
|
43
|
+
test_mode: Optional[bool] = None,
|
|
44
|
+
**kwargs: Any,
|
|
45
|
+
) -> str:
|
|
46
|
+
client = PaymeClient(merchant_id=merchant_id, test_mode=test_mode)
|
|
47
|
+
return client.create_link(amount=amount, order_id=order_id, return_url=return_url, **kwargs)
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def charge(
|
|
51
|
+
card_token: str,
|
|
52
|
+
amount: float,
|
|
53
|
+
order_id: Union[str, int],
|
|
54
|
+
secret_key: Optional[str] = None,
|
|
55
|
+
description: Optional[str] = None,
|
|
56
|
+
**kwargs: Any,
|
|
57
|
+
) -> CardChargeResult:
|
|
58
|
+
client = PaymeClient(secret_key=secret_key)
|
|
59
|
+
return client.charge_card(card_token=card_token, amount=amount, order_id=order_id, description=description, **kwargs)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _ClickProxy:
|
|
63
|
+
"""Convenience helper for Click one-liners."""
|
|
64
|
+
|
|
65
|
+
@staticmethod
|
|
66
|
+
def link(
|
|
67
|
+
amount: float,
|
|
68
|
+
order_id: Union[str, int],
|
|
69
|
+
return_url: Optional[str] = None,
|
|
70
|
+
service_id: Optional[str] = None,
|
|
71
|
+
merchant_id: Optional[str] = None,
|
|
72
|
+
**kwargs: Any,
|
|
73
|
+
) -> str:
|
|
74
|
+
client = ClickClient(service_id=service_id, merchant_id=merchant_id)
|
|
75
|
+
return client.create_link(amount=amount, order_id=order_id, return_url=return_url, **kwargs)
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def charge(
|
|
79
|
+
card_token: str,
|
|
80
|
+
amount: float,
|
|
81
|
+
order_id: Union[str, int],
|
|
82
|
+
service_id: Optional[str] = None,
|
|
83
|
+
secret_key: Optional[str] = None,
|
|
84
|
+
merchant_user_id: Optional[str] = None,
|
|
85
|
+
**kwargs: Any,
|
|
86
|
+
) -> CardChargeResult:
|
|
87
|
+
client = ClickClient(service_id=service_id, secret_key=secret_key, merchant_user_id=merchant_user_id)
|
|
88
|
+
return client.charge_card(card_token=card_token, amount=amount, order_id=order_id, **kwargs)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class _UzumProxy:
|
|
92
|
+
"""Convenience helper for Uzum Bank one-liners."""
|
|
93
|
+
|
|
94
|
+
@staticmethod
|
|
95
|
+
def link(
|
|
96
|
+
amount: float,
|
|
97
|
+
order_id: Union[str, int],
|
|
98
|
+
return_url: Optional[str] = None,
|
|
99
|
+
shop_id: Optional[str] = None,
|
|
100
|
+
**kwargs: Any,
|
|
101
|
+
) -> str:
|
|
102
|
+
client = UzumClient(shop_id=shop_id)
|
|
103
|
+
return client.create_link(amount=amount, order_id=order_id, return_url=return_url, **kwargs)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class PayEngine:
|
|
107
|
+
"""Unified master engine for payments."""
|
|
108
|
+
|
|
109
|
+
def __init__(
|
|
110
|
+
self,
|
|
111
|
+
payme_merchant_id: Optional[str] = None,
|
|
112
|
+
payme_secret_key: Optional[str] = None,
|
|
113
|
+
click_service_id: Optional[str] = None,
|
|
114
|
+
click_merchant_id: Optional[str] = None,
|
|
115
|
+
click_secret_key: Optional[str] = None,
|
|
116
|
+
uzum_shop_id: Optional[str] = None,
|
|
117
|
+
uzum_secret_key: Optional[str] = None,
|
|
118
|
+
test_mode: bool = False,
|
|
119
|
+
):
|
|
120
|
+
self.payme = PaymeClient(merchant_id=payme_merchant_id, secret_key=payme_secret_key, test_mode=test_mode) if (payme_merchant_id or PaymeConfig().merchant_id) else None
|
|
121
|
+
self.click = ClickClient(service_id=click_service_id, merchant_id=click_merchant_id, secret_key=click_secret_key) if (click_service_id or ClickConfig().service_id) else None
|
|
122
|
+
self.uzum = UzumClient(shop_id=uzum_shop_id, secret_key=uzum_secret_key) if (uzum_shop_id or UzumConfig().shop_id) else None
|
|
123
|
+
|
|
124
|
+
def get_links(
|
|
125
|
+
self,
|
|
126
|
+
amount: float,
|
|
127
|
+
order_id: Union[str, int],
|
|
128
|
+
return_url: Optional[str] = None,
|
|
129
|
+
) -> Dict[str, str]:
|
|
130
|
+
"""Returns checkout URLs for all configured providers."""
|
|
131
|
+
links = {}
|
|
132
|
+
if self.payme:
|
|
133
|
+
links["payme"] = self.payme.create_link(amount=amount, order_id=order_id, return_url=return_url)
|
|
134
|
+
if self.click:
|
|
135
|
+
links["click"] = self.click.create_link(amount=amount, order_id=order_id, return_url=return_url)
|
|
136
|
+
if self.uzum:
|
|
137
|
+
links["uzum"] = self.uzum.create_link(amount=amount, order_id=order_id, return_url=return_url)
|
|
138
|
+
return links
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# Public helper instances
|
|
142
|
+
payme = _PaymeProxy()
|
|
143
|
+
click = _ClickProxy()
|
|
144
|
+
uzum = _UzumProxy()
|
|
145
|
+
|
|
146
|
+
__all__ = [
|
|
147
|
+
"PayEngine",
|
|
148
|
+
"payme",
|
|
149
|
+
"click",
|
|
150
|
+
"uzum",
|
|
151
|
+
"PaymeClient",
|
|
152
|
+
"PaymeHandler",
|
|
153
|
+
"ClickClient",
|
|
154
|
+
"ClickHandler",
|
|
155
|
+
"UzumClient",
|
|
156
|
+
"UzumHandler",
|
|
157
|
+
"PayEngineError",
|
|
158
|
+
"ConfigurationError",
|
|
159
|
+
"SignatureVerificationError",
|
|
160
|
+
"InvalidAmountError",
|
|
161
|
+
"OrderNotFoundError",
|
|
162
|
+
"CardTokenError",
|
|
163
|
+
"TransactionStatus",
|
|
164
|
+
"PaymentRequest",
|
|
165
|
+
"WebhookResult",
|
|
166
|
+
"CardChargeRequest",
|
|
167
|
+
"CardChargeResult",
|
|
168
|
+
]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Core module for pay-engine."""
|
|
2
|
+
|
|
3
|
+
from .config import ClickConfig, PaymeConfig, UzumConfig
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
CardTokenError,
|
|
6
|
+
ConfigurationError,
|
|
7
|
+
InvalidAmountError,
|
|
8
|
+
OrderNotFoundError,
|
|
9
|
+
PayEngineError,
|
|
10
|
+
SignatureVerificationError,
|
|
11
|
+
)
|
|
12
|
+
from .models import (
|
|
13
|
+
CardChargeRequest,
|
|
14
|
+
CardChargeResult,
|
|
15
|
+
PaymentRequest,
|
|
16
|
+
TransactionStatus,
|
|
17
|
+
WebhookResult,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PayEngineError",
|
|
22
|
+
"ConfigurationError",
|
|
23
|
+
"SignatureVerificationError",
|
|
24
|
+
"InvalidAmountError",
|
|
25
|
+
"OrderNotFoundError",
|
|
26
|
+
"CardTokenError",
|
|
27
|
+
"TransactionStatus",
|
|
28
|
+
"PaymentRequest",
|
|
29
|
+
"WebhookResult",
|
|
30
|
+
"CardChargeRequest",
|
|
31
|
+
"CardChargeResult",
|
|
32
|
+
"PaymeConfig",
|
|
33
|
+
"ClickConfig",
|
|
34
|
+
"UzumConfig",
|
|
35
|
+
]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Configuration management for pay-engine."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _load_env_file():
|
|
9
|
+
"""Simple parser to load .env file if it exists, without needing python-dotenv."""
|
|
10
|
+
env_path = os.path.join(os.getcwd(), ".env")
|
|
11
|
+
if os.path.isfile(env_path):
|
|
12
|
+
try:
|
|
13
|
+
with open(env_path, "r", encoding="utf-8") as f:
|
|
14
|
+
for line in f:
|
|
15
|
+
line = line.strip()
|
|
16
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
17
|
+
continue
|
|
18
|
+
key, val = line.split("=", 1)
|
|
19
|
+
key = key.strip()
|
|
20
|
+
val = val.strip().strip("'\"")
|
|
21
|
+
if key not in os.environ:
|
|
22
|
+
os.environ[key] = val
|
|
23
|
+
except Exception:
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# Automatically attempt to load .env on module import
|
|
28
|
+
_load_env_file()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class PaymeConfig:
|
|
33
|
+
merchant_id: Optional[str] = None
|
|
34
|
+
secret_key: Optional[str] = None
|
|
35
|
+
test_mode: bool = False
|
|
36
|
+
|
|
37
|
+
def __post_init__(self):
|
|
38
|
+
if not self.merchant_id:
|
|
39
|
+
self.merchant_id = os.getenv("PAYME_MERCHANT_ID")
|
|
40
|
+
if not self.secret_key:
|
|
41
|
+
self.secret_key = os.getenv("PAYME_SECRET_KEY")
|
|
42
|
+
test_env = os.getenv("PAYME_TEST_MODE", "").lower()
|
|
43
|
+
if test_env in ("1", "true", "yes"):
|
|
44
|
+
self.test_mode = True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ClickConfig:
|
|
49
|
+
service_id: Optional[str] = None
|
|
50
|
+
merchant_id: Optional[str] = None
|
|
51
|
+
secret_key: Optional[str] = None
|
|
52
|
+
merchant_user_id: Optional[str] = None
|
|
53
|
+
|
|
54
|
+
def __post_init__(self):
|
|
55
|
+
if not self.service_id:
|
|
56
|
+
self.service_id = os.getenv("CLICK_SERVICE_ID")
|
|
57
|
+
if not self.merchant_id:
|
|
58
|
+
self.merchant_id = os.getenv("CLICK_MERCHANT_ID")
|
|
59
|
+
if not self.secret_key:
|
|
60
|
+
self.secret_key = os.getenv("CLICK_SECRET_KEY")
|
|
61
|
+
if not self.merchant_user_id:
|
|
62
|
+
self.merchant_user_id = os.getenv("CLICK_MERCHANT_USER_ID")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class UzumConfig:
|
|
67
|
+
shop_id: Optional[str] = None
|
|
68
|
+
secret_key: Optional[str] = None
|
|
69
|
+
|
|
70
|
+
def __post_init__(self):
|
|
71
|
+
if not self.shop_id:
|
|
72
|
+
self.shop_id = os.getenv("UZUM_SHOP_ID")
|
|
73
|
+
if not self.secret_key:
|
|
74
|
+
self.secret_key = os.getenv("UZUM_SECRET_KEY")
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Exceptions for pay-engine."""
|
|
2
|
+
|
|
3
|
+
class PayEngineError(Exception):
|
|
4
|
+
"""Base exception for all pay-engine errors."""
|
|
5
|
+
pass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(PayEngineError):
|
|
9
|
+
"""Raised when provider credentials or configurations are missing or invalid."""
|
|
10
|
+
pass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SignatureVerificationError(PayEngineError):
|
|
14
|
+
"""Raised when an incoming webhook signature or authentication fails."""
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class InvalidAmountError(PayEngineError):
|
|
19
|
+
"""Raised when transaction amount is invalid, zero, or negative."""
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class OrderNotFoundError(PayEngineError):
|
|
24
|
+
"""Raised when an order or transaction ID cannot be found."""
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class CardTokenError(PayEngineError):
|
|
29
|
+
"""Raised when card tokenization, binding, or auto-charge fails."""
|
|
30
|
+
pass
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Data models for pay-engine."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TransactionStatus(str, Enum):
|
|
9
|
+
PENDING = "pending"
|
|
10
|
+
PAID = "paid"
|
|
11
|
+
CANCELLED = "cancelled"
|
|
12
|
+
FAILED = "failed"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PaymentRequest:
|
|
17
|
+
amount: float
|
|
18
|
+
order_id: str
|
|
19
|
+
description: Optional[str] = None
|
|
20
|
+
return_url: Optional[str] = None
|
|
21
|
+
extra_params: Dict[str, Any] = field(default_factory=dict)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class WebhookResult:
|
|
26
|
+
is_paid: bool
|
|
27
|
+
provider: str
|
|
28
|
+
order_id: str
|
|
29
|
+
amount: float
|
|
30
|
+
transaction_id: Optional[str] = None
|
|
31
|
+
status: TransactionStatus = TransactionStatus.PENDING
|
|
32
|
+
raw_data: Dict[str, Any] = field(default_factory=dict)
|
|
33
|
+
response_data: Optional[Dict[str, Any]] = None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class CardChargeRequest:
|
|
38
|
+
card_token: str
|
|
39
|
+
amount: float
|
|
40
|
+
order_id: str
|
|
41
|
+
description: Optional[str] = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class CardChargeResult:
|
|
46
|
+
success: bool
|
|
47
|
+
order_id: str
|
|
48
|
+
amount: float
|
|
49
|
+
transaction_id: Optional[str] = None
|
|
50
|
+
message: Optional[str] = None
|
|
51
|
+
raw_data: Dict[str, Any] = field(default_factory=dict)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Integrations package for pay-engine."""
|
|
2
|
+
|
|
3
|
+
from .fastapi import click_router, pay_router, payme_router, uzum_router
|
|
4
|
+
from .telegram import format_amount, get_payment_links, payment_buttons
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"payme_router",
|
|
8
|
+
"click_router",
|
|
9
|
+
"uzum_router",
|
|
10
|
+
"pay_router",
|
|
11
|
+
"payment_buttons",
|
|
12
|
+
"get_payment_links",
|
|
13
|
+
"format_amount",
|
|
14
|
+
]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""FastAPI integration for pay-engine."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, Callable, Optional
|
|
5
|
+
|
|
6
|
+
from ..providers.click.handler import ClickHandler
|
|
7
|
+
from ..providers.payme.handler import PaymeHandler
|
|
8
|
+
from ..providers.uzum.handler import UzumHandler
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from fastapi import APIRouter, Request, Response
|
|
12
|
+
from fastapi.responses import JSONResponse
|
|
13
|
+
FASTAPI_AVAILABLE = True
|
|
14
|
+
except ImportError:
|
|
15
|
+
FASTAPI_AVAILABLE = False
|
|
16
|
+
APIRouter = Any # type: ignore
|
|
17
|
+
Request = Any # type: ignore
|
|
18
|
+
Response = Any # type: ignore
|
|
19
|
+
JSONResponse = Any # type: ignore
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _ensure_fastapi():
|
|
23
|
+
if not FASTAPI_AVAILABLE:
|
|
24
|
+
raise ImportError("FastAPI is not installed. Install it via 'pip install fastapi'.")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def _run_callback(cb: Optional[Callable], *args):
|
|
28
|
+
if not cb:
|
|
29
|
+
return
|
|
30
|
+
if inspect.iscoroutinefunction(cb):
|
|
31
|
+
await cb(*args)
|
|
32
|
+
else:
|
|
33
|
+
cb(*args)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def payme_router(
|
|
37
|
+
on_success: Optional[Callable[[str, float], Any]] = None,
|
|
38
|
+
check_order: Optional[Callable[[str, float], bool]] = None,
|
|
39
|
+
secret_key: Optional[str] = None,
|
|
40
|
+
path: str = "/payme",
|
|
41
|
+
) -> APIRouter:
|
|
42
|
+
"""Creates a ready-to-mount FastAPI APIRouter for Payme JSON-RPC webhook."""
|
|
43
|
+
_ensure_fastapi()
|
|
44
|
+
router = APIRouter()
|
|
45
|
+
|
|
46
|
+
async def _on_success_wrapper(order_id: str, amount: float):
|
|
47
|
+
await _run_callback(on_success, order_id, amount)
|
|
48
|
+
|
|
49
|
+
handler = PaymeHandler(
|
|
50
|
+
secret_key=secret_key,
|
|
51
|
+
on_success=_on_success_wrapper,
|
|
52
|
+
check_order=check_order,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
@router.post(path)
|
|
56
|
+
async def handle_payme(request: Request):
|
|
57
|
+
headers = dict(request.headers)
|
|
58
|
+
body = await request.body()
|
|
59
|
+
result = handler.process(headers, body)
|
|
60
|
+
return JSONResponse(content=result.response_data or {})
|
|
61
|
+
|
|
62
|
+
return router
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def click_router(
|
|
66
|
+
on_success: Optional[Callable[[str, float], Any]] = None,
|
|
67
|
+
check_order: Optional[Callable[[str, float], bool]] = None,
|
|
68
|
+
service_id: Optional[str] = None,
|
|
69
|
+
secret_key: Optional[str] = None,
|
|
70
|
+
path: str = "/click",
|
|
71
|
+
) -> APIRouter:
|
|
72
|
+
"""Creates a ready-to-mount FastAPI APIRouter for Click webhook."""
|
|
73
|
+
_ensure_fastapi()
|
|
74
|
+
router = APIRouter()
|
|
75
|
+
|
|
76
|
+
async def _on_success_wrapper(order_id: str, amount: float):
|
|
77
|
+
await _run_callback(on_success, order_id, amount)
|
|
78
|
+
|
|
79
|
+
handler = ClickHandler(
|
|
80
|
+
service_id=service_id,
|
|
81
|
+
secret_key=secret_key,
|
|
82
|
+
on_success=_on_success_wrapper,
|
|
83
|
+
check_order=check_order,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
@router.post(path)
|
|
87
|
+
async def handle_click(request: Request):
|
|
88
|
+
headers = dict(request.headers)
|
|
89
|
+
body = await request.body()
|
|
90
|
+
result = handler.process(headers, body)
|
|
91
|
+
return JSONResponse(content=result.response_data or {})
|
|
92
|
+
|
|
93
|
+
return router
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def uzum_router(
|
|
97
|
+
on_success: Optional[Callable[[str, float], Any]] = None,
|
|
98
|
+
secret_key: Optional[str] = None,
|
|
99
|
+
path: str = "/uzum",
|
|
100
|
+
) -> APIRouter:
|
|
101
|
+
"""Creates a ready-to-mount FastAPI APIRouter for Uzum Bank webhook."""
|
|
102
|
+
_ensure_fastapi()
|
|
103
|
+
router = APIRouter()
|
|
104
|
+
|
|
105
|
+
async def _on_success_wrapper(order_id: str, amount: float):
|
|
106
|
+
await _run_callback(on_success, order_id, amount)
|
|
107
|
+
|
|
108
|
+
handler = UzumHandler(
|
|
109
|
+
secret_key=secret_key,
|
|
110
|
+
on_success=_on_success_wrapper,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
@router.post(path)
|
|
114
|
+
async def handle_uzum(request: Request):
|
|
115
|
+
headers = dict(request.headers)
|
|
116
|
+
body = await request.body()
|
|
117
|
+
result = handler.process(headers, body)
|
|
118
|
+
return JSONResponse(content=result.response_data or {})
|
|
119
|
+
|
|
120
|
+
return router
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def pay_router(
|
|
124
|
+
on_success: Optional[Callable[[str, float], Any]] = None,
|
|
125
|
+
check_order: Optional[Callable[[str, float], bool]] = None,
|
|
126
|
+
prefix: str = "/payments",
|
|
127
|
+
) -> APIRouter:
|
|
128
|
+
"""
|
|
129
|
+
Creates an all-in-one APIRouter including Payme (/payme), Click (/click), and Uzum (/uzum).
|
|
130
|
+
Mount with `app.include_router(pay_router(...))`.
|
|
131
|
+
"""
|
|
132
|
+
_ensure_fastapi()
|
|
133
|
+
master = APIRouter(prefix=prefix)
|
|
134
|
+
master.include_router(payme_router(on_success=on_success, check_order=check_order))
|
|
135
|
+
master.include_router(click_router(on_success=on_success, check_order=check_order))
|
|
136
|
+
master.include_router(uzum_router(on_success=on_success))
|
|
137
|
+
return master
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Telegram bot integrations for pay-engine."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional, Union
|
|
4
|
+
|
|
5
|
+
from ..core.config import ClickConfig, PaymeConfig, UzumConfig
|
|
6
|
+
from ..providers.click.client import ClickClient
|
|
7
|
+
from ..providers.payme.client import PaymeClient
|
|
8
|
+
from ..providers.uzum.client import UzumClient
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
|
12
|
+
AIOGRAM_AVAILABLE = True
|
|
13
|
+
except ImportError:
|
|
14
|
+
AIOGRAM_AVAILABLE = False
|
|
15
|
+
InlineKeyboardButton = Any # type: ignore
|
|
16
|
+
InlineKeyboardMarkup = Any # type: ignore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def format_amount(amount: float) -> str:
|
|
20
|
+
"""Format amount with space separator, e.g. 100000 -> '100 000'."""
|
|
21
|
+
if amount.is_integer():
|
|
22
|
+
return f"{int(amount):,}".replace(",", " ")
|
|
23
|
+
return f"{amount:,.2f}".replace(",", " ")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def get_payment_links(
|
|
27
|
+
amount: float,
|
|
28
|
+
order_id: Union[str, int],
|
|
29
|
+
providers: Optional[List[str]] = None,
|
|
30
|
+
return_url: Optional[str] = None,
|
|
31
|
+
) -> Dict[str, str]:
|
|
32
|
+
"""
|
|
33
|
+
Generate payment URLs for multiple providers at once.
|
|
34
|
+
Only providers with valid credentials in env or config will be included.
|
|
35
|
+
"""
|
|
36
|
+
if providers is None:
|
|
37
|
+
providers = ["payme", "click", "uzum"]
|
|
38
|
+
|
|
39
|
+
links = {}
|
|
40
|
+
|
|
41
|
+
if "payme" in providers:
|
|
42
|
+
cfg = PaymeConfig()
|
|
43
|
+
if cfg.merchant_id:
|
|
44
|
+
try:
|
|
45
|
+
links["payme"] = PaymeClient(merchant_id=cfg.merchant_id).create_link(
|
|
46
|
+
amount=amount, order_id=order_id, return_url=return_url
|
|
47
|
+
)
|
|
48
|
+
except Exception:
|
|
49
|
+
pass
|
|
50
|
+
|
|
51
|
+
if "click" in providers:
|
|
52
|
+
cfg = ClickConfig()
|
|
53
|
+
if cfg.service_id and cfg.merchant_id:
|
|
54
|
+
try:
|
|
55
|
+
links["click"] = ClickClient(
|
|
56
|
+
service_id=cfg.service_id, merchant_id=cfg.merchant_id
|
|
57
|
+
).create_link(amount=amount, order_id=order_id, return_url=return_url)
|
|
58
|
+
except Exception:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
if "uzum" in providers:
|
|
62
|
+
cfg = UzumConfig()
|
|
63
|
+
if cfg.shop_id:
|
|
64
|
+
try:
|
|
65
|
+
links["uzum"] = UzumClient(shop_id=cfg.shop_id).create_link(
|
|
66
|
+
amount=amount, order_id=order_id, return_url=return_url
|
|
67
|
+
)
|
|
68
|
+
except Exception:
|
|
69
|
+
pass
|
|
70
|
+
|
|
71
|
+
return links
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def payment_buttons(
|
|
75
|
+
amount: float,
|
|
76
|
+
order_id: Union[str, int],
|
|
77
|
+
providers: Optional[List[str]] = None,
|
|
78
|
+
return_url: Optional[str] = None,
|
|
79
|
+
custom_labels: Optional[Dict[str, str]] = None,
|
|
80
|
+
) -> Any:
|
|
81
|
+
"""
|
|
82
|
+
Generate Telegram inline keyboard with payment buttons.
|
|
83
|
+
Returns aiogram `InlineKeyboardMarkup` if aiogram is installed,
|
|
84
|
+
otherwise returns a dict representation suitable for any bot framework.
|
|
85
|
+
"""
|
|
86
|
+
links = get_payment_links(amount=amount, order_id=order_id, providers=providers, return_url=return_url)
|
|
87
|
+
amount_str = format_amount(amount)
|
|
88
|
+
|
|
89
|
+
default_labels = {
|
|
90
|
+
"payme": f"💳 Payme ({amount_str} so'm)",
|
|
91
|
+
"click": f"🔹 Click ({amount_str} so'm)",
|
|
92
|
+
"uzum": f"🍇 Uzum Bank ({amount_str} so'm)",
|
|
93
|
+
}
|
|
94
|
+
labels = {**default_labels, **(custom_labels or {})}
|
|
95
|
+
|
|
96
|
+
if AIOGRAM_AVAILABLE:
|
|
97
|
+
keyboard = []
|
|
98
|
+
for prov, url in links.items():
|
|
99
|
+
btn = InlineKeyboardButton(text=labels.get(prov, prov.title()), url=url)
|
|
100
|
+
keyboard.append([btn])
|
|
101
|
+
return InlineKeyboardMarkup(inline_keyboard=keyboard)
|
|
102
|
+
|
|
103
|
+
# Fallback to standard dict format
|
|
104
|
+
raw_keyboard = []
|
|
105
|
+
for prov, url in links.items():
|
|
106
|
+
raw_keyboard.append([{"text": labels.get(prov, prov.title()), "url": url}])
|
|
107
|
+
return {"inline_keyboard": raw_keyboard}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Providers package for pay-engine."""
|
|
2
|
+
|
|
3
|
+
from .base import BaseProvider
|
|
4
|
+
from .click import ClickClient, ClickHandler
|
|
5
|
+
from .payme import PaymeClient, PaymeHandler
|
|
6
|
+
from .uzum import UzumClient, UzumHandler
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseProvider",
|
|
10
|
+
"PaymeClient",
|
|
11
|
+
"PaymeHandler",
|
|
12
|
+
"ClickClient",
|
|
13
|
+
"ClickHandler",
|
|
14
|
+
"UzumClient",
|
|
15
|
+
"UzumHandler",
|
|
16
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Abstract Base Provider for pay-engine."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from typing import Any, Dict, Optional, Union
|
|
5
|
+
|
|
6
|
+
from ..core.models import CardChargeResult, WebhookResult
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BaseProvider(ABC):
|
|
10
|
+
"""Base class for all payment providers."""
|
|
11
|
+
|
|
12
|
+
@abstractmethod
|
|
13
|
+
def create_link(
|
|
14
|
+
self,
|
|
15
|
+
amount: float,
|
|
16
|
+
order_id: Union[str, int],
|
|
17
|
+
return_url: Optional[str] = None,
|
|
18
|
+
**kwargs: Any,
|
|
19
|
+
) -> str:
|
|
20
|
+
"""Generate a direct payment checkout URL for customer."""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def verify_webhook(
|
|
25
|
+
self,
|
|
26
|
+
headers: Dict[str, str],
|
|
27
|
+
body: Union[Dict[str, Any], bytes, str],
|
|
28
|
+
) -> WebhookResult:
|
|
29
|
+
"""Verify incoming webhook request and return parsed result."""
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
def charge_card(
|
|
33
|
+
self,
|
|
34
|
+
card_token: str,
|
|
35
|
+
amount: float,
|
|
36
|
+
order_id: Union[str, int],
|
|
37
|
+
**kwargs: Any,
|
|
38
|
+
) -> CardChargeResult:
|
|
39
|
+
"""Execute automated payment from saved card token. Override in providers supporting it."""
|
|
40
|
+
raise NotImplementedError(f"{self.__class__.__name__} does not support direct card charging.")
|