Skip to content
This repository was archived by the owner on Nov 2, 2025. It is now read-only.

Commit 0b64eea

Browse files
committed
Integrate Cryptomus payment gateway
1 parent 725da8f commit 0b64eea

File tree

12 files changed

+170
-48
lines changed

12 files changed

+170
-48
lines changed

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828

2929
**3X-UI-SHOP** is a comprehensive solution designed to automate the sale of VPN subscriptions through Telegram.
3030
The bot uses the **3X-UI** panel API for client management and supports multiple payment methods, including
31-
**~~Cryptomus~~**, **YooKassa**, **YooMoney**, and **Telegram Stars**.
31+
**Cryptomus**, **YooKassa**, **YooMoney**, and **Telegram Stars**.
3232

3333
The bot enables efficient subscription sales with advanced features:
3434

@@ -74,7 +74,7 @@ The bot includes a user-friendly admin panel with tools for efficient management
7474
Administrators do not have access to server management.
7575

7676
- **`Server Manager`**: Add, remove, disable, and check servers in the pool
77-
- **`Bot Statistics`**: View usage analytics and performance data
77+
- **`Statistics`**: View usage analytics and performance data
7878
- **`User Editor`**: Manage user accounts and subscriptions
7979
- **`Promocode Editor`**: Create, edit, and delete promocodes
8080
- **`Notification Sender`**: Send custom notifications to users
@@ -83,7 +83,6 @@ Administrators do not have access to server management.
8383

8484

8585
### 🚧 Current Tasks
86-
- [ ] Cryptomus payment
8786
- [ ] Trial period
8887
- [ ] Referral system
8988
- [ ] Statistics
@@ -144,7 +143,7 @@ Before starting the installation, make sure you have the installed [**Docker**](
144143
| ~~SHOP_TRIAL_ENABLED~~ || ~~True~~ | ~~Enable trial subscription~~ |
145144
| ~~SHOP_TRIAL_PERIOD~~ || ~~3~~ | ~~Period of the trial subscription in days~~ |
146145
| SHOP_PAYMENT_STARS_ENABLED || True | Enable Telegram stars payment |
147-
| ~~SHOP_PAYMENT_CRYPTOMUS_ENABLED~~ || ~~False~~ | ~~Enable Cryptomus payment~~ |
146+
| SHOP_PAYMENT_CRYPTOMUS_ENABLED || False | Enable Cryptomus payment |
148147
| SHOP_PAYMENT_YOOKASSA_ENABLED || False | Enable Yookassa payment |
149148
| SHOP_PAYMENT_YOOMONEY_ENABLED || False | Enable Yoomoney payment |
150149
| | | |
@@ -154,6 +153,9 @@ Before starting the installation, make sure you have the installed [**Docker**](
154153
| XUI_SUBSCRIPTION_PORT || 2096 | Port for subscription |
155154
| XUI_SUBSCRIPTION_PATH || /user/ | Path for subscription |
156155
| | | |
156+
| CRYPTOMUS_API_KEY || - | API key for Cryptomus payment |
157+
| CRYPTOMUS_MERCHANT_ID || - | Merchant ID for Cryptomus payment |
158+
| | | |
157159
| YOOKASSA_TOKEN || - | Token for YooKassa payment |
158160
| YOOKASSA_SHOP_ID || - | Shop ID for YooKassa payment |
159161
| | | |

app/bot/payment_gateways/cryptomus.py

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,25 @@
1+
import base64
2+
import hashlib
3+
import json
14
import logging
5+
import uuid
6+
from hmac import compare_digest
27

8+
import aiohttp
39
from aiogram import Bot
410
from aiogram.fsm.storage.redis import RedisStorage
511
from aiogram.utils.i18n import I18n
12+
from aiogram.utils.i18n import gettext as _
613
from aiogram.utils.i18n import lazy_gettext as __
714
from aiohttp.web import Application, Request, Response
815
from sqlalchemy.ext.asyncio import async_sessionmaker
916

1017
from app.bot.models import ServicesContainer, SubscriptionData
1118
from app.bot.payment_gateways import PaymentGateway
12-
from app.bot.utils.constants import CRYPTOMUS_WEBHOOK, Currency
19+
from app.bot.utils.constants import CRYPTOMUS_WEBHOOK, Currency, TransactionStatus
1320
from app.bot.utils.navigation import NavSubscription
1421
from app.config import Config
22+
from app.db.models import Transaction
1523

1624
logger = logging.getLogger(__name__)
1725

@@ -30,7 +38,7 @@ def __init__(
3038
bot: Bot,
3139
i18n: I18n,
3240
services: ServicesContainer,
33-
):
41+
) -> None:
3442
self.name = __("payment:gateway:cryptomus")
3543
self.app = app
3644
self.config = config
@@ -40,12 +48,51 @@ def __init__(
4048
self.i18n = i18n
4149
self.services = services
4250

43-
self.app.router.add_post(CRYPTOMUS_WEBHOOK, lambda request: self.webhook_handler(request))
51+
self.app.router.add_post(CRYPTOMUS_WEBHOOK, self.webhook_handler)
4452
logger.info("Cryptomus payment gateway initialized.")
4553

4654
async def create_payment(self, data: SubscriptionData) -> str:
55+
bot_username = (await self.bot.get_me()).username
56+
redirect_url = f"https://t.me/{bot_username}"
57+
order_id = str(uuid.uuid4())
58+
price = str(data.price)
59+
60+
payload = {
61+
"amount": price,
62+
"currency": self.currency.code,
63+
"order_id": order_id,
64+
"url_return": redirect_url,
65+
"url_success": redirect_url,
66+
"url_callback": self.config.bot.DOMAIN + CRYPTOMUS_WEBHOOK,
67+
"lifetime": 1800,
68+
"is_payment_multiple": False,
69+
}
70+
headers = {
71+
"merchant": self.config.cryptomus.MERCHANT_ID,
72+
"sign": self.generate_signature(json.dumps(payload)),
73+
"Content-Type": "application/json",
74+
}
75+
76+
async with aiohttp.ClientSession() as session:
77+
url = "https://api.cryptomus.com/v1/payment"
78+
async with session.post(url, json=payload, headers=headers) as response:
79+
result = await response.json()
80+
if response.status == 200 and result.get("result", {}).get("url"):
81+
pay_url = result["result"]["url"]
82+
else:
83+
raise Exception(f"Error: {response.status}; Result: {result}; Data: {data}")
84+
85+
async with self.session() as session:
86+
await Transaction.create(
87+
session=session,
88+
tg_id=data.user_id,
89+
subscription=data.pack(),
90+
payment_id=result["result"]["order_id"],
91+
status=TransactionStatus.PENDING,
92+
)
93+
4794
logger.info(f"Payment link created for user {data.user_id}: {pay_url}")
48-
pass
95+
return pay_url
4996

5097
async def handle_payment_succeeded(self, payment_id: str) -> None:
5198
await self._on_payment_succeeded(payment_id)
@@ -54,4 +101,57 @@ async def handle_payment_canceled(self, payment_id: str) -> None:
54101
await self._on_payment_canceled(payment_id)
55102

56103
async def webhook_handler(self, request: Request) -> Response:
57-
pass
104+
logger.debug(f"Received Cryptomus webhook request")
105+
try:
106+
event_json = await request.json()
107+
108+
if not self.verify_webhook(request, event_json):
109+
return Response(status=403)
110+
111+
match event_json.get("status"):
112+
case "paid" | "paid_over":
113+
order_id = event_json.get("order_id")
114+
await self.handle_payment_succeeded(order_id)
115+
return Response(status=200)
116+
117+
case "cancel":
118+
order_id = event_json.get("order_id")
119+
await self.handle_payment_canceled(order_id)
120+
return Response(status=200)
121+
122+
case _:
123+
return Response(status=400)
124+
125+
except Exception as exception:
126+
logger.exception(f"Error processing Cryptomus webhook: {exception}")
127+
return Response(status=400)
128+
129+
def verify_webhook(self, request: Request, data: dict) -> bool:
130+
client_ip = (
131+
request.headers.get("CF-Connecting-IP")
132+
or request.headers.get("X-Real-IP")
133+
or request.headers.get("X-Forwarded-For")
134+
or request.remote
135+
)
136+
if client_ip not in ["91.227.144.54"]:
137+
logger.warning(f"Unauthorized IP: {client_ip}")
138+
return False
139+
140+
sign = data.pop("sign", None)
141+
if not sign:
142+
logger.warning("Missing signature.")
143+
return False
144+
145+
json_data = json.dumps(data, separators=(",", ":"))
146+
hash_value = self.generate_signature(json_data)
147+
148+
if not compare_digest(hash_value, sign):
149+
logger.warning(f"Invalid signature.")
150+
return False
151+
152+
return True
153+
154+
def generate_signature(self, data: str) -> str:
155+
base64_encoded = base64.b64encode(data.encode()).decode()
156+
raw_string = f"{base64_encoded}{self.config.cryptomus.API_KEY}"
157+
return hashlib.md5(raw_string.encode()).hexdigest()

app/bot/payment_gateways/gateway_factory.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ def register_gateways(
4444

4545
gateways = [
4646
(config.shop.PAYMENT_STARS_ENABLED, TelegramStars),
47+
(config.shop.PAYMENT_CRYPTOMUS_ENABLED, Cryptomus),
4748
(config.shop.PAYMENT_YOOKASSA_ENABLED, Yookassa),
4849
(config.shop.PAYMENT_YOOMONEY_ENABLED, Yoomoney),
49-
(config.shop.PAYMENT_CRYPTOMUS_ENABLED, Cryptomus),
5050
]
5151

5252
for enabled, gateway_cls in gateways:

app/bot/payment_gateways/yookassa.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def __init__(
5353
self.services = services
5454

5555
Configuration.configure(self.config.yookassa.SHOP_ID, self.config.yookassa.TOKEN)
56-
self.app.router.add_post(YOOKASSA_WEBHOOK, lambda request: self.webhook_handler(request))
56+
self.app.router.add_post(YOOKASSA_WEBHOOK, self.webhook_handler)
5757
logger.info("YooKassa payment gateway initialized.")
5858

5959
async def create_payment(self, data: SubscriptionData) -> str:

app/bot/payment_gateways/yoomoney.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ def __init__(
4646
self.i18n = i18n
4747
self.services = services
4848

49-
self.app.router.add_post(YOOMONEY_WEBHOOK, lambda request: self.webhook_handler(request))
49+
self.app.router.add_post(YOOMONEY_WEBHOOK, self.webhook_handler)
5050
logger.info("YooMoney payment gateway initialized.")
5151

5252
async def create_payment(self, data: SubscriptionData) -> str:

app/bot/routers/main_menu/handler.py

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,9 @@ async def redirect_to_main_menu(
7070
main_message_id = await state.get_value(MAIN_MESSAGE_ID_KEY)
7171
is_admin = await IsAdmin()(user_id=user.tg_id)
7272

73-
try:
74-
await bot.edit_message_text(
75-
text=_("main_menu:message:main").format(name=user.first_name),
76-
chat_id=user.tg_id,
77-
message_id=main_message_id,
78-
reply_markup=main_menu_keyboard(is_admin),
79-
)
80-
except Exception as exception:
81-
logger.error(f"Failed to edit main message: {exception}")
73+
await bot.edit_message_text(
74+
text=_("main_menu:message:main").format(name=user.first_name),
75+
chat_id=user.tg_id,
76+
message_id=main_message_id,
77+
reply_markup=main_menu_keyboard(is_admin),
78+
)

app/bot/routers/subscription/payment_handler.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ async def callback_payment_method_selected(
7272
)
7373
except Exception as exception:
7474
logger.error(f"Error processing payment: {exception}")
75-
await callback.answer(_("payment:message:error"), show_alert=True)
75+
await services.notification.show_popup(callback=callback, text=_("payment:popup:error"))
7676
finally:
7777
await state.set_state(None)
7878

app/bot/utils/constants.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@
3030

3131
# region: Webhook paths
3232
TELEGRAM_WEBHOOK = "/webhook" # Webhook path for Telegram bot updates
33+
CONNECTION_WEBHOOK = "/connection" # Webhook path for receiving connection requests
34+
CRYPTOMUS_WEBHOOK = "/cryptomus" # Webhook path for receiving Cryptomus payment notifications
3335
YOOKASSA_WEBHOOK = "/yookassa" # Webhook path for receiving Yookassa payment notifications
3436
YOOMONEY_WEBHOOK = "/yoomoney" # Webhook path for receiving Yoomoney payment notifications
35-
CRYPTOMUS_WEBHOOK = "/cryptomus" # Webhook path for receiving Cryptomus payment notifications
36-
CONNECTION_WEBHOOK = "/connection" # Webhook path for receiving connection requests
3737
# endregion
3838

3939
# region: Notification tags

app/config.py

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ class XUIConfig:
8080
SUBSCRIPTION_PATH: str
8181

8282

83+
@dataclass
84+
class CryptomusConfig:
85+
API_KEY: str | None
86+
MERCHANT_ID: str | None
87+
88+
8389
@dataclass
8490
class YooKassaConfig:
8591
TOKEN: str | None
@@ -132,6 +138,7 @@ class Config:
132138
bot: BotConfig
133139
shop: ShopConfig
134140
xui: XUIConfig
141+
cryptomus: CryptomusConfig
135142
yookassa: YooKassaConfig
136143
yoomoney: YooMoneyConfig
137144
database: DatabaseConfig
@@ -156,6 +163,19 @@ def load_config() -> Config:
156163
default=DEFAULT_SHOP_PAYMENT_STARS_ENABLED,
157164
)
158165

166+
payment_cryptomus_enabled = env.bool(
167+
"SHOP_PAYMENT_CRYPTOMUS_ENABLED",
168+
default=DEFAULT_SHOP_PAYMENT_CRYPTOMUS_ENABLED,
169+
)
170+
if payment_cryptomus_enabled:
171+
cryptomus_api_key = env.str("CRYPTOMUS_API_KEY", default=None)
172+
cryptomus_merchant_id = env.str("CRYPTOMUS_MERCHANT_ID", default=None)
173+
if not cryptomus_api_key or not cryptomus_merchant_id:
174+
logger.error(
175+
"CRYPTOMUS_API_KEY or CRYPTOMUS_MERCHANT_ID is not set. Payment Cryptomus is disabled."
176+
)
177+
payment_cryptomus_enabled = False
178+
159179
payment_yookassa_enabled = env.bool(
160180
"SHOP_PAYMENT_YOOKASSA_ENABLED",
161181
default=DEFAULT_SHOP_PAYMENT_YOOKASSA_ENABLED,
@@ -182,17 +202,10 @@ def load_config() -> Config:
182202
)
183203
payment_yoomoney_enabled = False
184204

185-
payment_cryptomus_enabled = env.bool(
186-
"SHOP_PAYMENT_CRYPTOMUS_ENABLED",
187-
default=DEFAULT_SHOP_PAYMENT_CRYPTOMUS_ENABLED,
188-
)
189-
if payment_cryptomus_enabled:
190-
pass
191-
192205
if (
193-
not payment_yookassa_enabled
206+
not payment_stars_enabled
194207
and not payment_cryptomus_enabled
195-
and not payment_stars_enabled
208+
and not payment_yookassa_enabled
196209
and not payment_yoomoney_enabled
197210
):
198211
logger.warning("No payment methods are enabled. Enabling Stars payment method.")
@@ -235,6 +248,10 @@ def load_config() -> Config:
235248
default=DEFAULT_SUBSCRIPTION_PATH,
236249
),
237250
),
251+
cryptomus=CryptomusConfig(
252+
API_KEY=env.str("CRYPTOMUS_API_KEY", default=None),
253+
MERCHANT_ID=env.str("CRYPTOMUS_MERCHANT_ID", default=None),
254+
),
238255
yookassa=YooKassaConfig(
239256
TOKEN=env.str("YOOKASSA_TOKEN", default=None),
240257
SHOP_ID=env.int("YOOKASSA_SHOP_ID", default=None),

app/locales/en/LC_MESSAGES/bot.po

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ msgid ""
77
msgstr ""
88
"Project-Id-Version: bot 0.1\n"
99
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
10-
"POT-Creation-Date: 2025-02-23 22:16+0500\n"
10+
"POT-Creation-Date: 2025-02-24 11:41+0500\n"
1111
"PO-Revision-Date: 2024-12-05 10:24+0500\n"
1212
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
1313
"Language: en\n"
@@ -40,7 +40,7 @@ msgstr ""
4040
"User ID: {user_id}\n"
4141
"<code>{devices}</code> | <code>{duration}</code>"
4242

43-
#: app/bot/payment_gateways/cryptomus.py:34
43+
#: app/bot/payment_gateways/cryptomus.py:41
4444
msgid "payment:gateway:cryptomus"
4545
msgstr "Cryptomus"
4646

@@ -738,7 +738,7 @@ msgstr "◀️ Change duration"
738738
msgid "subscription:button:download_app"
739739
msgstr "🔌 Connect"
740740

741-
#: app/bot/routers/subscription/payment_handler.py:46
741+
#: app/bot/routers/subscription/payment_handler.py:58
742742
msgid "payment:message:order_extend"
743743
msgstr ""
744744
"🛒 <b>Confirmation of extension:</b>\n"
@@ -750,7 +750,7 @@ msgstr ""
750750
"<i>The number of devices will remain the same, and the new subscription "
751751
"duration will be added to the remaining time!</i>"
752752

753-
#: app/bot/routers/subscription/payment_handler.py:48
753+
#: app/bot/routers/subscription/payment_handler.py:60
754754
msgid "payment:message:order_change"
755755
msgstr ""
756756
"🛒 <b>Confirmation of change:</b>\n"
@@ -762,7 +762,7 @@ msgstr ""
762762
"<i>The number of devices and subscription duration will be changed "
763763
"without recalculating the previous data!</i>"
764764

765-
#: app/bot/routers/subscription/payment_handler.py:50
765+
#: app/bot/routers/subscription/payment_handler.py:62
766766
msgid "payment:message:order"
767767
msgstr ""
768768
"🛒 <b>Confirmation of purchase:</b>\n"
@@ -774,6 +774,10 @@ msgstr ""
774774
"<i>After the payment, a unique key for connecting to the VPN will be "
775775
"generated for you. The key will be available on your profile page.</i>"
776776

777+
#: app/bot/routers/subscription/payment_handler.py:75
778+
msgid "payment:popup:error"
779+
msgstr "❌ An error occurred during creating payment."
780+
777781
#: app/bot/routers/subscription/promocode_handler.py:30
778782
msgid "promocode:message:main"
779783
msgstr ""
@@ -978,5 +982,4 @@ msgstr[1] ""
978982
msgid "1 month"
979983
msgid_plural "{} months"
980984
msgstr[0] ""
981-
msgstr[1] ""
982-
985+
msgstr[1] ""

0 commit comments

Comments
 (0)