refactor: payments providers

This commit is contained in:
3252a8
2026-05-18 10:29:00 +03:00
parent fff7e90e14
commit 51c9c8b4f0
63 changed files with 7480 additions and 6843 deletions
+52
View File
@@ -93,6 +93,58 @@ WATA_ENABLED=False #
# Order of payment methods (top to bottom). Supported: severpay, wata, freekassa, platega, yookassa, stars, cryptopay
PAYMENT_METHODS_ORDER=severpay,wata,yookassa,cryptopay,freekassa,platega,stars
# Payment button presentation overrides (all optional; empty = provider defaults)
# Text supports per-language overrides: *_LABEL_RU and *_LABEL_EN. Legacy *_LABEL applies to all languages if per-language values are empty.
# Available WebApp icons are exported from frontend/src/lib/components/ui/icons.js, e.g. CreditCard, Smartphone, Bitcoin, Sparkles.
PAYMENT_YOOKASSA_WEBAPP_LABEL_RU= # WebApp payment button text for YooKassa (Russian)
PAYMENT_YOOKASSA_WEBAPP_LABEL_EN= # WebApp payment button text for YooKassa (English)
PAYMENT_YOOKASSA_WEBAPP_ICON=CreditCard # WebApp payment button icon for YooKassa
PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU= # Telegram bot payment button text for YooKassa (Russian)
PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN= # Telegram bot payment button text for YooKassa (English)
PAYMENT_YOOKASSA_TELEGRAM_EMOJI= # Telegram bot payment button emoji for YooKassa
PAYMENT_FREEKASSA_WEBAPP_LABEL_RU= # WebApp payment button text for FreeKassa (Russian)
PAYMENT_FREEKASSA_WEBAPP_LABEL_EN= # WebApp payment button text for FreeKassa (English)
PAYMENT_FREEKASSA_WEBAPP_ICON=Smartphone # WebApp payment button icon for FreeKassa
PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU= # Telegram bot payment button text for FreeKassa (Russian)
PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN= # Telegram bot payment button text for FreeKassa (English)
PAYMENT_FREEKASSA_TELEGRAM_EMOJI= # Telegram bot payment button emoji for FreeKassa
PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_RU= # WebApp payment button text for Platega SBP (Russian)
PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_EN= # WebApp payment button text for Platega SBP (English)
PAYMENT_PLATEGA_SBP_WEBAPP_ICON=CreditCard # WebApp payment button icon for Platega SBP
PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Platega SBP (Russian)
PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Platega SBP (English)
PAYMENT_PLATEGA_SBP_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Platega SBP
PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU= # WebApp payment button text for Platega crypto (Russian)
PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_EN= # WebApp payment button text for Platega crypto (English)
PAYMENT_PLATEGA_CRYPTO_WEBAPP_ICON=Bitcoin # WebApp payment button icon for Platega crypto
PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Platega crypto (Russian)
PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Platega crypto (English)
PAYMENT_PLATEGA_CRYPTO_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Platega crypto
PAYMENT_SEVERPAY_WEBAPP_LABEL_RU= # WebApp payment button text for SeverPay (Russian)
PAYMENT_SEVERPAY_WEBAPP_LABEL_EN= # WebApp payment button text for SeverPay (English)
PAYMENT_SEVERPAY_WEBAPP_ICON=CreditCard # WebApp payment button icon for SeverPay
PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU= # Telegram bot payment button text for SeverPay (Russian)
PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN= # Telegram bot payment button text for SeverPay (English)
PAYMENT_SEVERPAY_TELEGRAM_EMOJI= # Telegram bot payment button emoji for SeverPay
PAYMENT_WATA_WEBAPP_LABEL_RU= # WebApp payment button text for Wata (Russian)
PAYMENT_WATA_WEBAPP_LABEL_EN= # WebApp payment button text for Wata (English)
PAYMENT_WATA_WEBAPP_ICON=WalletCards # WebApp payment button icon for Wata
PAYMENT_WATA_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Wata (Russian)
PAYMENT_WATA_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Wata (English)
PAYMENT_WATA_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Wata
PAYMENT_STARS_WEBAPP_LABEL_RU= # WebApp payment button text for Telegram Stars (Russian)
PAYMENT_STARS_WEBAPP_LABEL_EN= # WebApp payment button text for Telegram Stars (English)
PAYMENT_STARS_WEBAPP_ICON=Sparkles # WebApp payment button icon for Telegram Stars
PAYMENT_STARS_TELEGRAM_LABEL_RU= # Telegram bot payment button text for Telegram Stars (Russian)
PAYMENT_STARS_TELEGRAM_LABEL_EN= # Telegram bot payment button text for Telegram Stars (English)
PAYMENT_STARS_TELEGRAM_EMOJI= # Telegram bot payment button emoji for Telegram Stars
PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU= # WebApp payment button text for CryptoPay (Russian)
PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN= # WebApp payment button text for CryptoPay (English)
PAYMENT_CRYPTOPAY_WEBAPP_ICON=Bitcoin # WebApp payment button icon for CryptoPay
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU= # Telegram bot payment button text for CryptoPay (Russian)
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN= # Telegram bot payment button text for CryptoPay (English)
PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI= # Telegram bot payment button emoji for CryptoPay
# YooKassa Payment Gateway Configuration
YOOKASSA_SHOP_ID=your_shop_id # Your store ID in YooKassa
YOOKASSA_SECRET_KEY=your_secret_key # Your secret key for YooKassa
+15 -71
View File
@@ -2,19 +2,13 @@ from aiogram import Bot
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.freekassa_service import FreeKassaService
from bot.payment_providers import ServiceFactoryContext, build_provider_services
from bot.services.lknpd_service import LknpdService
from bot.services.panel_api_service import PanelApiService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.platega_service import PlategaService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.stars_service import StarsService
from bot.services.subscription_service import SubscriptionService
from bot.services.wata_service import WataService
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
@@ -29,61 +23,19 @@ def build_core_services(
subscription_service = SubscriptionService(settings, panel_service, bot, i18n)
referral_service = ReferralService(settings, subscription_service, bot, i18n)
promo_code_service = PromoCodeService(settings, subscription_service, bot, i18n)
stars_service = StarsService(bot, settings, i18n, subscription_service, referral_service)
cryptopay_service = CryptoPayService(
settings.CRYPTOPAY_TOKEN,
settings.CRYPTOPAY_NETWORK,
bot,
settings,
i18n,
async_session_factory,
subscription_service,
referral_service,
)
freekassa_service = FreeKassaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
)
platega_service = PlategaService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
severpay_service = SeverPayService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
wata_service = WataService(
bot=bot,
settings=settings,
i18n=i18n,
async_session_factory=async_session_factory,
subscription_service=subscription_service,
referral_service=referral_service,
default_return_url=bot_username_for_default_return,
)
panel_webhook_service = PanelWebhookService(
bot, settings, i18n, async_session_factory, panel_service
)
yookassa_service = YooKassaService(
shop_id=settings.YOOKASSA_SHOP_ID,
secret_key=settings.YOOKASSA_SECRET_KEY,
configured_return_url=settings.YOOKASSA_RETURN_URL,
payment_services = build_provider_services(
ServiceFactoryContext(
settings=settings,
bot=bot,
async_session_factory=async_session_factory,
i18n=i18n,
bot_username_for_default_return=bot_username_for_default_return,
settings_obj=settings,
subscription_service=subscription_service,
referral_service=referral_service,
)
)
lknpd_service = LknpdService(
settings.LKNPD_INN,
@@ -91,25 +43,17 @@ def build_core_services(
api_url=settings.LKNPD_API_URL,
)
# Wire services that depend on each other. These attachments are critical
# for auto-renew (subscription_service.yookassa_service) and for the panel
# webhook handler's 24h pre-expiry renewal trigger; do NOT swallow errors —
# silent wiring failures previously caused auto-renew to disappear.
subscription_service.yookassa_service = yookassa_service
# These attachments are critical for auto-renew and panel pre-expiry hooks.
subscription_service.yookassa_service = payment_services.get("yookassa_service")
panel_webhook_service.subscription_service = subscription_service
return {
services = {
"panel_service": panel_service,
"subscription_service": subscription_service,
"referral_service": referral_service,
"promo_code_service": promo_code_service,
"stars_service": stars_service,
"cryptopay_service": cryptopay_service,
"freekassa_service": freekassa_service,
"panel_webhook_service": panel_webhook_service,
"yookassa_service": yookassa_service,
"lknpd_service": lknpd_service,
"platega_service": platega_service,
"severpay_service": severpay_service,
"wata_service": wata_service,
}
services.update(payment_services)
return services
+64 -1
View File
@@ -15,7 +15,7 @@ from typing import Any, List, Optional, Tuple
@dataclass(frozen=True)
class SettingField:
key: str
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret"
type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "icon"
section: str
label: str
description: str = ""
@@ -30,6 +30,61 @@ class SettingField:
i18n_description_key: Optional[str] = None
def _payment_presentation_fields(method_key: str, subsection: str) -> List[SettingField]:
prefix = f"PAYMENT_{method_key}"
return [
SettingField(
f"{prefix}_WEBAPP_LABEL_RU",
"string",
"payments",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
subsection=subsection,
),
SettingField(
f"{prefix}_WEBAPP_LABEL_EN",
"string",
"payments",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
subsection=subsection,
),
SettingField(
f"{prefix}_WEBAPP_ICON",
"icon",
"payments",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
subsection=subsection,
),
SettingField(
f"{prefix}_TELEGRAM_LABEL_RU",
"string",
"payments",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
subsection=subsection,
),
SettingField(
f"{prefix}_TELEGRAM_LABEL_EN",
"string",
"payments",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
subsection=subsection,
),
SettingField(
f"{prefix}_TELEGRAM_EMOJI",
"string",
"payments",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
subsection=subsection,
placeholder="💳",
),
]
SETTINGS_MANIFEST: List[SettingField] = [
# ─── General ────────────────────────────────────────────────────
SettingField(
@@ -136,6 +191,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
# ─── Payment providers (toggles) ───────────────────────────────
# Common
SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"),
*_payment_presentation_fields("STARS", "Telegram Stars"),
SettingField(
"PAYMENT_METHODS_ORDER",
"string",
@@ -187,6 +243,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Принудительная привязка карты",
subsection="YooKassa",
),
*_payment_presentation_fields("YOOKASSA", "YooKassa"),
# FreeKassa
SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"),
SettingField(
@@ -243,6 +300,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Через запятую — IP-адреса, с которых принимаются нотификации",
subsection="FreeKassa",
),
*_payment_presentation_fields("FREEKASSA", "FreeKassa"),
# Platega
SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"),
SettingField(
@@ -270,6 +328,8 @@ SETTINGS_MANIFEST: List[SettingField] = [
),
SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"),
SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"),
*_payment_presentation_fields("PLATEGA_SBP", "Platega SBP"),
*_payment_presentation_fields("PLATEGA_CRYPTO", "Platega Crypto"),
# SeverPay
SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"),
SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"),
@@ -295,6 +355,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
min=30,
max=4320,
),
*_payment_presentation_fields("SEVERPAY", "SeverPay"),
# Wata
SettingField("WATA_ENABLED", "bool", "payments", "Enabled", subsection="Wata"),
SettingField(
@@ -349,6 +410,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
"Comma-separated IP addresses accepted for Wata webhooks.",
subsection="Wata",
),
*_payment_presentation_fields("WATA", "Wata"),
# CryptoPay
SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"),
SettingField(
@@ -373,6 +435,7 @@ SETTINGS_MANIFEST: List[SettingField] = [
SettingField(
"CRYPTOPAY_ASSET", "string", "payments", "Asset", placeholder="RUB", subsection="CryptoPay"
),
*_payment_presentation_fields("CRYPTOPAY", "CryptoPay"),
# ─── Trial ─────────────────────────────────────────────────────
SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"),
SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0),
+19 -46
View File
@@ -7,6 +7,7 @@ from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_applicati
from aiohttp import web
from sqlalchemy.orm import sessionmaker
from bot.payment_providers import iter_provider_specs, iter_service_keys
from config.settings import Settings
@@ -29,20 +30,15 @@ def _inject_shared_instances(
app["settings"] = settings
app["async_session_factory"] = async_session_factory
app["i18n"] = dp.get("i18n_instance")
for key in (
"yookassa_service",
"lknpd_service",
shared_keys = [
"subscription_service",
"referral_service",
"panel_service",
"stars_service",
"freekassa_service",
"cryptopay_service",
"panel_webhook_service",
"platega_service",
"severpay_service",
"wata_service",
):
"lknpd_service",
*iter_service_keys(),
]
for key in shared_keys:
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore
app[key] = dp.workflow_data[key] # type: ignore
@@ -91,44 +87,21 @@ async def build_and_start_web_app(
f"Telegram webhook route configured at: [POST] {telegram_webhook_path} (relative to base URL)" # noqa: E501
)
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.crypto_pay_service import cryptopay_webhook_route
from bot.services.freekassa_service import freekassa_webhook_route
from bot.services.panel_webhook_service import panel_webhook_route
from bot.services.platega_service import platega_webhook_route
from bot.services.severpay_service import severpay_webhook_route
from bot.services.wata_service import wata_webhook_route
cp_path = settings.cryptopay_webhook_path
if cp_path.startswith("/"):
app.router.add_post(cp_path, cryptopay_webhook_route)
logging.info(f"CryptoPay webhook route configured at: [POST] {cp_path}")
fk_path = settings.freekassa_webhook_path
if fk_path.startswith("/"):
app.router.add_post(fk_path, freekassa_webhook_route)
logging.info(f"FreeKassa webhook route configured at: [POST] {fk_path}")
pg_path = settings.platega_webhook_path
if pg_path.startswith("/"):
app.router.add_post(pg_path, platega_webhook_route)
logging.info(f"Platega webhook route configured at: [POST] {pg_path}")
sp_path = settings.severpay_webhook_path
if sp_path.startswith("/"):
app.router.add_post(sp_path, severpay_webhook_route)
logging.info(f"SeverPay webhook route configured at: [POST] {sp_path}")
wata_path = settings.wata_webhook_path
if wata_path.startswith("/"):
app.router.add_post(wata_path, wata_webhook_route)
logging.info(f"Wata webhook route configured at: [POST] {wata_path}")
# YooKassa webhook (register only when base URL present and path configured)
yk_path = settings.yookassa_webhook_path
if settings.WEBHOOK_BASE_URL and yk_path and yk_path.startswith("/"):
app.router.add_post(yk_path, yookassa_webhook_route)
logging.info(f"YooKassa webhook route configured at: [POST] {yk_path}")
registered_webhook_paths: set[str] = set()
for spec in iter_provider_specs():
webhook_route = spec.load_webhook_route()
if not spec.webhook_path or not webhook_route:
continue
if spec.webhook_requires_base_url and not settings.WEBHOOK_BASE_URL:
continue
path = spec.webhook_path(settings)
if not path or not path.startswith("/") or path in registered_webhook_paths:
continue
registered_webhook_paths.add(path)
app.router.add_post(path, webhook_route)
logging.info("%s webhook route configured at: [POST] %s", spec.label, path)
panel_path = settings.panel_webhook_path
if panel_path.startswith("/"):
-7
View File
@@ -21,7 +21,6 @@ from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import parse_qsl, quote, urlencode, urlsplit, urlunsplit
from aiogram import Bot, Dispatcher
from aiogram.types import LabeledPrice
from aiohttp import ClientSession, ClientTimeout, web
from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, field_validator
from sqlalchemy.ext.asyncio import AsyncSession
@@ -43,17 +42,11 @@ from bot.app.web.webapp_auth import (
verify_webapp_session_token,
)
from bot.infra.redis import cache_get_json, cache_set_json, get_redis, redis_key
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.email_auth_service import EmailAuthService, normalize_email
from bot.services.email_templates import render_account_merged
from bot.services.freekassa_service import FreeKassaService
from bot.services.platega_service import PlategaService
from bot.services.promo_code_service import PromoCodeService
from bot.services.referral_service import ReferralService
from bot.services.severpay_service import SeverPayService
from bot.services.subscription_service import SubscriptionService
from bot.services.wata_service import WataService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import parse_ip_entries, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, sanitize_username
+3 -6
View File
@@ -38,17 +38,14 @@ def create_subscription_webapp_application(
app.on_startup.append(_startup)
app.on_shutdown.append(_shutdown)
from bot.payment_providers import iter_service_keys
for key in (
"subscription_service",
"yookassa_service",
"freekassa_service",
"cryptopay_service",
"platega_service",
"severpay_service",
"wata_service",
"promo_code_service",
"referral_service",
"panel_service",
*iter_service_keys(),
):
if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined]
app[key] = dp.workflow_data[key] # type: ignore[index]
+12 -610
View File
@@ -651,625 +651,27 @@ async def _create_subscription_payment(
else _payment_description(int(months), lang)
)
if method == "yookassa":
if not settings.YOOKASSA_ENABLED:
from bot.payment_providers import WebAppPaymentContext, get_provider_spec
provider_spec = get_provider_spec(method)
if provider_spec and provider_spec.create_webapp_payment:
if not provider_spec.is_enabled(settings):
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_yookassa_payment(
request,
session,
user_id,
months,
price,
description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
if method == "freekassa":
if not settings.FREEKASSA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_freekassa_payment(
request,
session,
user_id,
months,
price,
description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
if method in ("platega", "platega_sbp", "platega_crypto"):
if not settings.PLATEGA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if method == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if method == "platega_crypto" and not settings.PLATEGA_CRYPTO_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_platega_payment(
request,
session,
user_id,
months,
price,
description,
variant=method,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
if method == "severpay":
if not settings.SEVERPAY_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_severpay_payment(
request,
session,
user_id,
months,
price,
description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
if method == "wata":
if not settings.WATA_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_wata_payment(
request,
session,
user_id,
months,
price,
description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
if method == "cryptopay":
service: CryptoPayService = request.app["cryptopay_service"]
if not settings.CRYPTOPAY_ENABLED or not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
url = await service.create_invoice(
return await provider_spec.create_webapp_payment(
WebAppPaymentContext(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
amount=price,
price=price,
stars_price=stars_price,
description=description,
sale_mode=sale_mode,
url_kind="web",
)
if not url:
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{"ok": True, "action": "open_link", "payment_url": url, "payment_id": None}
)
if method == "stars":
if not settings.STARS_ENABLED or stars_price is None:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
return await _create_stars_payment(
request,
session,
user_id,
months,
int(stars_price),
description,
sale_mode=sale_mode,
traffic_gb=traffic_gb,
)
)
return _json_error(400, "payment_unavailable", "Payment method unavailable")
async def _create_base_payment_record(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
status: str,
description: str,
months: int,
provider: str,
sale_mode: Optional[str] = None,
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": amount,
"currency": currency,
"status": status,
"description": description,
"subscription_duration_months": months,
"provider": provider,
"sale_mode": sale_mode,
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
},
)
await session.commit()
return payment
async def _create_yookassa_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: Any,
price: float,
description: str,
*,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
service: YooKassaService = request.app["yookassa_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency="RUB",
status="pending_yookassa",
description=description,
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
provider="yookassa",
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
)
metadata = {
"user_id": str(user_id),
"subscription_months": str(
int(float(months)) if not traffic_sale and not hwid_devices_sale else 0
),
"payment_db_id": str(payment.payment_id),
"sale_mode": sale_mode,
"source": "webapp",
}
if traffic_sale:
metadata["traffic_gb"] = _format_number_for_payload(traffic_gb or months)
if hwid_devices_sale:
metadata["hwid_devices"] = str(int(float(months)))
if _sale_mode_tariff_key(sale_mode):
metadata["tariff_key"] = _sale_mode_tariff_key(sale_mode)
response = await service.create_payment(
amount=price,
currency="RUB",
description=description,
metadata=metadata,
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=bool(
settings.yookassa_autopayments_active
and settings.YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING
),
)
payment_url = response.get("confirmation_url") if response else None
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
await payment_dal.update_payment_status_by_db_id(
session,
payment.payment_id,
response.get("status", "pending"),
yk_payment_id=response.get("id"),
)
await session.commit()
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception:
await session.rollback()
logger.exception("YooKassa WebApp payment failed")
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_freekassa_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: Any,
price: float,
description: str,
*,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> web.Response:
request.app["settings"]
service: FreeKassaService = request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=service.default_currency,
status="pending_freekassa",
description=description,
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
provider="freekassa",
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
)
success, response_data = await service.create_order(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=service.default_currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
)
payment_url = response_data.get("location") if success else None
provider_id = response_data.get("orderHash") or response_data.get("orderId")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session, payment.payment_id, str(provider_id), payment.status
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception:
await session.rollback()
logger.exception("FreeKassa WebApp payment failed")
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_platega_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: Any,
price: float,
description: str,
variant: str = "platega_sbp",
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
service: PlategaService = request.app["platega_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
if variant == "platega_crypto":
if not settings.PLATEGA_CRYPTO_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
else:
if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
platega_method_id = settings.platega_sbp_method_resolved
try:
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
description=description,
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
provider="platega",
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
)
months_for_provider = (
int(float(months)) if not traffic_sale else int(float(traffic_gb or months))
)
payload = json.dumps(
{
"payment_db_id": payment.payment_id,
"user_id": user_id,
"months": months_for_provider if not traffic_sale else 0,
"sale_mode": sale_mode,
"traffic_gb": _format_number_for_payload(traffic_gb or months)
if traffic_sale
else None,
"hwid_devices": int(float(months)) if hwid_devices_sale else None,
"source": "webapp",
"platega_variant": "crypto" if variant == "platega_crypto" else "sbp",
}
)
success, response_data = await service.create_transaction(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months_for_provider,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
payload=payload,
payment_method=platega_method_id,
)
payment_url = (
(
response_data.get("redirect")
or response_data.get("url")
or response_data.get("paymentUrl")
)
if success
else None
)
provider_id = response_data.get("transactionId") or response_data.get("id")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_id),
str(response_data.get("status", payment.status)),
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception:
await session.rollback()
logger.exception("Platega WebApp payment failed")
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_severpay_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: Any,
price: float,
description: str,
*,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
service: SeverPayService = request.app["severpay_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_severpay",
description=description,
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
provider="severpay",
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
)
success, response_data = await service.create_payment(
payment_db_id=payment.payment_id,
user_id=user_id,
months=months,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
)
payment_url = (
(
response_data.get("url")
or response_data.get("payment_url")
or response_data.get("paymentUrl")
)
if success
else None
)
provider_id = response_data.get("id") or response_data.get("uid")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session, payment.payment_id, str(provider_id), payment.status
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session, payment.payment_id, "failed_creation"
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception:
await session.rollback()
logger.exception("SeverPay WebApp payment failed")
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_wata_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: Any,
price: float,
description: str,
*,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> web.Response:
settings: Settings = request.app["settings"]
service: WataService = request.app["wata_service"]
if not service or not service.configured:
return _json_error(400, "payment_unavailable", "Payment method unavailable")
try:
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_wata",
description=description,
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
provider="wata",
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
)
success, response_data = await service.create_payment_link(
payment_db_id=payment.payment_id,
amount=price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=description,
)
payment_url = response_data.get("url") if success else None
provider_id = response_data.get("id")
if provider_id:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_id),
payment.status,
)
await session.commit()
if not payment_url:
await payment_dal.update_payment_status_by_db_id(
session,
payment.payment_id,
"failed_creation",
)
await session.commit()
return _json_error(502, "payment_failed", "Failed to create payment")
return web.json_response(
{
"ok": True,
"action": "open_link",
"payment_url": payment_url,
"payment_id": payment.payment_id,
}
)
except Exception:
await session.rollback()
logger.exception("Wata WebApp payment failed")
return _json_error(502, "payment_failed", "Failed to create payment")
async def _create_stars_payment(
request: web.Request,
session: AsyncSession,
user_id: int,
months: Any,
stars_price: int,
description: str,
sale_mode: str = "subscription",
traffic_gb: Optional[float] = None,
) -> web.Response:
bot: Bot = request.app["bot"]
try:
traffic_sale = _sale_mode_is_traffic(sale_mode)
hwid_devices_sale = _sale_mode_is_hwid_devices(sale_mode)
payment = await _create_base_payment_record(
session,
user_id=user_id,
amount=float(stars_price),
currency="XTR",
status="pending_stars",
description=description,
months=int(float(months)) if not traffic_sale else int(float(traffic_gb or months)),
provider="telegram_stars",
sale_mode=sale_mode,
tariff_key=_sale_mode_tariff_key(sale_mode),
purchased_gb=float(traffic_gb or months) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
)
payload_units = traffic_gb if traffic_sale and traffic_gb is not None else months
payload = f"{payment.payment_id}:{_format_number_for_payload(payload_units)}:{sale_mode}"
prices = [LabeledPrice(label=description, amount=stars_price)]
create_invoice_link = getattr(bot, "create_invoice_link", None)
if callable(create_invoice_link):
invoice_url = await create_invoice_link(
title=description,
description=description,
payload=payload,
# Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "open_invoice",
"payment_url": invoice_url,
"payment_id": payment.payment_id,
}
)
await bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "invoice_sent",
"payment_id": payment.payment_id,
}
)
except Exception:
await session.rollback()
logger.exception("Stars WebApp payment failed")
return _json_error(502, "payment_failed", "Failed to create invoice")
+14 -57
View File
@@ -97,7 +97,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A
traffic_packages=cached["traffic_packages"],
stars_traffic_packages=cached["stars_traffic_packages"],
),
"payment_methods": _serialize_payment_methods(settings, request.app),
"payment_methods": _serialize_payment_methods(settings, request.app, lang),
"themes_catalog": public_themes_catalog_payload(
settings.webapp_themes_catalog,
settings.WEBAPP_PRIMARY_COLOR or "#00fe7a",
@@ -566,66 +566,23 @@ def _serialize_tariff_change_target(
def _serialize_payment_methods(
settings: Settings,
app: web.Application,
lang: str = "ru",
) -> List[Dict[str, Any]]:
labels = {
"wata": "Wata",
"severpay": "SeverPay",
"freekassa": "FreeKassa / СБП",
"platega_sbp": "Platega · СБП",
"platega_crypto": "Platega · Crypto",
"yookassa": "Банковская карта",
"stars": "Telegram Stars",
"cryptopay": "CryptoPay",
}
from bot.payment_providers import get_provider_spec, resolve_provider_presentation
methods: List[Dict[str, Any]] = []
for method in settings.payment_methods_order:
method = method.lower()
if (
method == "severpay"
and settings.SEVERPAY_ENABLED
and _service_configured(app, "severpay_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "freekassa"
and settings.FREEKASSA_ENABLED
and _service_configured(app, "freekassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_sbp"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_SBP_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "platega_crypto"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_CRYPTO_ENABLED
and _service_configured(app, "platega_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "wata"
and settings.WATA_ENABLED
and _service_configured(app, "wata_service")
):
methods.append({"id": method, "name": labels[method]})
elif (
method == "yookassa"
and settings.YOOKASSA_ENABLED
and _service_configured(app, "yookassa_service")
):
methods.append({"id": method, "name": labels[method]})
elif method == "stars" and settings.STARS_ENABLED:
methods.append({"id": method, "name": labels[method]})
elif (
method == "cryptopay"
and settings.CRYPTOPAY_ENABLED
and _service_configured(app, "cryptopay_service")
):
methods.append({"id": method, "name": labels[method]})
spec = get_provider_spec(method)
if spec and spec.is_visible(settings, app):
presentation = resolve_provider_presentation(spec, settings, language=lang)
methods.append(
{
"id": method,
"name": presentation.webapp_label,
"icon": presentation.webapp_icon,
}
)
return methods
+6 -19
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.admin_keyboards import get_back_to_admin_panel_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses, provider_label_map
from config.settings import Settings
from db.dal import payment_dal
from db.models import Payment
@@ -38,19 +39,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
"""Format single payment info as text."""
_ = lambda key, **kwargs: i18n.gettext(lang, key, **kwargs)
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_wata",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ("" if payment.status in pending_statuses else "")
else ("" if payment.status in pending_statuses() else "")
)
user_info = f"User {payment.user_id}"
@@ -61,15 +53,10 @@ def format_payment_text(payment: Payment, i18n: JsonI18n, lang: str, settings: S
payment_date = payment.created_at.strftime("%Y-%m-%d %H:%M") if payment.created_at else "N/A"
provider_text = {
"yookassa": "YooKassa",
"telegram_stars": "Telegram Stars",
"cryptopay": "CryptoPay",
"freekassa": "FreeKassa",
"severpay": "SeverPay",
"platega": "Platega",
"wata": "Wata",
}.get(payment.provider, payment.provider or "Unknown")
provider_text = provider_label_map(settings, lang).get(
payment.provider,
payment.provider or "Unknown",
)
sale_base = (payment.sale_mode or "").split("@", 1)[0].split("|", 1)[0]
traffic_like = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
+2 -10
View File
@@ -10,6 +10,7 @@ from bot.keyboards.inline.admin_keyboards import (
get_back_to_user_management_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.payment_providers import pending_statuses
from bot.services.panel_api_service import PanelApiService
from config.settings import Settings
from db.dal import panel_sync_dal, payment_dal, user_dal
@@ -209,20 +210,11 @@ async def show_statistics_handler(
if last_payments_models:
stats_text_parts.append(f"\n<b>{_('admin_stats_recent_payments_header')}</b>")
for payment in last_payments_models:
pending_statuses = [
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_wata",
"pending_cryptopay",
]
status_emoji = (
""
if payment.status == "succeeded"
else ""
if payment.status in pending_statuses
if payment.status in pending_statuses()
else ""
)
-800
View File
@@ -1,800 +0,0 @@
import asyncio
import json
import logging
from datetime import datetime, timezone
from typing import Optional
from aiogram import Bot
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from yookassa.domain.notification import WebhookNotification
from bot.infra.webhook_queue import enqueue_webhook_event
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.lknpd_service import LknpdService
from bot.services.notification_service import NotificationService
from bot.services.panel_api_service import PanelApiService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal, user_dal
payment_processing_lock = asyncio.Lock()
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded"
YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled"
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture"
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
"185.71.76.0/27",
"185.71.77.0/27",
"77.75.153.0/25",
"77.75.156.11",
"77.75.156.35",
"77.75.154.128/25",
"2a02:5180::/32",
]
async def process_successful_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
lknpd_service: Optional[LknpdService] = None,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
subscription_months_str = metadata.get("subscription_months")
traffic_gb_str = metadata.get("traffic_gb")
sale_mode = metadata.get("sale_mode") or (
"traffic" if settings.traffic_sale_mode else "subscription"
)
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
promo_code_id_str = metadata.get("promo_code_id")
payment_db_id_str = metadata.get("payment_db_id")
auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id")
# For auto-renew payments, payment_db_id may be absent. In that case,
# we will create/ensure a payment record idempotently using provider payment id.
if (
not user_id_str
or (not subscription_months_str and not traffic_gb_str)
or (not payment_db_id_str and not auto_renew_subscription_id_str)
):
logging.error(
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501
)
return
db_user = None
try:
user_id = int(user_id_str)
subscription_months = float(subscription_months_str or 0)
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
payment_db_id = (
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
)
is_auto_renew = bool(
auto_renew_subscription_id_str
and not payment_db_id
and sale_mode_base == "subscription"
)
promo_code_id = (
int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None
)
amount_data = payment_info_from_webhook.get("amount", {})
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
payment_value = float(amount_data.get("value", 0.0))
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_record = None
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
if payment_db_id is None and auto_renew_subscription_id_str:
try:
if not yk_payment_id_from_hook:
logging.error(
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501
)
return
from db.dal import payment_dal as _payment_dal
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
session, yk_payment_id_from_hook
)
if not payment_record:
payment_record = await _payment_dal.ensure_payment_with_provider_id(
session,
user_id=user_id,
amount=payment_value,
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
months=months_for_record or 1,
description=payment_info_from_webhook.get("description")
or f"Auto-renewal for {months_for_record or subscription_months} months",
provider="yookassa",
provider_payment_id=yk_payment_id_from_hook,
)
payment_db_id = payment_record.payment_id
except Exception as e_ensure:
logging.error(
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501
exc_info=True,
)
return
elif payment_db_id is not None:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
)
return
if payment_record and payment_record.status == "succeeded":
logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})." # noqa: E501
)
return
db_user = await user_dal.get_user_by_id(session, user_id)
if not db_user:
logging.error(
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501
)
await payment_dal.update_payment_status_by_db_id(
session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id")
)
return
except (TypeError, ValueError) as e:
logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}")
if payment_db_id_str and payment_db_id_str.isdigit():
try:
await payment_dal.update_payment_status_by_db_id(
session,
int(payment_db_id_str),
"failed_metadata_error",
payment_info_from_webhook.get("id"),
)
except Exception as e_upd:
logging.error(f"Failed to update payment status after metadata error: {e_upd}")
return
try:
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
payment_before_update = None
if payment_db_id is not None:
payment_before_update = await payment_dal.get_payment_by_db_id(
session,
payment_db_id,
)
should_send_lknpd_receipt = bool(
lknpd_service
and lknpd_service.configured
and payment_info_from_webhook.get("paid") is True
and payment_info_from_webhook.get("status") == "succeeded"
and payment_before_update
and payment_before_update.status != "succeeded"
)
# Try to capture and save payment method for future charges if available
try:
payment_method = payment_info_from_webhook.get("payment_method")
if (
settings.yookassa_autopayments_active
and isinstance(payment_method, dict)
and payment_method.get("saved", False)
):
pm_id = payment_method.get("id")
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get("account_number") or payment_method.get(
"account"
)
display_network = None
display_last4 = None
# Build generic display for various instrument types
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
# Normalize wallet display name to avoid leaking full account from title
display_network = "YooMoney"
if isinstance(account_number, str) and len(account_number) >= 4:
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
# Wallets, SBP, etc. — use provided title/type; no last4
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm_id,
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm_id,
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
except Exception:
logging.exception("Failed to persist multi-card YooKassa method from webhook")
except Exception:
logging.exception("Failed to persist YooKassa payment method from webhook")
months_for_activation = (
int(subscription_months) if sale_mode_base == "subscription" else int(traffic_amount_gb)
)
activation_details = await subscription_service.activate_subscription(
session,
user_id,
months_for_activation,
payment_value,
payment_db_id,
promo_code_id_from_payment=promo_code_id,
provider="yookassa",
sale_mode=sale_mode,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
)
raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "succeeded"),
yk_payment_id=yk_payment_id_from_hook,
)
if not updated_payment_record:
logging.error(
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501
)
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
base_subscription_end_date = activation_details["end_date"]
final_end_date_for_user = base_subscription_end_date
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
referral_bonus_info = None
if sale_mode_base == "subscription":
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
months_for_activation or int(subscription_months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
applied_referee_bonus_days_from_referral: Optional[int] = None
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
final_end_date_for_user = referral_bonus_info["referee_new_end_date"]
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
"referee_bonus_applied_days"
)
# Use user's DB language for all user-facing messages
user_lang = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
traffic_label = (
str(int(traffic_amount_gb))
if float(traffic_amount_gb).is_integer()
else f"{traffic_amount_gb:g}"
)
if should_send_lknpd_receipt:
receipt_item_name = payment_info_from_webhook.get("description")
if not receipt_item_name:
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
else:
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
months=int(subscription_months)
)
try:
await lknpd_service.create_income_receipt(
item_name=receipt_item_name,
amount=payment_value,
quantity=1.0,
operation_time=datetime.now(timezone.utc),
)
except Exception:
logging.exception(
"Failed to send LKNPD receipt for payment %s",
yk_payment_id_from_hook,
)
config_link_display, connect_button_url = await prepare_config_links(
settings, activation_details.get("subscription_url") if activation_details else None
)
config_link_text = config_link_display or _("config_link_not_available")
# For auto-renew charges, avoid re-sending config link; send concise message
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
details_message = _(
"yookassa_auto_renewal",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
)
details_markup = None
elif sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
details_message = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end_date_for_user.strftime("%Y-%m-%d")
if final_end_date_for_user
else "",
config_link=config_link_text,
)
details_markup = get_connect_and_main_keyboard(
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
else:
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
details_message = _(
"payment_successful_with_referral_bonus_full",
months=int(subscription_months),
base_end_date=base_subscription_end_date.strftime("%Y-%m-%d"),
bonus_days=applied_referee_bonus_days_from_referral,
final_end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_bonus_days > 0 and final_end_date_for_user:
details_message = _(
"payment_successful_with_promo_full",
months=int(subscription_months),
bonus_days=applied_promo_bonus_days,
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
elif final_end_date_for_user:
details_message = _(
"payment_successful_full",
months=int(subscription_months),
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
logging.error(
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
)
details_message = _("payment_successful_error_details")
details_markup = get_connect_and_main_keyboard(
user_lang,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await bot.send_message(
user_id,
details_message,
reply_markup=details_markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_notify:
logging.error(f"Failed to send payment details message to user {user_id}: {e_notify}")
# Send notification about payment
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
tariff_for_log = None
if payment_before_update and getattr(payment_before_update, "tariff_key", None):
tariff_for_log = payment_before_update.tariff_key
elif updated_payment_record and getattr(updated_payment_record, "tariff_key", None):
tariff_for_log = updated_payment_record.tariff_key
elif payment_record and getattr(payment_record, "tariff_key", None):
tariff_for_log = payment_record.tariff_key
await notification_service.notify_payment_received(
user_id=user_id,
amount=payment_value,
currency=settings.DEFAULT_CURRENCY_SYMBOL,
months=int(subscription_months) if sale_mode_base == "subscription" else 0,
payment_provider="yookassa", # This is specifically for YooKassa webhook
username=user.username if user else None,
traffic_gb=traffic_amount_gb
if sale_mode_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_is_premium=sale_mode_base == "premium_topup",
tariff_key=tariff_for_log,
)
except Exception as e:
logging.error(f"Failed to send payment notification: {e}")
except Exception as e_process:
logging.error(
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501
exc_info=True,
)
raise
async def process_cancelled_payment(
session: AsyncSession,
bot: Bot,
payment_info_from_webhook: dict,
i18n: JsonI18n,
settings: Settings,
):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
payment_db_id_str = metadata.get("payment_db_id")
if not user_id_str or not payment_db_id_str:
logging.warning(
f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
)
return
try:
user_id = int(user_id_str)
payment_db_id = int(payment_db_id_str)
except ValueError:
logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}")
return
try:
updated_payment = await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=payment_db_id,
new_status=payment_info_from_webhook.get("status", "canceled"),
yk_payment_id=payment_info_from_webhook.get("id"),
)
if updated_payment:
logging.info(
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501
)
else:
logging.warning(
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501
)
db_user = await user_dal.get_user_by_id(session, user_id)
user_lang = settings.DEFAULT_LANGUAGE
if db_user and db_user.language_code:
user_lang = db_user.language_code
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
await bot.send_message(user_id, _("payment_failed"))
except Exception as e_process_cancel:
logging.error(
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501
exc_info=True,
)
raise
async def yookassa_webhook_route(request: web.Request):
try:
bot: Bot = request.app["bot"]
i18n_instance: JsonI18n = request.app["i18n"]
settings: Settings = request.app["settings"]
panel_service: PanelApiService = request.app["panel_service"]
subscription_service: SubscriptionService = request.app["subscription_service"]
referral_service: ReferralService = request.app["referral_service"]
lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service")
async_session_factory: sessionmaker = request.app["async_session_factory"]
except KeyError:
logging.exception("KeyError accessing app context in yookassa_webhook_route.")
return web.Response(status=500, text="Internal Server Error: Missing app context component")
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
logging.warning("YooKassa webhook denied from unauthorized IP source.")
return web.Response(status=403)
try:
event_json = await request.json()
notification_object = WebhookNotification(event_json)
payment_data_from_notification = notification_object.object
logging.info(
f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501
)
if (
not payment_data_from_notification
or not hasattr(payment_data_from_notification, "metadata")
or payment_data_from_notification.metadata is None
):
logging.error(
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501
)
return web.Response(status=200, text="ok_error_no_metadata")
# Safely extract payment_method details (SDK objects may not have to_dict)
pm_obj = getattr(payment_data_from_notification, "payment_method", None)
pm_dict = None
if pm_obj is not None:
try:
card_obj = getattr(pm_obj, "card", None)
pm_dict = {
"id": getattr(pm_obj, "id", None),
"type": getattr(pm_obj, "type", None),
"saved": bool(getattr(pm_obj, "saved", False)),
"title": getattr(pm_obj, "title", None),
"account_number": (
getattr(pm_obj, "account_number", None)
if hasattr(pm_obj, "account_number")
else (
getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None
)
),
"card": (
{
"first6": getattr(card_obj, "first6", None),
"last4": getattr(card_obj, "last4", None),
"expiry_month": getattr(card_obj, "expiry_month", None),
"expiry_year": getattr(card_obj, "expiry_year", None),
"card_type": getattr(card_obj, "card_type", None),
}
if card_obj is not None
else None
),
}
except Exception:
logging.exception("Failed to serialize YooKassa payment_method from webhook")
pm_dict = None
payment_dict_for_processing = {
"id": str(payment_data_from_notification.id),
"status": str(payment_data_from_notification.status),
"paid": bool(payment_data_from_notification.paid),
"amount": {
"value": str(payment_data_from_notification.amount.value),
"currency": str(payment_data_from_notification.amount.currency),
}
if payment_data_from_notification.amount
else {},
"metadata": dict(payment_data_from_notification.metadata),
"description": str(payment_data_from_notification.description)
if payment_data_from_notification.description
else None,
"payment_method": pm_dict,
}
if notification_object.event in {
YOOKASSA_EVENT_PAYMENT_SUCCEEDED,
YOOKASSA_EVENT_PAYMENT_CANCELED,
}:
queued = await enqueue_webhook_event(
settings,
"yookassa",
{
"event": notification_object.event,
"payment": payment_dict_for_processing,
},
event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}",
)
if queued:
return web.Response(status=200, text="queued")
async with payment_processing_lock:
async with async_session_factory() as session:
try:
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
if (
payment_dict_for_processing.get("paid")
and payment_dict_for_processing.get("status") == "succeeded"
):
await process_successful_payment(
session,
bot,
payment_dict_for_processing,
i18n_instance,
settings,
panel_service,
subscription_service,
referral_service,
lknpd_service,
)
await session.commit()
else:
logging.warning(
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501
f"paid='{payment_dict_for_processing.get('paid')}'"
)
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
await process_cancelled_payment(
session, bot, payment_dict_for_processing, i18n_instance, settings
)
await session.commit()
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
# Bind-only flow: save method and cancel auth if metadata has bind_only
metadata = payment_dict_for_processing.get("metadata", {}) or {}
if (
settings.yookassa_autopayments_active
and metadata.get("bind_only") == "1"
):
try:
user_id_str = metadata.get("user_id")
if user_id_str and user_id_str.isdigit():
user_id = int(user_id_str)
payment_method = payment_dict_for_processing.get(
"payment_method"
)
if isinstance(payment_method, dict) and payment_method.get(
"id"
):
pm_type = payment_method.get("type")
title = payment_method.get("title")
card = payment_method.get("card") or {}
account_number = payment_method.get(
"account_number"
) or payment_method.get("account")
display_network = None
display_last4 = None
if (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = (
card.get("card_type") or title or "Card"
)
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {
"yoo_money",
"yoomoney",
"yoo-money",
"wallet",
}:
# Normalize wallet display name to avoid leaking full account from title # noqa: E501
display_network = "YooMoney"
if (
isinstance(account_number, str)
and len(account_number) >= 4
):
display_last4 = account_number[-4:]
else:
display_last4 = None
else:
display_network = title or (
pm_type.upper() if pm_type else "Payment method"
)
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=payment_method.get("id"),
card_last4=display_last4,
card_network=display_network,
)
await session.commit()
# Save multi-card entry and mark default if first
try:
from db.dal import user_billing_dal as ub
await ub.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=payment_method.get("id"),
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=True,
)
await session.commit()
except Exception:
await session.rollback()
# Notify user about successful binding with Back button
try:
# Use user's DB language for bind success notification
i18n_lang = settings.DEFAULT_LANGUAGE
from db.dal import user_dal
db_user = await user_dal.get_user_by_id(
session, user_id
)
if db_user and db_user.language_code:
i18n_lang = db_user.language_code
_ = lambda key, **kwargs: i18n_instance.gettext(
i18n_lang, key, **kwargs
)
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_methods_keyboard,
)
await bot.send_message(
chat_id=user_id,
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(
i18n_lang, i18n_instance
),
)
except Exception:
pass
# Attempt to cancel the authorization to avoid charge hold
try:
yk: YooKassaService = request.app.get(
"yookassa_service"
)
if yk:
await yk.cancel_payment(
payment_dict_for_processing.get("id")
)
except Exception:
logging.exception(
"Failed to cancel bind-only payment auth"
)
except Exception:
logging.exception(
"Failed to handle bind-only waiting_for_capture webhook"
)
except Exception:
await session.rollback()
logging.exception(
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501
notification_object.event,
payment_dict_for_processing.get("id"),
)
return web.Response(status=500, text="internal_processing_error")
return web.Response(status=200, text="ok")
except json.JSONDecodeError:
logging.error("YooKassa Webhook: Invalid JSON received.")
return web.Response(status=400, text="bad_request_invalid_json")
except Exception:
logging.exception("YooKassa Webhook general processing error.")
return web.Response(status=500, text="internal_error")
@@ -1,13 +1,12 @@
from aiogram import Router
from . import core, payment_methods, payments
from . import core, payments
router = Router(name="user_subscription_router")
# Include sub-routers
router.include_router(core.router)
router.include_router(payments.router)
router.include_router(payment_methods.router)
# Re-export commonly used entrypoints for backward compatibility
from .core import ( # noqa: E402,F401
@@ -1,550 +0,0 @@
from typing import List, Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from bot.keyboards.inline.user_keyboards import (
get_bind_url_keyboard,
get_payment_method_delete_confirm_keyboard,
get_payment_method_details_keyboard,
get_payment_methods_list_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import user_billing_dal
from db.models import Payment
router = Router(name="user_subscription_payment_methods_router")
@router.callback_query(F.data == "pm:manage")
async def payment_methods_manage(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
get_text = _
methods = await list_user_payment_methods(session, callback.from_user.id)
cards: List[tuple] = []
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
for m in methods:
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data == "pm:bind")
async def payment_method_bind(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
resp = await yookassa_service.create_payment(
amount=1.00,
currency="RUB",
description="Bind card",
metadata=metadata,
receipt_email=settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL,
save_payment_method=True,
capture=False,
bind_only=True,
)
if not resp or not resp.get("confirmation_url"):
await callback.answer(_("error_payment_gateway"), show_alert=True)
return
await callback.message.edit_text(
_("payment_methods_title"),
reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete_confirm"))
async def payment_method_delete_confirm(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else ""
await callback.message.edit_text(
_("payment_method_delete_confirm"),
reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:delete"))
async def payment_method_delete(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
parts = callback.data.split(":", 2)
pm_id_raw = parts[2] if len(parts) >= 3 else ""
deleted = False
try:
from db.dal.user_billing_dal import (
delete_user_payment_method,
delete_user_payment_method_by_provider_id,
list_user_payment_methods,
)
if pm_id_raw:
if pm_id_raw.isdigit():
deleted = await delete_user_payment_method(
session, callback.from_user.id, int(pm_id_raw)
)
else:
deleted = await delete_user_payment_method_by_provider_id(
session, callback.from_user.id, pm_id_raw
)
try:
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
session, callback.from_user.id
)
deleted = deleted or legacy_deleted
except Exception:
pass
await session.commit()
methods = await list_user_payment_methods(session, callback.from_user.id)
text = _("payment_methods_title")
cards = []
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
if not cards:
text += "\n\n" + _("payment_method_none")
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
await callback.message.edit_text(
f"{msg}\n\n{text}",
reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n),
)
try:
await callback.answer()
except Exception:
pass
return
except Exception:
await session.rollback()
try:
await callback.answer(_("error_try_again"), show_alert=True)
except Exception:
pass
@router.callback_query(F.data.startswith("pm:view"))
async def payment_method_view(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
if not billing or not billing.yookassa_payment_method_id:
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
if not methods:
await callback.answer(_("payment_method_none"), show_alert=True)
return
parts = callback.data.split(":", 2)
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
sel = next(
(
m
for m in methods
if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id
),
methods[0],
)
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(sel.card_network, sel.card_last4)
added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else ""
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
lp = result.scalar_one_or_none()
if lp and lp.created_at:
last_tx = lp.created_at.strftime("%Y-%m-%d")
except Exception:
pass
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
str(sel.method_id), current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
pass
return
added_at = (
billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else ""
)
last_tx = ""
try:
stmt = (
select(Payment)
.where(
Payment.user_id == callback.from_user.id,
Payment.status == "succeeded",
Payment.provider == "yookassa",
)
.order_by(Payment.created_at.desc())
.limit(1)
)
result = await session.execute(stmt)
last_payment = result.scalar_one_or_none()
if last_payment and last_payment.created_at:
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
except Exception:
pass
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return _("payment_method_wallet_title", last4=l4)
return _("payment_method_wallet_title", last4="****")
if last4:
network_name = network or _("payment_network_card")
return _("payment_method_card_title", network=network_name, last4=last4)
network_name = network or _("payment_network_generic")
return _("payment_method_generic_title", network=network_name)
title = _format_pm_title(billing.card_network, billing.card_last4)
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
await callback.message.edit_text(
details,
reply_markup=get_payment_method_details_keyboard(
billing.yookassa_payment_method_id, current_lang, i18n
),
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pm:history"))
async def payment_method_history(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
yookassa_service: YooKassaService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not settings.yookassa_autopayments_active:
try:
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
await callback.answer(_("error_service_unavailable"), show_alert=True)
except Exception:
pass
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal import payment_dal
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
selected_pm_provider_id: Optional[str] = None
pm_filter_requested: bool = False
try:
split_a, split_b, split_pm_id = callback.data.split(":", 2)
if split_pm_id:
pm_filter_requested = True
if split_pm_id.isdigit():
from db.dal.user_billing_dal import list_user_payment_methods
methods = await list_user_payment_methods(session, callback.from_user.id)
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
if sel and sel.provider_payment_method_id:
selected_pm_provider_id = sel.provider_payment_method_id
else:
selected_pm_provider_id = split_pm_id
except Exception:
selected_pm_provider_id = None
pm_filter_requested = False
if pm_filter_requested and not selected_pm_provider_id:
user_payments = []
if selected_pm_provider_id:
filtered: List[Payment] = []
for p in user_payments:
if p.provider != "yookassa":
continue
if p.yookassa_payment_id and yookassa_service:
try:
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
pm = (info or {}).get("payment_method") or {}
if pm.get("id") == selected_pm_provider_id:
filtered.append(p)
continue
except Exception:
pass
user_payments = filtered
if not user_payments:
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_pm_id = ""
try:
split_a, split_b, back_pm_id = callback.data.split(":", 2)
except Exception:
back_pm_id = ""
back_markup = (
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
if back_pm_id
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
return
traffic_mode = getattr(settings, "traffic_sale_mode", False)
def _format_item(p: Payment) -> str:
if traffic_mode:
units_val = p.subscription_duration_months or 0
units_display = (
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
)
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
else:
title = p.description or _(
"subscription_purchase_title", months=p.subscription_duration_months or 1
)
date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A"
return f"{date_str}{title}{p.amount:.2f} {p.currency}"
lines = [_format_item(p) for p in user_payments]
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
try:
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
except Exception:
split_pm_id_for_back = ""
from bot.keyboards.inline.user_keyboards import (
get_back_to_payment_method_details_keyboard,
get_payment_methods_manage_keyboard,
)
back_markup = (
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
if split_pm_id_for_back
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
)
await callback.message.edit_text(text, reply_markup=back_markup)
@router.callback_query(F.data.startswith("pm:list:"))
async def payment_methods_list(
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
from db.dal.user_billing_dal import list_user_payment_methods
cards: List[tuple] = []
methods = await list_user_payment_methods(session, callback.from_user.id)
for m in methods:
def _is_yoomoney_network(network: Optional[str]) -> bool:
s = (network or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
if _is_yoomoney_network(network):
l4 = last4 or _extract_last4(network or "")
if l4:
return get_text("payment_method_wallet_title", last4=l4)
return get_text("payment_method_wallet_title", last4="****")
if last4:
network_name = network or get_text("payment_network_card")
return get_text("payment_method_card_title", network=network_name, last4=last4)
network_name = network or get_text("payment_network_generic")
return get_text("payment_method_generic_title", network=network_name)
title = _format_pm_title(m.card_network, m.card_last4)
cards.append((str(m.method_id), title if not m.is_default else f"{title}"))
try:
_, _, page_str = callback.data.split(":", 2)
page = int(page_str)
except Exception:
page = 0
text = get_text("payment_methods_title")
if not cards:
text += "\n\n" + get_text("payment_method_none")
await callback.message.edit_text(
text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)
)
try:
await callback.answer()
except Exception:
pass
@@ -1,23 +1,13 @@
from aiogram import Router
from .payments_crypto import router as crypto_router
from .payments_freekassa import router as freekassa_router
from .payments_platega import router as platega_router
from .payments_severpay import router as severpay_router
from .payments_stars import router as stars_router
from bot.payment_providers import iter_unique_provider_routers
from .payments_subscription import router as subscription_selection_router
from .payments_wata import router as wata_router
from .payments_yookassa import router as yookassa_router
router = Router(name="user_subscription_payments_router")
router.include_router(subscription_selection_router)
router.include_router(yookassa_router)
router.include_router(freekassa_router)
router.include_router(platega_router)
router.include_router(severpay_router)
router.include_router(wata_router)
router.include_router(crypto_router)
router.include_router(stars_router)
for provider_router in iter_unique_provider_routers():
router.include_router(provider_router)
__all__ = ["router"]
@@ -1,128 +0,0 @@
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.crypto_pay_service import CryptoPayService
from config.settings import Settings
router = Router(name="user_subscription_payments_crypto_router")
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if (
not settings.CRYPTOPAY_ENABLED
or not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_amount = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=user_id,
months=months,
amount=price_amount,
description=payment_description,
sale_mode=sale_mode,
)
if invoice_url:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
invoice_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,244 +0,0 @@
import logging
from datetime import datetime
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.freekassa_service import FreeKassaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_freekassa_router")
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_fk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "freekassa",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"FreeKassa: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await freekassa_service.create_order(
payment_db_id=payment_record.payment_id,
user_id=payment_record.user_id,
months=months,
amount=price_rub,
currency=freekassa_service.default_currency,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": freekassa_service.payment_method_id,
},
)
if success:
location = response_data.get("location")
order_hash = response_data.get("orderHash")
order_id_api = response_data.get("orderId")
provider_identifier = order_hash or order_id_api
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"FreeKassa: failed to store provider order id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
if location:
order_identifier_display = str(
order_id_api or provider_identifier or payment_record.payment_id
)
order_info_text = get_text(
"free_kassa_order_info",
order_id=order_identifier_display,
date=datetime.now().strftime("%Y-%m-%d"),
)
try:
await callback.message.edit_text(
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"FreeKassa: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
f"{order_info_text}\n\n"
+ get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
location,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"FreeKassa: create_order succeeded but no payment link returned for payment %s. Response: %s", # noqa: E501
payment_record.payment_id,
response_data,
)
else:
logging.error(
"FreeKassa: create_order failed for payment %s with response %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"FreeKassa: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,261 +0,0 @@
import json
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.platega_service import PlategaService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_platega_router")
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
):
callback_prefix, _, _ = (callback.data or "").partition(":")
if callback_prefix == "pay_platega_crypto":
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
platega_variant = "crypto"
if not settings.PLATEGA_CRYPTO_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
elif callback_prefix == "pay_platega_sbp":
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
if not settings.PLATEGA_SBP_ENABLED:
try:
await callback.answer()
except Exception:
pass
return
else:
# Legacy callback (pre-split): keep working as SBP
platega_method_id = settings.platega_sbp_method_resolved
platega_variant = "sbp"
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_platega data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_platega",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "platega",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"Platega: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
payload_meta = json.dumps(
{
"payment_db_id": payment_record.payment_id,
"user_id": user_id,
"months": months,
"sale_mode": sale_mode,
"platega_variant": platega_variant,
}
)
success, response_data = await platega_service.create_transaction(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
payload=payload_meta,
payment_method=platega_method_id,
)
if success:
transaction_id = response_data.get("transactionId") or response_data.get("id")
redirect_url = (
response_data.get("redirect")
or response_data.get("url")
or response_data.get("paymentUrl")
)
provider_status = response_data.get("status", payment_record.status)
if transaction_id and redirect_url:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(transaction_id),
str(provider_status),
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"Platega: failed to store transaction id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"Platega: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
redirect_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"Platega: transaction created but missing transaction id or payment link for payment %s. Response: %s", # noqa: E501
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"Platega: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,221 +0,0 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.severpay_service import SeverPayService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_severpay_router")
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error(f"Invalid pay_severpay data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_severpay",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "severpay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
f"SeverPay: failed to create payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await severpay_service.create_payment(
payment_db_id=payment_record.payment_id,
user_id=user_id,
months=months,
amount=price_rub,
currency=currency_code,
description=payment_description,
)
if success:
payment_link = (
response_data.get("url")
or response_data.get("payment_url")
or response_data.get("paymentUrl")
)
provider_identifier = response_data.get("id") or response_data.get("uid")
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"SeverPay: failed to store provider payment id for payment {payment_record.payment_id}: {e_status}", # noqa: E501
exc_info=True,
)
if payment_link:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
f"SeverPay: failed to display payment link ({e_edit}), sending new message."
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"SeverPay: payment created but missing payment link for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
f"SeverPay: failed to mark payment {payment_record.payment_id} as failed_creation: {e_status}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,148 +0,0 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.stars_service import StarsService
from config.settings import Settings
router = Router(name="user_subscription_payments_stars_router")
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not settings.STARS_ENABLED:
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
stars_price = int(float(parts[1]))
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_db_id = await stars_service.create_invoice(
session=session,
user_id=user_id,
months=months,
stars_price=stars_price,
description=payment_description,
sale_mode=sale_mode,
)
if payment_db_id:
try:
await callback.message.edit_text(
get_text(
"payment_invoice_sent_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_invoice_sent_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=get_text("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{human_value}",
)
]
]
),
)
except Exception as e_edit:
logging.warning(f"Stars payment: failed to show invoice info message ({e_edit})")
try:
await callback.answer()
except Exception:
pass
return
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
# Nothing else to do here; Telegram will show an error if not answered
pass
@router.message(F.successful_payment)
async def handle_successful_stars_payment(
message: types.Message,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
payload = (
message.successful_payment.invoice_payload if message and message.successful_payment else ""
)
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
months = float(parts[1]) if len(parts) > 1 else 0
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except Exception:
return
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
await stars_service.process_successful_payment(
session=session,
message=message,
payment_db_id=payment_db_id,
months=months,
stars_amount=stars_amount,
i18n_data=i18n_data,
sale_mode=sale_mode,
)
@@ -1,223 +0,0 @@
import logging
from typing import Optional
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.wata_service import WataService
from config.settings import Settings
from db.dal import payment_dal
router = Router(name="user_subscription_payments_wata_router")
@router.callback_query(F.data.startswith("pay_wata:"))
async def pay_wata_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
wata_service: WataService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not wata_service or not wata_service.configured:
logging.error("Wata service is not configured or unavailable.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
logging.error("Invalid pay_wata data in callback: %s", callback.data)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
human_value = str(int(months)) if float(months).is_integer() else f"{months:g}"
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_description = (
get_text("payment_description_traffic", traffic_gb=human_value)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code,
"status": "pending_wata",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "wata",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
try:
payment_record = await payment_dal.create_payment_record(session, payment_record_payload)
await session.commit()
except Exception as e_db_create:
await session.rollback()
logging.error(
"Wata: failed to create payment record for user %s: %s",
user_id,
e_db_create,
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
success, response_data = await wata_service.create_payment_link(
payment_db_id=payment_record.payment_id,
amount=price_rub,
currency=currency_code,
description=payment_description,
)
if success:
payment_link = response_data.get("url")
provider_identifier = response_data.get("id")
if provider_identifier:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(provider_identifier),
payment_record.status,
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
"Wata: failed to store provider payment id for payment %s: %s",
payment_record.payment_id,
e_status,
exc_info=True,
)
if payment_link:
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(
"Wata: failed to display payment link (%s), sending new message.",
e_edit,
)
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base
in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=human_value,
),
reply_markup=get_payment_url_keyboard(
payment_link,
current_lang,
i18n,
back_callback=f"subscribe_period:{human_value}",
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
logging.error(
"Wata: payment link created but missing url for payment %s. Response: %s",
payment_record.payment_id,
response_data,
)
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_record.payment_id,
"failed_creation",
)
await session.commit()
except Exception as e_status:
await session.rollback()
logging.error(
"Wata: failed to mark payment %s as failed_creation: %s",
payment_record.payment_id,
e_status,
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
try:
await callback.answer(get_text("error_payment_gateway"), show_alert=True)
except Exception:
pass
@@ -1,842 +0,0 @@
import logging
from typing import List, Optional, Tuple
from aiogram import F, Router, types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import (
get_back_to_main_menu_markup,
get_payment_url_keyboard,
get_yk_autopay_choice_keyboard,
get_yk_saved_cards_keyboard,
)
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
from db.dal import payment_dal, user_billing_dal
router = Router(name="user_subscription_payments_yookassa_router")
def _format_value(val: float) -> str:
return str(int(val)) if float(val).is_integer() else f"{val:g}"
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
try:
parts = payload.split(":")
value = float(parts[0])
price = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
return value, price, sale_mode
except (ValueError, IndexError):
return None
def _sale_mode_base(sale_mode: str) -> str:
return (sale_mode or "subscription").split("@", 1)[0].split("|", 1)[0]
def _format_saved_payment_method_title(
get_text, network: Optional[str], last4: Optional[str], is_default: bool
) -> str:
def _is_yoomoney_network(name: Optional[str]) -> bool:
s = (name or "").lower()
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
def _extract_last4(text: str) -> Optional[str]:
digits = "".join(ch for ch in text if ch.isdigit())
return digits[-4:] if len(digits) >= 4 else None
if _is_yoomoney_network(network):
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
title = get_text("payment_method_wallet_title", last4=inferred_last4)
elif last4:
network_name = network or get_text("payment_network_card")
title = get_text("payment_method_card_title", network=network_name, last4=last4)
else:
network_name = network or get_text("payment_network_generic")
title = get_text("payment_method_generic_title", network=network_name)
return f"{title}" if is_default else title
async def _initiate_yk_payment(
callback: types.CallbackQuery,
*,
settings: Settings,
session: AsyncSession,
yookassa_service: YooKassaService,
i18n: Optional[JsonI18n],
current_lang: str,
get_text,
user_id: int,
months: int,
price_rub: float,
currency_code_for_yk: str,
save_payment_method: bool,
back_callback: str,
payment_method_id: Optional[str] = None,
selected_method_internal_id: Optional[int] = None,
sale_mode: str = "subscription",
) -> bool:
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
if not callback.message:
return False
sale_base = _sale_mode_base(sale_mode)
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else (
get_text("payment_description_hwid_devices", count=int(months))
if sale_base in {"hwid_device", "hwid_devices"}
else get_text("payment_description_subscription", months=int(months))
)
)
payment_record_data = {
"user_id": user_id,
"amount": price_rub,
"currency": currency_code_for_yk,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"sale_mode": sale_base,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"purchased_hwid_devices": int(months)
if sale_base in {"hwid_device", "hwid_devices"}
else None,
}
db_payment_record = None
try:
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
await session.commit()
logging.info(
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'." # noqa: E501
)
except Exception as e_db_payment:
await session.rollback()
logging.error(
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
if not db_payment_record:
try:
await callback.message.edit_text(get_text("error_creating_payment_record"))
except Exception:
pass
return False
yookassa_metadata = {
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(db_payment_record.payment_id),
"sale_mode": sale_mode,
}
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
yookassa_metadata["traffic_gb"] = str(months)
if payment_method_id:
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
payment_response_yk = await yookassa_service.create_payment(
amount=price_rub,
currency=currency_code_for_yk,
description=payment_description,
metadata=yookassa_metadata,
receipt_email=receipt_email_for_yk,
save_payment_method=save_payment_method,
payment_method_id=payment_method_id,
)
if payment_response_yk and payment_response_yk.get("confirmation_url"):
pm = payment_response_yk.get("payment_method")
try:
if pm and pm.get("id"):
pm_type = pm.get("type")
title = pm.get("title")
card = pm.get("card") or {}
account_number = pm.get("account_number") or pm.get("account")
if isinstance(card, dict) and (pm_type or "").lower() in {
"bank_card",
"bank-card",
"card",
}:
display_network = card.get("card_type") or title or "Card"
display_last4 = card.get("last4")
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
display_network = "YooMoney"
display_last4 = (
account_number[-4:]
if isinstance(account_number, str) and len(account_number) >= 4
else None
)
else:
display_network = title or (pm_type.upper() if pm_type else "Payment method")
display_last4 = None
await user_billing_dal.upsert_yk_payment_method(
session,
user_id=user_id,
payment_method_id=pm["id"],
card_last4=display_last4,
card_network=display_network,
)
try:
await user_billing_dal.upsert_user_payment_method(
session,
user_id=user_id,
provider_payment_method_id=pm["id"],
provider="yookassa",
card_last4=display_last4,
card_network=display_network,
set_default=save_payment_method,
)
except Exception:
pass
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to save YooKassa payment method preliminarily")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=payment_response_yk.get("status", "pending"),
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception(
"Failed to set default payment method after initiating payment"
)
await session.commit()
except Exception as e_db_update_ykid:
await session.rollback()
logging.error(
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
except Exception:
pass
return False
try:
await callback.message.edit_text(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception as e_edit:
logging.warning(f"Edit message for payment link failed: {e_edit}. Sending new one.")
try:
await callback.message.answer(
get_text(
key="payment_link_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_link_message",
months=int(months),
traffic_gb=_format_value(months),
),
reply_markup=get_payment_url_keyboard(
payment_response_yk["confirmation_url"],
current_lang,
i18n,
back_callback=back_callback,
back_text_key="back_to_payment_methods_button",
),
disable_web_page_preview=False,
)
except Exception:
pass
return True
if payment_response_yk and payment_method_id:
status_to_store = payment_response_yk.get("status", "pending")
try:
await payment_dal.update_payment_status_by_db_id(
session,
payment_db_id=db_payment_record.payment_id,
new_status=status_to_store,
yk_payment_id=payment_response_yk.get("id"),
)
if selected_method_internal_id is not None:
try:
await user_billing_dal.set_user_default_payment_method(
session, user_id, selected_method_internal_id
)
except Exception:
logging.exception(
"Failed to set default payment method after saved-card payment start"
)
await session.commit()
except Exception as e_db_update_saved:
await session.rollback()
logging.error(
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}", # noqa: E501
exc_info=True,
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
message_text = get_text("yookassa_autopay_charge_initiated")
try:
await callback.message.edit_text(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception as e_edit:
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
try:
await callback.message.answer(
message_text,
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
)
except Exception:
pass
return True
try:
await payment_dal.update_payment_status_by_db_id(
session, db_payment_record.payment_id, "failed_creation"
)
await session.commit()
except Exception as e_db_fail_create:
await session.rollback()
logging.error(
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}", # noqa: E501
exc_info=True,
)
logging.error(
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}" # noqa: E501
)
try:
await callback.message.edit_text(get_text("error_payment_gateway"))
except Exception:
pass
return False
@router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service is not configured or unavailable.")
target_msg_edit = callback.message
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
saved_methods: List = []
if autopay_enabled:
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if autopay_enabled and saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
try:
await callback.message.answer(
get_text("yookassa_autopay_flow_prompt"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=True,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_new.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parsed = _parse_offer_payload(data_payload)
if not parsed:
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
months, price_rub, sale_mode = parsed
user_id = callback.from_user.id
currency_code_for_yk = "RUB"
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
autopay_require_binding = bool(
getattr(settings, "YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", True)
)
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=autopay_enabled and autopay_require_binding,
back_callback=f"subscribe_period:{_format_value(months)}",
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
async def pay_yk_saved_list_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 2:
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
page = int(parts[2]) if len(parts) > 2 else 0
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
if not saved_methods:
try:
await callback.message.edit_text(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_no_saved_cards"),
reply_markup=get_yk_autopay_choice_keyboard(
months,
price_rub,
current_lang,
i18n,
has_saved_cards=False,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
return
cards: List[Tuple[str, str]] = []
for method in saved_methods:
title = _format_saved_payment_method_title(
get_text, method.card_network, method.card_last4, method.is_default
)
cards.append((str(method.method_id), title))
per_page = 5
max_page = max(0, (len(cards) - 1) // per_page)
page = max(0, min(page, max_page))
try:
await callback.message.edit_text(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception as e_edit:
logging.warning(f"Failed to display saved card list: {e_edit}")
try:
await callback.message.answer(
get_text("yookassa_autopay_choose_saved_card"),
reply_markup=get_yk_saved_cards_keyboard(
cards,
months,
price_rub,
current_lang,
i18n,
page=page,
sale_mode=sale_mode,
),
)
except Exception:
pass
try:
await callback.answer()
except Exception:
pass
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
yookassa_service: YooKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
if not i18n or not callback.message:
try:
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
except Exception:
pass
return
if not yookassa_service or not yookassa_service.configured:
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
try:
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
except Exception:
pass
try:
await callback.message.edit_text(get_text("payment_service_unavailable"))
except Exception:
pass
return
try:
_, data_payload = callback.data.split(":", 1)
except ValueError:
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
parts = data_payload.split(":")
if len(parts) < 3:
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
try:
months = float(parts[0])
price_rub = float(parts[1])
sale_mode = parts[3] if len(parts) > 3 else "subscription"
except (ValueError, IndexError):
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
autopay_enabled = bool(
settings.yookassa_autopayments_active
and _sale_mode_base(sale_mode) == "subscription"
and not settings.traffic_sale_mode
)
if not autopay_enabled:
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
method_identifier = parts[2]
user_id = callback.from_user.id
try:
saved_methods = await user_billing_dal.list_user_payment_methods(
session, user_id, provider="yookassa"
)
except Exception as e_list:
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
saved_methods = []
selected_method = None
for method in saved_methods:
if method_identifier.isdigit():
if method.method_id == int(method_identifier):
selected_method = method
break
if method.provider_payment_method_id == method_identifier:
selected_method = method
break
if not selected_method:
logging.warning(
f"Selected payment method not found for user {user_id}: {method_identifier}"
)
try:
await callback.answer(get_text("error_try_again"), show_alert=True)
except Exception:
pass
return
currency_code_for_yk = "RUB"
await _initiate_yk_payment(
callback,
settings=settings,
session=session,
yookassa_service=yookassa_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
user_id=user_id,
months=months,
price_rub=price_rub,
currency_code_for_yk=currency_code_for_yk,
save_payment_method=False,
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
payment_method_id=selected_method.provider_payment_method_id,
selected_method_internal_id=selected_method.method_id,
sale_mode=sale_mode,
)
try:
await callback.answer()
except Exception:
pass
+14 -42
View File
@@ -344,7 +344,6 @@ def get_payment_method_keyboard(
return str(int(val)) if float(val).is_integer() else f"{val:g}"
value_str = _format_value(months)
mode_suffix = f":{sale_mode}"
import logging as _kbd_logging
_kbd_logging.info(
@@ -354,50 +353,23 @@ def get_payment_method_keyboard(
settings.PLATEGA_SBP_ENABLED,
settings.PLATEGA_CRYPTO_ENABLED,
)
from bot.payment_providers import get_provider_spec, provider_telegram_button_text
for method in settings.payment_methods_order:
if method == "severpay" and getattr(settings, "SEVERPAY_ENABLED", False):
builder.button(
text=_("pay_with_severpay_button"),
callback_data=f"pay_severpay:{value_str}:{price}{mode_suffix}",
spec = get_provider_spec(method)
if not spec or not spec.button_text_key or not spec.is_enabled(settings):
continue
callback_data = spec.callback_data(
value=value_str,
rub_price=price,
stars_price=stars_price,
sale_mode=sale_mode,
)
elif method == "wata" and getattr(settings, "WATA_ENABLED", False):
if not callback_data:
continue
builder.button(
text=_("pay_with_wata_button"),
callback_data=f"pay_wata:{value_str}:{price}{mode_suffix}",
)
elif method == "freekassa" and settings.FREEKASSA_ENABLED:
builder.button(
text=_("pay_with_sbp_button"),
callback_data=f"pay_fk:{value_str}:{price}{mode_suffix}",
)
elif method == "platega_sbp" and settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED:
builder.button(
text=_("pay_with_platega_sbp_button"),
callback_data=f"pay_platega_sbp:{value_str}:{price}{mode_suffix}",
)
elif (
method == "platega_crypto"
and settings.PLATEGA_ENABLED
and settings.PLATEGA_CRYPTO_ENABLED
):
builder.button(
text=_("pay_with_platega_crypto_button"),
callback_data=f"pay_platega_crypto:{value_str}:{price}{mode_suffix}",
)
elif method == "yookassa" and settings.YOOKASSA_ENABLED:
builder.button(
text=_("pay_with_yookassa_button"),
callback_data=f"pay_yk:{value_str}:{price}{mode_suffix}",
)
elif method == "stars" and settings.STARS_ENABLED and stars_price is not None:
builder.button(
text=_("pay_with_stars_button"),
callback_data=f"pay_stars:{value_str}:{stars_price}{mode_suffix}",
)
elif method == "cryptopay" and settings.CRYPTOPAY_ENABLED:
builder.button(
text=_("pay_with_cryptopay_button"),
callback_data=f"pay_crypto:{value_str}:{price}{mode_suffix}",
text=provider_telegram_button_text(spec, settings, _, language=lang),
callback_data=callback_data,
)
builder.button(text=_(key="cancel_button"), callback_data="main_action:subscribe")
builder.adjust(1)
+3 -7
View File
@@ -187,20 +187,16 @@ async def on_shutdown_configured(dispatcher: Dispatcher):
except Exception as e:
logging.warning(f"Failed to close session for {key}: {e}")
from bot.payment_providers import iter_service_keys
for service_key in (
"panel_service",
"cryptopay_service",
"freekassa_service",
"panel_webhook_service",
"yookassa_service",
"lknpd_service",
"promo_code_service",
"stars_service",
"subscription_service",
"referral_service",
"platega_service",
"severpay_service",
"wata_service",
*iter_service_keys(),
):
await close_service(service_key)
+37
View File
@@ -0,0 +1,37 @@
from .base import (
PaymentProviderPresentation,
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .registry import (
PAYMENT_PROVIDER_SPECS,
build_provider_services,
get_provider_spec,
iter_provider_specs,
iter_service_keys,
iter_unique_provider_routers,
pending_statuses,
provider_emoji_map,
provider_label_map,
provider_telegram_button_text,
resolve_provider_presentation,
)
__all__ = [
"PAYMENT_PROVIDER_SPECS",
"PaymentProviderPresentation",
"PaymentProviderSpec",
"ServiceFactoryContext",
"WebAppPaymentContext",
"build_provider_services",
"get_provider_spec",
"iter_provider_specs",
"iter_service_keys",
"iter_unique_provider_routers",
"pending_statuses",
"provider_telegram_button_text",
"provider_emoji_map",
"provider_label_map",
"resolve_provider_presentation",
]
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence
@dataclass(frozen=True)
class ServiceFactoryContext:
settings: Any
bot: Any
async_session_factory: Any
i18n: Any
bot_username_for_default_return: str
subscription_service: Any
referral_service: Any
@dataclass(frozen=True)
class WebAppPaymentContext:
request: Any
session: Any
user_id: int
method: str
months: Any
price: float
stars_price: Optional[int]
description: str
sale_mode: str
traffic_gb: Optional[float] = None
EnabledPredicate = Callable[[Any], bool]
ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
@dataclass(frozen=True)
class PaymentProviderSpec:
id: str
provider_key: str
label: str
pending_status: str
enabled: EnabledPredicate
service_key: Optional[str] = None
button_text_key: Optional[str] = None
callback_prefix: Optional[str] = None
webapp_label: Optional[str] = None
webapp_labels: Optional[Mapping[str, str]] = None
telegram_labels: Optional[Mapping[str, str]] = None
aliases: Sequence[str] = ()
router: Any = None
create_service: Optional[ServiceFactory] = None
webhook_path: Optional[WebhookPathGetter] = None
webhook_route: Optional[WebhookRoute] = None
webhook_requires_base_url: bool = False
create_webapp_payment: Optional[WebAppPaymentFactory] = None
requires_configured_service: bool = True
price_source: str = "rub"
emoji: str = "💳"
webapp_icon: Optional[str] = None
telegram_emoji: Optional[str] = None
@property
def settings_key(self) -> str:
return self.id.upper()
@property
def default_telegram_emoji(self) -> str:
return self.telegram_emoji or self.emoji
@property
def method_ids(self) -> tuple[str, ...]:
return (self.id, *tuple(self.aliases))
def is_enabled(self, settings: Any) -> bool:
return bool(self.enabled(settings))
def is_service_configured(self, app: Any) -> bool:
if not self.requires_configured_service:
return True
if not self.service_key:
return True
service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False))
def is_visible(self, settings: Any, app: Any) -> bool:
return self.is_enabled(settings) and self.is_service_configured(app)
def load_router(self) -> Any:
return self.router
def load_webhook_route(self) -> Optional[WebhookRoute]:
return self.webhook_route
def callback_data(
self,
*,
value: str,
rub_price: float,
stars_price: Optional[int],
sale_mode: str,
) -> Optional[str]:
if not self.callback_prefix:
return None
if self.price_source == "stars":
if stars_price is None:
return None
price: Any = stars_price
else:
price = rub_price
return f"{self.callback_prefix}:{value}:{price}:{sale_mode}"
@dataclass(frozen=True)
class PaymentProviderPresentation:
webapp_label: str
webapp_icon: Optional[str]
telegram_label: str
telegram_emoji: str
telegram_customized: bool
@@ -6,22 +6,36 @@ from typing import Optional
from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
from aiogram import Bot
from aiogram import Bot, F, Router, types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
from db.dal import payment_dal
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
from .shared import (
PaymentSuccessRequest,
describe_payment,
finalize_successful_payment,
make_translator,
notify_callback_parse_error,
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_link_response,
payment_unavailable,
render_payment_link,
sale_mode_base,
sale_mode_tariff_key,
)
logger = logging.getLogger(__name__)
_LOG = "cryptopay"
class CryptoPayService:
@@ -54,13 +68,12 @@ class CryptoPayService:
self.configured = False
async def close(self):
"""Close underlying AioCryptoPay session if initialized."""
if self.client:
try:
await self.client.close()
logging.info("CryptoPay client session closed.")
except Exception as e:
logging.warning(f"Failed to close CryptoPay client: {e}")
logging.warning("Failed to close CryptoPay client: %s", e)
async def create_invoice(
self,
@@ -76,9 +89,9 @@ class CryptoPayService:
logging.error("CryptoPayService not configured")
return None
# Create pending payment in DB and commit to persist
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
try:
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_record = await payment_dal.create_payment_record(
session,
{
@@ -87,34 +100,28 @@ class CryptoPayService:
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": int(months)
if sale_base == "subscription"
else None,
"subscription_duration_months": (
int(months) if sale_base == "subscription" else None
),
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
},
)
await session.commit()
except Exception as e_db_create:
except Exception:
await session.rollback()
logging.error(
f"Failed to create cryptopay payment record for user {user_id}: {e_db_create}",
exc_info=True,
)
logging.exception("Failed to create cryptopay payment record for user %s.", user_id)
return None
payload = json.dumps(
{
"user_id": str(user_id),
"subscription_months": str(months),
"payment_db_id": str(payment_record.payment_id),
"sale_mode": sale_mode,
"traffic_gb": str(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
"traffic_gb": str(months) if is_traffic else None,
}
)
try:
@@ -169,7 +176,6 @@ class CryptoPayService:
sale_mode = meta.get("sale_mode") or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception:
logging.exception("Failed to parse CryptoPay payload.")
@@ -190,135 +196,44 @@ class CryptoPayService:
str(invoice.invoice_id),
"succeeded",
)
activation = await subscription_service.activate_subscription(
session,
user_id,
int(months) if sale_base == "subscription" else int(float(traffic_gb)),
float(invoice.amount),
payment_db_id,
provider="cryptopay",
sale_mode=sale_mode,
traffic_gb=traffic_gb
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
referral_bonus = await referral_service.apply_referral_bonuses_for_payment(
session,
user_id,
int(months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to process CryptoPay invoice.")
logging.exception(
"Failed to mark CryptoPay invoice %s as succeeded.",
payment_db_id,
)
return
db_user = await user_dal.get_user_by_id(session, user_id)
# Use DB language for user-facing messages
lang = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error(
"CryptoPay webhook: payment %s vanished after status update.",
payment_db_id,
)
_ = lambda k, **kw: i18n.gettext(lang, k, **kw)
return
raw_config_link = activation.get("subscription_url") if activation else None
display_link, button_link = await prepare_config_links(settings, raw_config_link)
config_link_text = display_link or _("config_link_not_available")
final_end = activation.get("end_date")
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _(
"payment_successful_traffic_full",
traffic_gb=str(int(traffic_gb))
if float(traffic_gb).is_integer()
else f"{traffic_gb:g}",
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
text = _(
"payment_successful_with_referral_bonus_full",
months=int(months),
base_end_date=activation["end_date"].strftime("%Y-%m-%d"),
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=int(months),
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
lang,
i18n,
settings,
display_link,
connect_button_url=button_link,
preserve_message=True,
)
try:
await bot.send_message(
user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception("Failed to send CryptoPay success message.")
# Send notification about payment
try:
payment_row = await payment_dal.get_payment_by_db_id(session, payment_db_id)
except Exception:
payment_row = None
try:
notification_service = NotificationService(bot, settings, i18n)
user = await user_dal.get_user_by_id(session, user_id)
await notification_service.notify_payment_received(
currency = invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL
await finalize_successful_payment(
PaymentSuccessRequest(
bot=bot,
settings=settings,
i18n=i18n,
session=session,
subscription_service=subscription_service,
referral_service=referral_service,
payment=payment,
user_id=user_id,
amount=float(invoice.amount),
currency=invoice.asset or settings.DEFAULT_CURRENCY_SYMBOL,
months=int(months) if sale_base == "subscription" else 0,
traffic_gb=traffic_gb
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="crypto_pay",
username=user.username if user else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment_row, "tariff_key", None) if payment_row else None,
currency=str(currency),
sale_mode=sale_mode,
months=int(months) if months else int(traffic_gb),
traffic_amount=float(traffic_gb),
provider_subscription="cryptopay",
provider_notification="crypto_pay",
log_prefix="CryptoPay webhook",
)
)
except Exception:
logging.exception("Failed to send crypto_pay payment notification.")
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
if not self.token:
@@ -347,3 +262,116 @@ class CryptoPayService:
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
service: CryptoPayService = request.app["cryptopay_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_crypto_router")
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if (
not settings.CRYPTOPAY_ENABLED
or not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
payment_description = describe_payment(translator, parts)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=callback.from_user.id,
months=parts.months,
amount=parts.price,
description=payment_description,
sale_mode=parts.sale_mode,
)
if invoice_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=invoice_url,
log_prefix=_LOG,
)
return
from .shared import safe_callback_answer
await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True)
def create_service(ctx: ServiceFactoryContext) -> CryptoPayService:
return CryptoPayService(
ctx.settings.CRYPTOPAY_TOKEN,
ctx.settings.CRYPTOPAY_NETWORK,
ctx.bot,
ctx.settings,
ctx.i18n,
ctx.async_session_factory,
ctx.subscription_service,
ctx.referral_service,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: CryptoPayService = ctx.request.app["cryptopay_service"]
if not service or not service.configured:
return payment_unavailable()
url = await service.create_invoice(
session=ctx.session,
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
)
if not url:
return payment_failed()
return payment_link_response(payment_url=url, payment_id=None)
SPEC = PaymentProviderSpec(
id="cryptopay",
provider_key="cryptopay",
label="CryptoPay",
webapp_label="CryptoPay",
webapp_labels={"ru": "CryptoPay", "en": "CryptoPay"},
webapp_icon="Bitcoin",
telegram_labels={"ru": "CryptoBot", "en": "CryptoBot"},
pending_status="pending_cryptopay",
enabled=lambda settings: settings.CRYPTOPAY_ENABLED,
service_key="cryptopay_service",
button_text_key="pay_with_cryptopay_button",
callback_prefix="pay_crypto",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.cryptopay_webhook_path,
webhook_route=cryptopay_webhook_route,
create_webapp_payment=create_webapp_payment,
emoji="",
telegram_emoji="",
)
+501
View File
@@ -0,0 +1,501 @@
import asyncio
import hashlib
import hmac
import json
import logging
import time
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qsl
from aiogram import Bot, F, Router, types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_unavailable,
post_json_request,
render_link_or_fail,
)
_LOG = "freekassa"
class FreeKassaService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
if not self.configured:
logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled."
)
if settings.FREEKASSA_ENABLED and not self.server_ip:
logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
)
async def create_order(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
email: Optional[str] = None,
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
ip_address = ip_address or self.server_ip
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
currency_code = (currency or self.default_currency or "RUB").upper()
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(payment_method_id),
"amount": f"{format_decimal_amount(amount):.2f}",
"currency": currency_code,
"email": email,
"ip": ip_address,
"us_user_id": str(user_id),
"us_months": str(months),
"us_payment_db_id": str(payment_db_id),
}
if extra_params:
for key, value in extra_params.items():
if value is None:
continue
payload[key] = value
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
return await post_json_request(
session,
f"{self.api_base_url}/orders/create",
body=payload,
log_prefix="FreeKassa create_order",
# FreeKassa returns ``{"type": "success", ...}`` on success.
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
)
async def _generate_nonce(self) -> int:
async with self._nonce_lock:
candidate = int(time.time() * 1000)
if candidate <= self._last_nonce:
candidate = self._last_nonce + 1
self._last_nonce = candidate
return candidate
def _sign_payload(self, payload: Dict[str, Any]) -> str:
if not self.api_key:
raise RuntimeError("FreeKassa API key is not configured.")
items = [
(key, value)
for key, value in payload.items()
if key != "signature" and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
return hmac.new(
self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
def _validate_signature(self, raw_body: bytes, provided_signature: str) -> bool:
if not provided_signature or not self.second_secret:
return False
expected_signature = hmac.new(
self.second_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_signature, provided_signature)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="freekassa_disabled")
try:
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
return web.Response(status=403)
raw_body = await request.read()
except Exception:
logging.exception("FreeKassa webhook: failed to read request body.")
return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any] = {}
if raw_body:
try:
if request.content_type.startswith("application/json"):
decoded_json = json.loads(raw_body.decode("utf-8"))
if isinstance(decoded_json, dict):
payload_dict = {str(k): v for k, v in decoded_json.items()}
else:
payload_dict = {
str(key): value
for key, value in parse_qsl(
raw_body.decode("utf-8"), keep_blank_values=True
)
}
except Exception:
payload_dict = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id:
return web.Response(status=403)
signature = _get("SIGN") or _get("signature")
if not signature:
return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str:
return web.Response(status=400, text="missing_data")
if not self._validate_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payment_db_id = int(order_id_str)
except (TypeError, ValueError):
logging.error("FreeKassa webhook: invalid order_id value %r", order_id_str)
return web.Response(status=400, text="invalid_order_id")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("FreeKassa webhook: payment %s not found", payment_db_id)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded":
logging.info("FreeKassa webhook: payment %s already succeeded", payment_db_id)
return web.Response(text="YES")
try:
if not decimal_amounts_equal(amount_str, payment.amount):
logging.warning(
"FreeKassa webhook: amount mismatch for payment %s (expected %s, got %s)",
payment_db_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_str),
)
except Exception as exc:
logging.warning(
"FreeKassa webhook: failed to compare amount for payment %s: %s",
payment_db_id,
exc,
)
resolved_provider_id = str(provider_payment_id or f"freekassa:{order_id_str}")
try:
await payment_dal.update_provider_payment_and_status(
session=session,
payment_db_id=payment.payment_id,
provider_payment_id=resolved_provider_id,
new_status="succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"FreeKassa webhook: failed to mark payment %s as succeeded.", payment_db_id
)
return web.Response(status=500, text="processing_error")
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
success_prefix: Optional[str] = None
if provider_payment_id:
# FreeKassa-specific: prepend "Order N from YYYY-MM-DD" to the success text.
# ``i18n`` is resolved inside ``finalize_successful_payment`` per user language,
# so the prefix is built in the admin/default language too — kept here for parity
# with the original behavior, which used the same key.
admin_lang = self.settings.DEFAULT_LANGUAGE
translator = make_translator(self.i18n, admin_lang)
success_prefix = translator(
"free_kassa_order_full",
order_id=provider_payment_id,
date=datetime.now().strftime("%Y-%m-%d"),
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.default_currency,
sale_mode=sale_mode,
months=months,
traffic_amount=float(months),
provider_subscription="freekassa",
provider_notification="freekassa",
db_user=payment.user,
log_prefix="FreeKassa webhook",
text_prefix=success_prefix,
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="YES")
async def freekassa_webhook_route(request: web.Request) -> web.Response:
service: FreeKassaService = request.app["freekassa_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_freekassa_router")
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_fk data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_freekassa",
description=payment_description,
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"FreeKassa: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await freekassa_service.create_order(
payment_db_id=payment_record.payment_id,
user_id=payment_record.user_id,
months=parts.months,
amount=parts.price,
currency=freekassa_service.default_currency,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": freekassa_service.payment_method_id,
},
)
location = first_value(response_data, "location")
provider_identifier = first_value(response_data, "orderHash", "orderId")
lead_text: Optional[str] = None
if success and location:
order_id_display = first_value(response_data, "orderId") or provider_identifier or str(
payment_record.payment_id
)
lead_text = translator(
"free_kassa_order_info",
order_id=order_id_display,
date=datetime.now().strftime("%Y-%m-%d"),
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=location,
provider_payment_id=provider_identifier,
lead_text=lead_text,
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> FreeKassaService:
return FreeKassaService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: FreeKassaService = ctx.request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return payment_unavailable()
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=service.default_currency,
status="pending_freekassa",
provider="freekassa",
)
success, response_data = await service.create_order(
payment_db_id=payment.payment_id,
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
currency=service.default_currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
)
except Exception:
await ctx.session.rollback()
logging.exception("FreeKassa WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(response_data, "location") if success else None,
provider_payment_id=first_value(response_data, "orderHash", "orderId"),
log_prefix="FreeKassa",
)
SPEC = PaymentProviderSpec(
id="freekassa",
provider_key="freekassa",
label="FreeKassa",
webapp_label="FreeKassa / СБП",
webapp_labels={"ru": "FreeKassa / СБП", "en": "FreeKassa / SBP"},
webapp_icon="Smartphone",
telegram_labels={"ru": "СБП", "en": "SBP"},
telegram_emoji="📱",
pending_status="pending_freekassa",
enabled=lambda settings: settings.FREEKASSA_ENABLED,
service_key="freekassa_service",
button_text_key="pay_with_sbp_button",
callback_prefix="pay_fk",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.freekassa_webhook_path,
webhook_route=freekassa_webhook_route,
create_webapp_payment=create_webapp_payment,
)
+529
View File
@@ -0,0 +1,529 @@
import hmac
import json
import logging
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
format_number_for_payload,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
post_json_request,
render_link_or_fail,
safe_callback_answer,
)
_LOG = "platega"
class PlategaService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
self.sbp_method = settings.platega_sbp_method_resolved
self.crypto_method = settings.PLATEGA_CRYPTO_METHOD
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
self._init_http_client(total_timeout=20)
self._auth_headers = {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
self.configured: bool = bool(settings.PLATEGA_ENABLED and self.merchant_id and self.secret)
if not self.configured:
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
)
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
self.sbp_method,
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
self.crypto_method,
)
async def create_transaction(
self,
*,
amount: float,
currency: Optional[str],
description: str,
payload: Optional[str] = None,
payment_method: Optional[int] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
"paymentMethod": method_id,
"paymentDetails": {"amount": float(amount), "currency": currency_code},
"description": description,
"return": self.return_url,
"failedUrl": self.failed_url,
"payload": payload,
}
# Remove optional keys with falsy values to avoid validation errors
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
safe_headers = {
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info(
"Platega create_transaction request: url=%s headers=%s body=%s",
url,
safe_headers,
clean_body,
)
return await post_json_request(
session,
url,
body=clean_body,
headers=self._auth_headers,
log_prefix="Platega create_transaction",
)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="platega_disabled")
try:
data = await request.json()
except Exception:
logging.exception("Platega webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret")
if not (
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
):
logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden")
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
status = str(data.get("status") or "").upper()
amount_raw = data.get("amount")
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not transaction_id or not status:
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
if not payment:
logging.error(
"Platega webhook: payment not found for transaction %s", transaction_id
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
if status == "CONFIRMED":
if amount_raw is not None:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"Platega webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to mark payment %s as succeeded.", transaction_id
)
return web.Response(status=500, text="processing_error")
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_months,
traffic_amount=float(payment_months),
provider_subscription="platega",
provider_notification="platega",
log_prefix="Platega webhook",
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"canceled",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to cancel payment %s.", transaction_id
)
return web.Response(status=500, text="processing_error")
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.Response(text="ok_canceled")
logging.warning(
"Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id
)
return web.Response(status=202, text="status_ignored")
async def platega_webhook_route(request: web.Request) -> web.Response:
service: PlategaService = request.app["platega_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_platega_router")
def _resolve_platega_variant(callback_prefix: str, settings: Settings) -> Optional[Tuple[str, int]]:
"""Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled."""
if callback_prefix == "pay_platega_crypto":
if not settings.PLATEGA_CRYPTO_ENABLED:
return None
return "crypto", settings.PLATEGA_CRYPTO_METHOD
if callback_prefix == "pay_platega_sbp":
if not settings.PLATEGA_SBP_ENABLED:
return None
return "sbp", settings.platega_sbp_method_resolved
# Legacy "pay_platega:" callback — keep working as SBP.
return "sbp", settings.platega_sbp_method_resolved
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
from .shared import make_translator
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
callback_prefix, _, _ = (callback.data or "").partition(":")
variant = _resolve_platega_variant(callback_prefix, settings)
if variant is None:
await safe_callback_answer(callback)
return
platega_variant, platega_method_id = variant
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_platega data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_platega",
description=payment_description,
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
payload_meta = json.dumps(
{
"payment_db_id": payment_record.payment_id,
"user_id": callback.from_user.id,
"months": parts.months,
"sale_mode": parts.sale_mode,
"platega_variant": platega_variant,
}
)
success, response_data = await platega_service.create_transaction(
amount=parts.price,
currency=currency_code,
description=payment_description,
payload=payload_meta,
payment_method=platega_method_id,
)
transaction_id = first_value(response_data, "transactionId", "id")
redirect_url = first_value(response_data, "redirect", "url", "paymentUrl")
provider_status = str((response_data or {}).get("status") or payment_record.status)
# Platega requires *both* a transaction id and a redirect url to count as a
# usable payment — neither field is sufficient on its own. Skipping the
# persistence step when the redirect is missing matches the pre-refactor
# behavior (we never stored a transaction id without a link).
persistable_id = transaction_id if redirect_url else None
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=redirect_url,
provider_payment_id=persistable_id,
new_status=provider_status if persistable_id else None,
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> PlategaService:
return PlategaService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web.Response:
settings = ctx.request.app["settings"]
service: PlategaService = ctx.request.app["platega_service"]
if not service or not service.configured:
return payment_unavailable()
if variant == "platega_crypto":
if not settings.PLATEGA_CRYPTO_ENABLED:
return payment_unavailable()
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
else:
if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
return payment_unavailable()
platega_method_id = settings.platega_sbp_method_resolved
try:
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
provider="platega",
)
payload = json.dumps(
{
"payment_db_id": payment.payment_id,
"user_id": ctx.user_id,
"months": amounts.months if not amounts.traffic_sale else 0,
"sale_mode": ctx.sale_mode,
"traffic_gb": format_number_for_payload(ctx.traffic_gb or ctx.months)
if amounts.traffic_sale
else None,
"hwid_devices": amounts.purchased_hwid_devices,
"source": "webapp",
"platega_variant": "crypto" if variant == "platega_crypto" else "sbp",
}
)
success, response_data = await service.create_transaction(
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description,
payload=payload,
payment_method=platega_method_id,
)
except Exception:
await ctx.session.rollback()
logging.exception("Platega WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=(
first_value(response_data, "redirect", "url", "paymentUrl") if success else None
),
provider_payment_id=first_value(response_data, "transactionId", "id"),
new_status=str((response_data or {}).get("status") or payment.status),
log_prefix="Platega",
)
async def create_sbp_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return await _create_webapp_payment(ctx, "platega_sbp")
async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return await _create_webapp_payment(ctx, "platega_crypto")
SBP_SPEC = PaymentProviderSpec(
id="platega_sbp",
provider_key="platega",
label="Platega",
webapp_label="Platega · СБП",
webapp_labels={"ru": "Оплата картой (СБП)", "en": "Pay with card (SBP)"},
webapp_icon="CreditCard",
telegram_labels={"ru": "Оплата через СБП", "en": "Pay via SBP"},
telegram_emoji="🏦",
pending_status="pending_platega",
enabled=lambda settings: settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED,
service_key="platega_service",
button_text_key="pay_with_platega_sbp_button",
callback_prefix="pay_platega_sbp",
aliases=("platega",),
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.platega_webhook_path,
webhook_route=platega_webhook_route,
create_webapp_payment=create_sbp_webapp_payment,
)
CRYPTO_SPEC = PaymentProviderSpec(
id="platega_crypto",
provider_key="platega",
label="Platega",
webapp_label="Platega · Crypto",
webapp_labels={"ru": "Крипта", "en": "Crypto"},
webapp_icon="Bitcoin",
telegram_labels={"ru": "Оплата криптой", "en": "Pay with crypto"},
telegram_emoji="🪙",
pending_status="pending_platega",
enabled=lambda settings: settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED,
service_key="platega_service",
button_text_key="pay_with_platega_crypto_button",
callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment,
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
from . import cryptopay, freekassa, platega, severpay, stars, wata, yookassa
from .base import PaymentProviderPresentation, PaymentProviderSpec, ServiceFactoryContext
PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
freekassa.SPEC,
platega.SBP_SPEC,
platega.CRYPTO_SPEC,
severpay.SPEC,
wata.SPEC,
yookassa.SPEC,
stars.SPEC,
cryptopay.SPEC,
)
def iter_provider_specs() -> Iterable[PaymentProviderSpec]:
return PAYMENT_PROVIDER_SPECS
def get_provider_spec(method: str) -> Optional[PaymentProviderSpec]:
normalized = str(method or "").strip().lower()
for spec in PAYMENT_PROVIDER_SPECS:
if normalized in spec.method_ids:
return spec
return None
def _setting_value(settings: Any, key: str) -> Optional[str]:
if settings is None:
return None
value = getattr(settings, key, None)
if value is None:
return None
value = str(value).strip()
return value or None
def _presentation_setting(spec: PaymentProviderSpec, suffix: str) -> str:
return f"PAYMENT_{spec.settings_key}_{suffix}"
def _normalize_language(language: Optional[str], settings: Any = None) -> str:
value = language or getattr(settings, "DEFAULT_LANGUAGE", None) or "ru"
normalized = str(value).strip().lower().split("-", 1)[0].split("_", 1)[0]
return normalized or "ru"
def _localized_setting_value(
settings: Any,
spec: PaymentProviderSpec,
suffix: str,
language: str,
) -> Optional[str]:
return _setting_value(
settings,
_presentation_setting(spec, f"{suffix}_{language.upper()}"),
)
def _localized_default(
values: Optional[Mapping[str, str]],
language: str,
fallback: Optional[str],
) -> Optional[str]:
if not values:
return fallback
return (
values.get(language)
or values.get("en")
or values.get("ru")
or next(iter(values.values()), None)
or fallback
)
def resolve_provider_presentation(
spec: PaymentProviderSpec,
settings: Any = None,
*,
language: Optional[str] = None,
translate: Optional[Callable[[str], str]] = None,
) -> PaymentProviderPresentation:
lang = _normalize_language(language, settings)
webapp_label = (
_localized_setting_value(settings, spec, "WEBAPP_LABEL", lang)
or _localized_default(spec.webapp_labels, lang, spec.webapp_label)
or spec.label
)
webapp_icon = (
_setting_value(settings, _presentation_setting(spec, "WEBAPP_ICON"))
or spec.webapp_icon
)
telegram_label_override = _localized_setting_value(
settings,
spec,
"TELEGRAM_LABEL",
lang,
)
telegram_emoji_override = _setting_value(
settings, _presentation_setting(spec, "TELEGRAM_EMOJI")
)
telegram_label = (
telegram_label_override
or _localized_default(spec.telegram_labels, lang, None)
or spec.label
)
telegram_emoji = telegram_emoji_override or spec.default_telegram_emoji
if (
not telegram_label_override
and not spec.telegram_labels
and translate
and spec.button_text_key
):
telegram_label = translate(spec.button_text_key)
return PaymentProviderPresentation(
webapp_label=webapp_label,
webapp_icon=webapp_icon,
telegram_label=telegram_label,
telegram_emoji=telegram_emoji,
telegram_customized=bool(telegram_label_override or telegram_emoji_override),
)
def provider_telegram_button_text(
spec: PaymentProviderSpec,
settings: Any,
translate: Callable[[str], str],
*,
language: Optional[str] = None,
) -> str:
presentation = resolve_provider_presentation(spec, settings, language=language)
if presentation.telegram_emoji:
return f"{presentation.telegram_emoji} {presentation.telegram_label}".strip()
return presentation.telegram_label
def iter_unique_provider_routers():
seen: set[int] = set()
for spec in PAYMENT_PROVIDER_SPECS:
router = spec.load_router()
if not router:
continue
marker = id(router)
if marker in seen:
continue
seen.add(marker)
yield router
def iter_service_keys() -> Iterable[str]:
seen: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
if not spec.service_key or spec.service_key in seen:
continue
seen.add(spec.service_key)
yield spec.service_key
def iter_service_specs() -> Iterable[PaymentProviderSpec]:
seen: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
if not spec.service_key or not spec.create_service or spec.service_key in seen:
continue
seen.add(spec.service_key)
yield spec
def build_provider_services(ctx: ServiceFactoryContext) -> Dict[str, Any]:
services: Dict[str, Any] = {}
for spec in iter_service_specs():
services[spec.service_key] = spec.create_service(ctx)
return services
def provider_label_map(settings: Any = None, language: Optional[str] = None) -> Dict[str, str]:
labels: Dict[str, str] = {}
for spec in PAYMENT_PROVIDER_SPECS:
presentation = resolve_provider_presentation(
spec,
settings,
language=language,
)
label = presentation.telegram_label if presentation.telegram_customized else spec.label
labels.setdefault(spec.provider_key, label)
for method in spec.method_ids:
labels.setdefault(method, label)
return labels
def provider_emoji_map(settings: Any = None) -> Dict[str, str]:
emojis: Dict[str, str] = {}
for spec in PAYMENT_PROVIDER_SPECS:
emoji = resolve_provider_presentation(spec, settings).telegram_emoji
emojis.setdefault(spec.provider_key, emoji)
for method in spec.method_ids:
emojis.setdefault(method, emoji)
return emojis
def pending_statuses() -> List[str]:
statuses = ["pending"]
for spec in PAYMENT_PROVIDER_SPECS:
if spec.pending_status not in statuses:
statuses.append(spec.pending_status)
return statuses
+444
View File
@@ -0,0 +1,444 @@
import hashlib
import hmac
import json
import logging
import secrets
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import payment_dal
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
lookup_payment_by_order_or_provider_id,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_unavailable,
post_json_request,
render_link_or_fail,
)
_LOG = "severpay"
class SeverPayService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip(
"/"
)
self.mid = settings.SEVERPAY_MID
self.token = settings.SEVERPAY_TOKEN or ""
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
self._init_http_client(total_timeout=15)
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
if not self.configured:
logging.warning(
"SeverPayService initialized but not fully configured. Payments disabled."
)
@staticmethod
def _format_amount(amount: float) -> str:
return f"{format_decimal_amount(amount):.2f}"
def _sign_payload(self, payload: Dict[str, Any]) -> str:
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return hmac.new(
self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
body: Dict[str, Any] = {
"mid": self.mid,
"salt": secrets.token_hex(8),
}
body.update(extra)
sorted_body = dict(sorted(body.items()))
sorted_body["sign"] = self._sign_payload(sorted_body)
return sorted_body
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
provided_sign = str(payload.get("sign") or "")
if not provided_sign or not self.token:
return False
# Webhook signatures are calculated on the original payload order (without sorting).
data = {k: v for k, v in payload.items() if k != "sign"}
expected_sign = self._sign_payload(data)
return hmac.compare_digest(provided_sign, expected_sign)
async def create_payment(
self,
*,
payment_db_id: int,
user_id: int,
amount: float,
currency: Optional[str],
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body = {
"order_id": str(payment_db_id),
"amount": self._format_amount(amount),
"currency": currency_code,
"client_email": f"{user_id}@telegram.org",
"client_id": str(user_id),
"url_return": self.return_url,
}
if self.lifetime_minutes:
body["lifetime"] = int(self.lifetime_minutes)
success, response_data = await post_json_request(
session,
url,
body=self._build_signed_body(body),
log_prefix="SeverPay create_payment",
# SeverPay marks success with both HTTP 200 *and* the top-level ``status`` flag.
is_success=lambda status, data: status == 200 and bool((data or {}).get("status")),
)
if success:
# SeverPay wraps the useful response inside ``data``; unwrap so callers
# don't have to know that detail.
return True, response_data.get("data") or response_data
return False, response_data
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
try:
payload = await request.json()
except Exception:
logging.exception("SeverPay webhook: failed to parse JSON.")
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict) or not self._validate_signature(payload):
logging.error("SeverPay webhook: invalid signature or payload.")
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
event_type = str(payload.get("type") or "").lower()
data = payload.get("data") or {}
if event_type != "payin" or not isinstance(data, dict):
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
return web.json_response({"status": True})
provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower()
async with self.async_session_factory() as session:
payment = await lookup_payment_by_order_or_provider_id(
session,
order_id_raw=order_id_raw,
provider_payment_id=provider_payment_id or None,
)
if not payment:
logging.error(
"SeverPay webhook: payment not found (order_id=%s, provider_id=%s)",
order_id_raw,
provider_payment_id,
)
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
resolved_provider_id = provider_payment_id or str(payment.payment_id)
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
if status == "success":
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to mark payment %s as succeeded.",
resolved_provider_id,
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=payment.currency,
sale_mode=sale_mode,
months=payment_months,
traffic_amount=float(payment_months),
provider_subscription="severpay",
provider_notification="severpay",
db_user=payment.user,
log_prefix="SeverPay webhook",
)
)
if outcome is None:
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
return web.json_response({"status": True})
if status in {"fail", "decline"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to mark payment %s as failed.",
resolved_provider_id,
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.json_response({"status": True})
if status in {"process", "new"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"pending_severpay",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to update pending status for %s.",
resolved_provider_id,
)
return web.json_response({"status": True})
logging.warning(
"SeverPay webhook: unhandled status '%s' for payment %s",
status,
resolved_provider_id,
)
return web.json_response({"status": True})
async def severpay_webhook_route(request: web.Request) -> web.Response:
service: SeverPayService = request.app["severpay_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_severpay_router")
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_severpay data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_severpay",
description=payment_description,
months=parts.months,
provider="severpay",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await severpay_service.create_payment(
payment_db_id=payment_record.payment_id,
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=first_value(response_data, "url", "payment_url", "paymentUrl"),
provider_payment_id=first_value(response_data, "id", "uid"),
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> SeverPayService:
return SeverPayService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
settings = ctx.request.app["settings"]
service: SeverPayService = ctx.request.app["severpay_service"]
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=currency,
status="pending_severpay",
provider="severpay",
)
success, response_data = await service.create_payment(
payment_db_id=payment.payment_id,
user_id=ctx.user_id,
amount=ctx.price,
currency=currency,
)
except Exception:
await ctx.session.rollback()
logging.exception("SeverPay WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=(
first_value(response_data, "url", "payment_url", "paymentUrl") if success else None
),
provider_payment_id=first_value(response_data, "id", "uid"),
log_prefix="SeverPay",
)
SPEC = PaymentProviderSpec(
id="severpay",
provider_key="severpay",
label="SeverPay",
webapp_label="SeverPay",
webapp_labels={"ru": "SeverPay", "en": "SeverPay"},
webapp_icon="CreditCard",
telegram_labels={"ru": "SeverPay", "en": "SeverPay"},
telegram_emoji="💳",
pending_status="pending_severpay",
enabled=lambda settings: settings.SEVERPAY_ENABLED,
service_key="severpay_service",
button_text_key="pay_with_severpay_button",
callback_prefix="pay_severpay",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.severpay_webhook_path,
webhook_route=severpay_webhook_route,
create_webapp_payment=create_webapp_payment,
)
@@ -0,0 +1,128 @@
"""Cross-provider helpers.
Provider modules at ``bot.payment_providers.<name>`` import the building
blocks they need from here. Nothing in this package depends on any
specific provider it is the layer below them.
"""
from .callbacks import (
PaymentCallbackParts,
describe_payment,
edit_or_answer,
notify_callback_parse_error,
notify_payment_gateway_failure,
notify_payment_record_failure,
notify_service_unavailable,
parse_payment_callback,
payment_link_message_text,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
safe_mark_failed_creation,
safe_store_provider_payment_id,
)
from .common import (
PaymentRecordAmounts,
Translator,
build_payment_description,
build_payment_record_payload,
create_base_payment_record,
create_webapp_payment_record,
decimal_amounts_equal,
format_decimal_amount,
format_human_units,
format_number_for_payload,
json_error,
make_translator,
mark_payment_failed_creation,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
from .http_client import (
HttpClientMixin,
SuccessCheck,
first_value,
http_ok,
post_json_request,
)
from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
notify_admins_payment_received,
resolve_inviter_name,
resolve_user_language,
send_success_message_to_user,
)
from .webapp import finalize_webapp_link_payment
from .webhooks import (
coerce_payment_db_id,
lookup_payment_by_order_or_provider_id,
notify_user_payment_failed,
)
__all__ = [
"HttpClientMixin",
"PaymentCallbackParts",
"PaymentRecordAmounts",
"PaymentSuccessOutcome",
"PaymentSuccessRequest",
"SuccessCheck",
"SuccessMessage",
"Translator",
"build_payment_description",
"build_payment_record_payload",
"build_success_message",
"coerce_payment_db_id",
"create_base_payment_record",
"create_webapp_payment_record",
"decimal_amounts_equal",
"describe_payment",
"edit_or_answer",
"finalize_successful_payment",
"finalize_webapp_link_payment",
"first_value",
"format_decimal_amount",
"format_human_units",
"format_number_for_payload",
"http_ok",
"is_traffic_sale_base",
"json_error",
"lookup_payment_by_order_or_provider_id",
"make_translator",
"mark_payment_failed_creation",
"notify_admins_payment_received",
"notify_callback_parse_error",
"notify_payment_gateway_failure",
"notify_payment_record_failure",
"notify_service_unavailable",
"notify_user_payment_failed",
"parse_payment_callback",
"payment_failed",
"payment_link_message_text",
"payment_link_response",
"payment_record_amounts",
"payment_unavailable",
"post_json_request",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
"resolve_user_language",
"safe_callback_answer",
"safe_mark_failed_creation",
"safe_store_provider_payment_id",
"sale_mode_base",
"sale_mode_is_hwid_devices",
"sale_mode_is_traffic",
"sale_mode_tariff_key",
"send_success_message_to_user",
]
@@ -0,0 +1,322 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal
from db.models import Payment
from .common import (
Translator,
build_payment_description,
format_human_units,
mark_payment_failed_creation,
sale_mode_base,
)
@dataclass(frozen=True)
class PaymentCallbackParts:
months: float
price: float
sale_mode: str
@property
def human_value(self) -> str:
return format_human_units(self.months)
@property
def sale_base(self) -> str:
return sale_mode_base(self.sale_mode)
def parse_payment_callback(callback_data: str) -> Optional[PaymentCallbackParts]:
"""Parse the ``<prefix>:<value>:<price>:<sale_mode>`` payload all providers use.
Returns ``None`` if the payload doesn't have the expected shape — callers
answer with ``error_try_again`` in that case.
"""
try:
_, data_payload = callback_data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
return None
return PaymentCallbackParts(months=months, price=price, sale_mode=sale_mode)
async def safe_callback_answer(
callback: types.CallbackQuery,
text: Optional[str] = None,
*,
show_alert: bool = False,
) -> None:
"""``callback.answer`` that never raises (Telegram occasionally 400s)."""
try:
if text is None:
await callback.answer()
else:
await callback.answer(text, show_alert=show_alert)
except Exception:
pass
async def edit_or_answer(
callback: types.CallbackQuery,
text: str,
*,
reply_markup=None,
disable_web_page_preview: bool = False,
log_prefix: str = "payment_providers",
) -> None:
"""Edit the callback message if possible, else send a fresh reply."""
if not callback.message:
return
try:
await callback.message.edit_text(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
return
except Exception as exc:
logging.warning("%s: failed to edit message (%s), sending new one.", log_prefix, exc)
try:
await callback.message.answer(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
except Exception:
pass
def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str:
"""Shortcut around ``build_payment_description`` for callback usage."""
return build_payment_description(
translator,
months=parts.months,
sale_mode=parts.sale_mode,
human_value=parts.human_value,
)
def payment_link_message_text(
translator: Translator,
parts: PaymentCallbackParts,
*,
lead_text: Optional[str] = None,
) -> str:
"""Build the ``payment_link_message`` text (with optional lead block)."""
traffic_like = sale_mode_base(parts.sale_mode) in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}
key = (
"payment_link_message_traffic"
if traffic_like
else "payment_link_message"
)
body = translator(
key,
months=int(parts.months),
traffic_gb=parts.human_value,
)
if lead_text:
return f"{lead_text}\n\n{body}"
return body
async def render_payment_link(
callback: types.CallbackQuery,
*,
translator: Translator,
current_lang: str,
i18n: Optional[JsonI18n],
parts: PaymentCallbackParts,
payment_url: str,
lead_text: Optional[str] = None,
back_text_key: str = "back_to_payment_methods_button",
log_prefix: str = "payment_providers",
) -> None:
"""Show the payment link with the standard back button and shared fallbacks."""
text = payment_link_message_text(translator, parts, lead_text=lead_text)
keyboard = get_payment_url_keyboard(
payment_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{parts.human_value}",
back_text_key=back_text_key,
)
await edit_or_answer(
callback,
text,
reply_markup=keyboard,
log_prefix=log_prefix,
)
await safe_callback_answer(callback)
async def notify_service_unavailable(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""Render the standard ``payment_service_unavailable`` UX."""
await safe_callback_answer(
callback,
translator("payment_service_unavailable_alert"),
show_alert=True,
)
if callback.message:
try:
await callback.message.edit_text(translator("payment_service_unavailable"))
except Exception:
pass
async def notify_callback_parse_error(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""The 4-line "callback payload looked wrong" guard every provider repeats."""
await safe_callback_answer(callback, translator("error_try_again"), show_alert=True)
async def notify_payment_record_failure(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""Both error_creating_payment_record + error_try_again shown after DB failure."""
if callback.message:
try:
await callback.message.edit_text(translator("error_creating_payment_record"))
except Exception:
pass
await safe_callback_answer(callback, translator("error_try_again"), show_alert=True)
async def notify_payment_gateway_failure(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""``error_payment_gateway`` shown both inline and as alert."""
if callback.message:
try:
await callback.message.edit_text(translator("error_payment_gateway"))
except Exception:
pass
await safe_callback_answer(
callback,
translator("error_payment_gateway"),
show_alert=True,
)
async def safe_store_provider_payment_id(
session: AsyncSession,
payment: Payment,
*,
provider_payment_id: str,
new_status: Optional[str] = None,
log_prefix: str,
) -> bool:
"""Persist ``(provider_payment_id, status)`` on the payment with rollback-on-fail.
Returns True on success; logs and rolls back on failure. ``new_status``
defaults to the payment's existing status (used after a successful API call
that doesn't change the pending state).
"""
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
)
await session.commit()
return True
except Exception:
await session.rollback()
logging.exception(
"%s: failed to store provider payment id for payment %s.",
log_prefix,
payment.payment_id,
)
return False
async def safe_mark_failed_creation(
session: AsyncSession,
payment: Payment,
*,
log_prefix: str,
) -> None:
"""Mark the payment as ``failed_creation``; swallow + log on failure."""
try:
await mark_payment_failed_creation(session, payment.payment_id)
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark payment %s as failed_creation.",
log_prefix,
payment.payment_id,
)
async def render_link_or_fail(
callback: types.CallbackQuery,
*,
translator: Translator,
current_lang: str,
i18n: Optional[JsonI18n],
parts: "PaymentCallbackParts",
session: AsyncSession,
payment: Payment,
api_success: bool,
payment_url: Optional[str],
provider_payment_id: Optional[str] = None,
new_status: Optional[str] = None,
lead_text: Optional[str] = None,
log_prefix: str,
) -> None:
"""Finalize the link-based callback flow after the provider API responded.
Persists the provider payment id (when one was returned), shows the
payment link, or falls through to ``error_payment_gateway`` and marks the
payment as ``failed_creation``. Every link-style provider used to inline
this same sequence.
"""
if api_success and provider_payment_id:
await safe_store_provider_payment_id(
session,
payment,
provider_payment_id=provider_payment_id,
new_status=new_status,
log_prefix=log_prefix,
)
if api_success and payment_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=payment_url,
lead_text=lead_text,
log_prefix=log_prefix,
)
return
await safe_mark_failed_creation(session, payment, log_prefix=log_prefix)
await notify_payment_gateway_failure(callback, translator)
@@ -0,0 +1,247 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Callable, Optional
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import payment_dal
from db.models import Payment
from ..base import WebAppPaymentContext
Translator = Callable[..., str]
def make_translator(i18n: Any, language: str) -> Translator:
"""Return a ``_(key, **kw)`` callable that falls back to the key when i18n is absent."""
def _(key: str, **kwargs: Any) -> str:
if i18n is None:
return key
return i18n.gettext(language, key, **kwargs)
return _
def format_decimal_amount(amount: Any, places: int = 2) -> Decimal:
"""Quantize ``amount`` to the given decimal places using bank rounding."""
return Decimal(str(amount)).quantize(Decimal(10) ** -places, rounding=ROUND_HALF_UP)
def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
"""True when both values round to the same fixed-point representation."""
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
def format_human_units(value: Any) -> str:
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
numeric = float(value)
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
def build_payment_description(
translator: Translator,
*,
months: Any,
sale_mode: str,
human_value: Optional[str] = None,
) -> str:
"""Render the standard user-visible payment description.
Mirrors the branching every callback handler used to repeat
(traffic / hwid_devices / subscription).
"""
base = sale_mode_base(sale_mode)
if base in {"traffic", "traffic_package", "topup", "premium_topup"}:
return translator(
"payment_description_traffic",
traffic_gb=human_value if human_value is not None else format_human_units(months),
)
if base in {"hwid_device", "hwid_devices"}:
return translator("payment_description_hwid_devices", count=int(float(months)))
return translator("payment_description_subscription", months=int(float(months)))
def build_payment_record_payload(
*,
user_id: int,
amount: float,
currency: str,
status: str,
description: str,
months: Any,
provider: str,
sale_mode: str,
) -> dict:
"""Assemble the payment-record dict that every callback handler used to inline.
For the ``traffic`` sale modes, ``purchased_gb`` is taken from ``months``
(callbacks encode the GB amount in the ``months`` slot); webapp creators
use the ``payment_record_amounts`` helper directly to split the two.
"""
base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode)
return {
"user_id": user_id,
"amount": amount,
"currency": currency,
"status": status,
"description": description,
"subscription_duration_months": int(float(months)) if base == "subscription" else None,
"provider": provider,
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
}
@dataclass(frozen=True)
class PaymentRecordAmounts:
months: int
purchased_gb: Optional[float]
purchased_hwid_devices: Optional[int]
tariff_key: Optional[str]
traffic_sale: bool
hwid_devices_sale: bool
def sale_mode_base(sale_mode: str) -> str:
return str(sale_mode or "").split("@", 1)[0].split("|", 1)[0]
def sale_mode_is_traffic(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"}
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
return str(sale_mode or "").split("@", 1)[1] if "@" in str(sale_mode or "") else None
def format_number_for_payload(value: Any) -> str:
value_float = float(value)
return str(int(value_float)) if value_float.is_integer() else f"{value_float:g}"
def payment_record_amounts(
*,
months: Any,
sale_mode: str,
traffic_gb: Optional[float] = None,
) -> PaymentRecordAmounts:
traffic_sale = sale_mode_is_traffic(sale_mode)
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
units = traffic_gb if traffic_sale and traffic_gb is not None else months
return PaymentRecordAmounts(
months=int(float(units)) if traffic_sale else int(float(months)),
purchased_gb=float(units) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
tariff_key=sale_mode_tariff_key(sale_mode),
traffic_sale=traffic_sale,
hwid_devices_sale=hwid_devices_sale,
)
def json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
def payment_unavailable() -> web.Response:
return json_error(400, "payment_unavailable", "Payment method unavailable")
def payment_failed(message: str = "Failed to create payment") -> web.Response:
return json_error(502, "payment_failed", message)
def payment_link_response(
*,
payment_url: str,
payment_id: Optional[int],
action: str = "open_link",
) -> web.Response:
return web.json_response(
{
"ok": True,
"action": action,
"payment_url": payment_url,
"payment_id": payment_id,
}
)
async def create_base_payment_record(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
status: str,
description: str,
months: int,
provider: str,
sale_mode: Optional[str] = None,
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": amount,
"currency": currency,
"status": status,
"description": description,
"subscription_duration_months": months,
"provider": provider,
"sale_mode": sale_mode,
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
},
)
await session.commit()
return payment
async def create_webapp_payment_record(
ctx: WebAppPaymentContext,
*,
amount: float,
currency: str,
status: str,
provider: str,
) -> Payment:
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
return await create_base_payment_record(
ctx.session,
user_id=ctx.user_id,
amount=amount,
currency=currency,
status=status,
description=ctx.description,
months=amounts.months,
provider=provider,
sale_mode=ctx.sale_mode,
tariff_key=amounts.tariff_key,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
)
async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None:
await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
await session.commit()
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
import logging
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
from aiohttp import ClientSession, ClientTimeout
SuccessCheck = Callable[[int, Any], bool]
def http_ok(status: int, _body: Any) -> bool:
"""Default success criterion — HTTP 200 with any body."""
return status == 200
async def post_json_request(
session: ClientSession,
url: str,
*,
body: Any,
headers: Optional[Mapping[str, str]] = None,
log_prefix: str,
is_success: SuccessCheck = http_ok,
) -> Tuple[bool, Dict[str, Any]]:
"""Centralized JSON-POST every HTTP-API provider used to inline ~25 lines for.
On transport failure, JSON decode failure, or rejected ``is_success`` check,
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
can decide what to do (typically: mark the payment as ``failed_creation``).
"""
try:
async with session.post(
url,
json=body,
headers=dict(headers) if headers else None,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if not is_success(response.status, response_data):
logging.error(
"%s: API returned error (status=%s, body=%s)",
log_prefix,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)}
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]:
"""Return the first non-empty value among ``keys`` (cast to ``str``)."""
if not data:
return None
for key in keys:
value = data.get(key)
if value:
return str(value)
return None
class HttpClientMixin:
"""Shared lazy ``aiohttp.ClientSession`` lifecycle for provider services.
Each subclass calls ``self._init_http_client(total_timeout=...)`` from
``__init__`` and inherits ``_get_session`` / ``close``. The session is
created on first use and recreated transparently if it was closed.
"""
_timeout: ClientTimeout
_session: Optional[ClientSession]
def _init_http_client(self, *, total_timeout: float = 20.0) -> None:
self._timeout = ClientTimeout(total=total_timeout)
self._session = None
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
@@ -0,0 +1,383 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
def is_traffic_sale_base(sale_base: str) -> bool:
return sale_base in _TRAFFIC_MODES
async def resolve_user_language(
session: AsyncSession,
*,
user_id: int,
db_user: Optional[User],
settings: Any,
) -> tuple[Optional[User], str]:
"""Return the loaded user and the language to use for messaging."""
if db_user is None:
db_user = await user_dal.get_user_by_id(session, user_id)
language = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
return db_user, language
async def resolve_inviter_name(
session: AsyncSession,
translator: Translator,
db_user: Optional[User],
) -> str:
"""Return a display name for the user's inviter, or the localized placeholder."""
placeholder = translator("friend_placeholder")
if not db_user or not db_user.referred_by_id:
return placeholder
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if not inviter:
return placeholder
if inviter.first_name:
safe_name = sanitize_display_name(inviter.first_name)
if safe_name:
return safe_name
if inviter.username:
return username_for_display(inviter.username, with_at=False)
return placeholder
@dataclass
class SuccessMessage:
"""Inputs for ``build_success_message``."""
translator: Translator
sale_mode: str
months: Any
base_end_date: Optional[datetime]
final_end_date: Optional[datetime]
config_link_text: str
applied_referee_bonus_days: int = 0
applied_promo_bonus_days: int = 0
inviter_name: Optional[str] = None
fallback_date_text: str = ""
def _fmt_date(dt: Optional[datetime], fallback: str) -> str:
return dt.strftime("%Y-%m-%d") if dt else fallback
def build_success_message(payload: SuccessMessage) -> str:
"""Render the post-payment user-facing text.
Picks one of: ``payment_successful_traffic_full`` /
``payment_successful_with_referral_bonus_full`` /
``payment_successful_with_promo_full`` / ``payment_successful_full``.
"""
base = sale_mode_base(payload.sale_mode)
_ = payload.translator
end_text = _fmt_date(payload.final_end_date, payload.fallback_date_text)
if is_traffic_sale_base(base):
return _(
"payment_successful_traffic_full",
traffic_gb=format_human_units(payload.months),
end_date=end_text,
config_link=payload.config_link_text,
)
if payload.applied_referee_bonus_days and payload.final_end_date:
base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text)
return _(
"payment_successful_with_referral_bonus_full",
months=payload.months,
base_end_date=base_end_text,
bonus_days=payload.applied_referee_bonus_days,
final_end_date=end_text,
inviter_name=payload.inviter_name or _("friend_placeholder"),
config_link=payload.config_link_text,
)
if payload.applied_promo_bonus_days and payload.final_end_date:
return _(
"payment_successful_with_promo_full",
months=payload.months,
bonus_days=payload.applied_promo_bonus_days,
end_date=end_text,
config_link=payload.config_link_text,
)
return _(
"payment_successful_full",
months=payload.months,
end_date=end_text,
config_link=payload.config_link_text,
)
async def send_success_message_to_user(
*,
bot: Bot,
user_id: int,
text: str,
language: str,
i18n: Any,
settings: Any,
config_link_display: Optional[str],
connect_button_url: Optional[str],
include_keyboard: bool = True,
log_prefix: str = "payment_providers",
) -> None:
"""Send the rendered success text with the standard connect keyboard."""
markup = None
if include_keyboard:
markup = get_connect_and_main_keyboard(
language,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await bot.send_message(
user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception("%s: failed to notify user %s.", log_prefix, user_id)
async def notify_admins_payment_received(
*,
bot: Bot,
settings: Any,
i18n: Any,
user_id: int,
amount: float,
currency: str,
months_for_admin: int,
traffic_gb_for_admin: Optional[float],
payment_provider: str,
username: Optional[str],
traffic_is_premium: bool,
tariff_key: Optional[str],
log_prefix: str = "payment_providers",
) -> None:
"""Push the standard ``notify_payment_received`` to the admin log channel."""
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_payment_received(
user_id=user_id,
amount=amount,
currency=currency,
months=months_for_admin,
traffic_gb=traffic_gb_for_admin,
payment_provider=payment_provider,
username=username,
traffic_is_premium=traffic_is_premium,
tariff_key=tariff_key,
)
except Exception:
logging.exception("%s: failed to notify admins.", log_prefix)
@dataclass
class PaymentSuccessRequest:
"""All the inputs ``finalize_successful_payment`` needs."""
bot: Bot
settings: Any
i18n: Any
session: AsyncSession
subscription_service: Any
referral_service: Any
payment: Payment
user_id: int
amount: float
currency: str
sale_mode: str
months: Any
traffic_amount: Optional[float]
provider_subscription: str
provider_notification: str
db_user: Optional[User] = None
log_prefix: str = "payment_providers"
activation_extra_kwargs: dict = field(default_factory=dict)
skip_keyboard: bool = False
text_prefix: Optional[str] = None
@dataclass
class PaymentSuccessOutcome:
activation: Optional[dict]
referral_bonus: Optional[dict]
final_end_date: Optional[datetime]
applied_referee_bonus_days: int
applied_promo_bonus_days: int
db_user: Optional[User]
language: str
async def finalize_successful_payment(
req: PaymentSuccessRequest,
) -> Optional[PaymentSuccessOutcome]:
"""Activate the subscription, apply referral bonus, notify user + admins.
Returns ``None`` if the activation pipeline failed mid-way (errors are
logged and the session is rolled back). On success returns an outcome
object so callers can drive extra side-effects (e.g. yookassa LKNPD
receipts) using the same activation result.
"""
base = sale_mode_base(req.sale_mode)
is_subscription = base == "subscription"
is_traffic = is_traffic_sale_base(base)
activation_months = (
int(float(req.months)) if is_subscription else int(float(req.traffic_amount or req.months))
)
traffic_gb_for_activation = (
float(req.traffic_amount or req.months) if is_traffic else None
)
try:
activation = await req.subscription_service.activate_subscription(
req.session,
req.user_id,
activation_months,
req.amount,
req.payment.payment_id,
provider=req.provider_subscription,
sale_mode=req.sale_mode,
traffic_gb=traffic_gb_for_activation,
**req.activation_extra_kwargs,
)
referral_bonus = None
if is_subscription:
referral_bonus = await req.referral_service.apply_referral_bonuses_for_payment(
req.session,
req.user_id,
activation_months or 1,
current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False,
)
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to activate subscription for payment %s.",
req.log_prefix,
req.payment.payment_id,
)
return None
db_user, language = await resolve_user_language(
req.session,
user_id=req.user_id,
db_user=req.db_user,
settings=req.settings,
)
translator = make_translator(req.i18n, language)
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
req.settings, raw_config_link
)
config_link_text = config_link_display or translator("config_link_not_available")
base_end_date = activation.get("end_date") if activation else None
final_end_date = base_end_date
applied_referee_bonus_days = 0
applied_promo_bonus_days = (
activation.get("applied_promo_bonus_days", 0) if activation else 0
)
inviter_name: Optional[str] = None
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end_date = referral_bonus["referee_new_end_date"]
applied_referee_bonus_days = referral_bonus.get("referee_bonus_applied_days", 0) or 0
inviter_name = await resolve_inviter_name(req.session, translator, db_user)
success_text = build_success_message(
SuccessMessage(
translator=translator,
sale_mode=req.sale_mode,
months=(
activation_months
if is_subscription
else format_human_units(req.traffic_amount or req.months)
),
base_end_date=base_end_date,
final_end_date=final_end_date,
config_link_text=config_link_text,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name,
)
)
if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
await send_success_message_to_user(
bot=req.bot,
user_id=req.user_id,
text=success_text,
language=language,
i18n=req.i18n,
settings=req.settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix,
)
refreshed_payment = await payment_dal.get_payment_by_db_id(
req.session, req.payment.payment_id
)
tariff_key = getattr(refreshed_payment or req.payment, "tariff_key", None)
await notify_admins_payment_received(
bot=req.bot,
settings=req.settings,
i18n=req.i18n,
user_id=req.user_id,
amount=req.amount,
currency=req.currency,
months_for_admin=activation_months if is_subscription else 0,
traffic_gb_for_admin=traffic_gb_for_activation,
payment_provider=req.provider_notification,
username=db_user.username if db_user else None,
traffic_is_premium=base == "premium_topup",
tariff_key=tariff_key,
log_prefix=req.log_prefix,
)
return PaymentSuccessOutcome(
activation=activation,
referral_bonus=referral_bonus,
final_end_date=final_end_date,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
db_user=db_user,
language=language,
)
@@ -0,0 +1,71 @@
from __future__ import annotations
import logging
from typing import Optional
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import payment_dal
from db.models import Payment
from .common import mark_payment_failed_creation, payment_failed, payment_link_response
async def finalize_webapp_link_payment(
*,
session: AsyncSession,
payment: Payment,
api_success: bool,
payment_url: Optional[str],
provider_payment_id: Optional[str] = None,
new_status: Optional[str] = None,
log_prefix: str,
) -> web.Response:
"""The trailing "persist id → return link or fail" used by every link-style webapp creator.
Mirrors :func:`render_link_or_fail` but for the webapp HTTP context: instead
of editing a Telegram message it returns either ``payment_link_response``
or ``payment_failed``. Provider modules just call:
payment = await create_webapp_payment_record(ctx, ...)
success, data = await service.create_xxx(...)
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(data, "url", "payment_url"),
provider_payment_id=first_value(data, "id"),
log_prefix="Wata",
)
"""
if api_success and provider_payment_id:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"%s: failed to persist provider payment id for payment %s.",
log_prefix,
payment.payment_id,
)
if not payment_url:
try:
await mark_payment_failed_creation(session, payment.payment_id)
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark payment %s as failed_creation.",
log_prefix,
payment.payment_id,
)
return payment_failed()
return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id)
@@ -0,0 +1,69 @@
from __future__ import annotations
import logging
from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import payment_dal, user_dal
from db.models import Payment
from .common import make_translator
def coerce_payment_db_id(order_id_raw: Any) -> Optional[int]:
"""Pull a numeric DB id out of a webhook's ``orderId``/``order_id`` field."""
if isinstance(order_id_raw, int):
return order_id_raw
if isinstance(order_id_raw, str) and order_id_raw.isdigit():
return int(order_id_raw)
return None
async def lookup_payment_by_order_or_provider_id(
session: AsyncSession,
*,
order_id_raw: Any = None,
provider_payment_id: Optional[str] = None,
) -> Optional[Payment]:
"""Find a payment by DB id first, fall back to provider id.
Returns ``None`` so callers stay in charge of the not-found response.
"""
payment_db_id = coerce_payment_db_id(order_id_raw)
payment: Optional[Payment] = None
if payment_db_id is not None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment and provider_payment_id:
payment = await payment_dal.get_payment_by_provider_payment_id(
session, provider_payment_id
)
return payment
async def notify_user_payment_failed(
*,
bot: Bot,
settings: Any,
i18n: Any,
session: AsyncSession,
payment: Payment,
message_key: str = "payment_failed",
) -> None:
"""Send the localized ``payment_failed`` text to the user; never raises."""
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
language = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
translator = make_translator(i18n, language)
try:
await bot.send_message(payment.user_id, translator(message_key))
except Exception:
logging.exception(
"Webhook helper: failed to notify user %s about %s.",
payment.user_id,
message_key,
)
+369
View File
@@ -0,0 +1,369 @@
import logging
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .shared import (
PaymentSuccessRequest,
create_webapp_payment_record,
describe_payment,
finalize_successful_payment,
format_number_for_payload,
make_translator,
notify_callback_parse_error,
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
safe_callback_answer,
sale_mode_base,
sale_mode_tariff_key,
)
class StarsService:
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.subscription_service = subscription_service
self.referral_service = referral_service
async def create_invoice(
self,
session: AsyncSession,
user_id: int,
months: int,
stars_price: int,
description: str,
sale_mode: str = "subscription",
) -> Optional[int]:
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"currency": "XTR",
"status": "pending_stars",
"description": description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
session, payment_record_data
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to create stars payment record")
return None
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
prices = [LabeledPrice(label=description, amount=stars_price)]
try:
await self.bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
# Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
provider_token="",
currency="XTR",
prices=prices,
)
return db_payment_record.payment_id
except Exception:
logging.exception("Failed to send Telegram Stars invoice")
return None
async def process_successful_payment(
self,
session: AsyncSession,
message: types.Message,
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict,
sale_mode: str = "subscription",
) -> None:
try:
payment_record = await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
message.successful_payment.provider_payment_charge_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to update stars payment record %s", payment_db_id)
return
target_user_id = (
int(payment_record.user_id)
if payment_record and payment_record.user_id is not None
else int(message.from_user.id)
)
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("Stars: payment %s vanished after status update.", payment_db_id)
return
await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=i18n_data.get("i18n_instance") or self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=target_user_id,
amount=float(stars_amount),
currency="XTR",
sale_mode=sale_mode,
months=months,
traffic_amount=float(months),
provider_subscription="telegram_stars",
provider_notification="stars",
log_prefix="Stars",
)
)
router = Router(name="user_subscription_payments_stars_router")
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not settings.STARS_ENABLED:
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
# ``parts.price`` for the Stars callback is the integer Stars price.
stars_price = int(parts.price)
payment_description = describe_payment(translator, parts)
payment_db_id = await stars_service.create_invoice(
session=session,
user_id=callback.from_user.id,
months=parts.months,
stars_price=stars_price,
description=payment_description,
sale_mode=parts.sale_mode,
)
if payment_db_id:
sale_base = parts.sale_base
text_key = (
"payment_invoice_sent_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_invoice_sent_message"
)
markup = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=translator("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{parts.human_value}",
)
]
]
)
try:
await callback.message.edit_text(
translator(
text_key,
months=int(parts.months),
traffic_gb=parts.human_value,
),
reply_markup=markup,
)
except Exception:
logging.warning("Stars payment: failed to show invoice info message")
await safe_callback_answer(callback)
return
await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True)
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
# Nothing else to do here; Telegram will show an error if not answered
pass
@router.message(F.successful_payment)
async def handle_successful_stars_payment(
message: types.Message,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
payload = (
message.successful_payment.invoice_payload if message and message.successful_payment else ""
)
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
months = float(parts[1]) if len(parts) > 1 else 0
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except Exception:
return
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
await stars_service.process_successful_payment(
session=session,
message=message,
payment_db_id=payment_db_id,
months=months,
stars_amount=stars_amount,
i18n_data=i18n_data,
sale_mode=sale_mode,
)
def create_service(ctx: ServiceFactoryContext) -> StarsService:
return StarsService(
ctx.bot,
ctx.settings,
ctx.i18n,
ctx.subscription_service,
ctx.referral_service,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if ctx.stars_price is None:
return payment_unavailable()
bot = ctx.request.app["bot"]
try:
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
payment = await create_webapp_payment_record(
ctx,
amount=float(ctx.stars_price),
currency="XTR",
status="pending_stars",
provider="telegram_stars",
)
payload_units = amounts.purchased_gb if amounts.traffic_sale else ctx.months
payload = (
f"{payment.payment_id}:{format_number_for_payload(payload_units)}:{ctx.sale_mode}"
)
prices = [LabeledPrice(label=ctx.description, amount=ctx.stars_price)]
create_invoice_link = getattr(bot, "create_invoice_link", None)
if callable(create_invoice_link):
invoice_url = await create_invoice_link(
title=ctx.description,
description=ctx.description,
payload=payload,
# Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "open_invoice",
"payment_url": invoice_url,
"payment_id": payment.payment_id,
}
)
await bot.send_invoice(
chat_id=ctx.user_id,
title=ctx.description,
description=ctx.description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "invoice_sent",
"payment_id": payment.payment_id,
}
)
except Exception:
await ctx.session.rollback()
logging.exception("Stars WebApp payment failed")
return payment_failed("Failed to create invoice")
SPEC = PaymentProviderSpec(
id="stars",
provider_key="telegram_stars",
label="Telegram Stars",
webapp_label="Telegram Stars",
webapp_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"},
webapp_icon="Sparkles",
telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"},
pending_status="pending_stars",
enabled=lambda settings: settings.STARS_ENABLED,
service_key="stars_service",
button_text_key="pay_with_stars_button",
callback_prefix="pay_stars",
router=router,
create_service=create_service,
create_webapp_payment=create_webapp_payment,
requires_configured_service=False,
price_source="stars",
emoji="",
telegram_emoji="",
)
+450
View File
@@ -0,0 +1,450 @@
import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from db.dal import payment_dal
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
lookup_payment_by_order_or_provider_id,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_unavailable,
post_json_request,
render_link_or_fail,
)
router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata"
class WataService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.WATA_BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/")
self.api_token = settings.WATA_API_TOKEN or ""
self.return_url = settings.WATA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.WATA_FAILED_URL or self.return_url
self.payment_link_ttl_days = settings.WATA_PAYMENT_LINK_TTL_DAYS
self.verify_webhook_signature = settings.WATA_WEBHOOK_VERIFY_SIGNATURE
self._public_key_pem = settings.WATA_PUBLIC_KEY
self._init_http_client(total_timeout=20)
self.configured: bool = bool(settings.WATA_ENABLED and self.api_token)
if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.")
def _auth_headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
async def create_payment_link(
self,
*,
payment_db_id: int,
amount: float,
currency: Optional[str],
description: str,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
"description": description,
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
"failRedirectUrl": self.failed_url,
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
}
return await post_json_request(
session,
f"{self.base_url}/links",
body=body,
headers=self._auth_headers(),
log_prefix="Wata create_payment_link",
)
async def _get_public_key_pem(self) -> Optional[str]:
if self._public_key_pem:
return self._public_key_pem.replace("\\n", "\n")
session = await self._get_session()
try:
async with session.get(f"{self.base_url}/public-key") as response:
if response.status != 200:
logging.error("Wata public key request failed with status %s", response.status)
return None
data = await response.json()
value = data.get("value") if isinstance(data, dict) else None
if isinstance(value, str) and value.strip():
self._public_key_pem = value
return value.replace("\\n", "\n")
except Exception:
logging.exception("Wata public key request failed.")
return None
async def _verify_signature(self, raw_body: bytes, signature_header: str) -> bool:
if not signature_header:
return False
public_key_pem = await self._get_public_key_pem()
if not public_key_pem:
return False
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
signature = base64.b64decode(signature_header)
public_key.verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA512())
return True
except (InvalidSignature, ValueError, TypeError):
logging.warning("Wata webhook: invalid signature.")
return False
except Exception:
logging.exception("Wata webhook: signature verification failed.")
return False
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="wata_disabled")
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if self.settings.wata_trusted_ips and not ip_in_allowlist(
client_ip, self.settings.wata_trusted_ips
):
logging.warning("Wata webhook denied from unauthorized IP source.")
return web.Response(status=403, text="forbidden")
raw_body = await request.read()
if self.verify_webhook_signature:
signature = request.headers.get("X-Signature", "")
if not await self._verify_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode("utf-8"))
except Exception:
logging.exception("Wata webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
transaction_id = str(payload.get("transactionId") or "").strip()
status = str(payload.get("transactionStatus") or "").strip().lower()
order_id_raw = payload.get("orderId")
amount_raw = payload.get("amount")
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not status or not (transaction_id or order_id_raw):
logging.error("Wata webhook: missing transaction status or ids: %s", payload)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await lookup_payment_by_order_or_provider_id(
session,
order_id_raw=order_id_raw,
provider_payment_id=transaction_id or None,
)
if not payment:
logging.error(
"Wata webhook: payment not found (order_id=%s, transaction_id=%s)",
order_id_raw,
transaction_id,
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "paid":
return web.Response(text="ok")
resolved_transaction_id = transaction_id or str(payment.payment_id)
if status == "paid":
if amount_raw is not None:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"Wata webhook: amount mismatch for payment %s "
"(expected %s, got %s)",
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"Wata webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_transaction_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to mark payment %s as succeeded.",
resolved_transaction_id,
)
return web.Response(status=500, text="processing_error")
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
provider_subscription="wata",
provider_notification="wata",
db_user=payment.user,
log_prefix="Wata webhook",
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status == "declined":
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_transaction_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to mark payment %s as failed.",
resolved_transaction_id,
)
return web.Response(status=500, text="processing_error")
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.Response(text="ok")
logging.warning(
"Wata webhook: unhandled status '%s' for transaction %s",
status,
transaction_id,
)
return web.Response(status=202, text="status_ignored")
@router.callback_query(F.data.startswith("pay_wata:"))
async def pay_wata_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
wata_service: WataService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not wata_service or not wata_service.configured:
logging.error("Wata service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_wata data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_wata",
description=payment_description,
months=parts.months,
provider="wata",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await wata_service.create_payment_link(
payment_db_id=payment_record.payment_id,
amount=parts.price,
currency=currency_code,
description=payment_description,
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=first_value(response_data, "url"),
provider_payment_id=first_value(response_data, "id"),
log_prefix=_LOG,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
settings: Settings = ctx.request.app["settings"]
service: WataService = ctx.request.app["wata_service"]
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=currency,
status="pending_wata",
provider="wata",
)
success, response_data = await service.create_payment_link(
payment_db_id=payment.payment_id,
amount=ctx.price,
currency=currency,
description=ctx.description,
)
except Exception:
await ctx.session.rollback()
logging.exception("Wata WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(response_data, "url") if success else None,
provider_payment_id=first_value(response_data, "id"),
log_prefix="Wata",
)
async def wata_webhook_route(request: web.Request) -> web.Response:
service: WataService = request.app["wata_service"]
return await service.webhook_route(request)
def create_service(ctx: ServiceFactoryContext) -> WataService:
return WataService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
SPEC = PaymentProviderSpec(
id="wata",
provider_key="wata",
label="Wata",
webapp_label="Wata",
webapp_labels={"ru": "Wata", "en": "Wata"},
webapp_icon="WalletCards",
telegram_labels={"ru": "Wata", "en": "Wata"},
telegram_emoji="💳",
pending_status="pending_wata",
enabled=lambda settings: settings.WATA_ENABLED,
service_key="wata_service",
button_text_key="pay_with_wata_button",
callback_prefix="pay_wata",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.wata_webhook_path,
webhook_route=wata_webhook_route,
create_webapp_payment=create_webapp_payment,
)
File diff suppressed because it is too large Load Diff
-466
View File
@@ -1,466 +0,0 @@
import asyncio
import hashlib
import hmac
import json
import logging
import time
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qsl
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
from sqlalchemy.orm import sessionmaker
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class FreeKassaService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
self.api_base_url: str = "https://api.fk.life/v1"
self._timeout = ClientTimeout(total=15)
self._session: Optional[ClientSession] = None
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
if not self.configured:
logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled."
)
if settings.FREEKASSA_ENABLED and not self.server_ip:
logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
)
@staticmethod
def _format_amount(amount: float) -> str:
"""Format amount for payloads and signature with two decimal places."""
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{quantized:.2f}"
async def create_order(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
email: Optional[str] = None,
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
ip_address = ip_address or self.server_ip
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
amount_str = self._format_amount(amount)
currency_code = (currency or self.default_currency or "RUB").upper()
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(payment_method_id),
"amount": amount_str,
"currency": currency_code,
"email": email,
"ip": ip_address,
"us_user_id": str(user_id),
"us_months": str(months),
"us_payment_db_id": str(payment_db_id),
}
if extra_params:
for key, value in extra_params.items():
if value is None:
continue
payload[key] = value
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
url = f"{self.api_base_url}/orders/create"
try:
async with session.post(url, json=payload) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error(
"FreeKassa create_order: failed to decode JSON: %s", response_text
)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if response.status != 200 or response_data.get("type") != "success":
logging.error(
"FreeKassa create_order: API returned error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("FreeKassa create_order: request failed.")
return False, {"message": str(exc)}
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def _generate_nonce(self) -> int:
async with self._nonce_lock:
candidate = int(time.time() * 1000)
if candidate <= self._last_nonce:
candidate = self._last_nonce + 1
self._last_nonce = candidate
return candidate
def _sign_payload(self, payload: Dict[str, Any]) -> str:
if not self.api_key:
raise RuntimeError("FreeKassa API key is not configured.")
items = [
(key, value)
for key, value in payload.items()
if key != "signature" and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
return hmac.new(
self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
def _validate_signature(
self,
raw_body: bytes,
provided_signature: str,
) -> bool:
if not provided_signature:
return False
if not self.second_secret:
return False
expected_signature = hmac.new(
self.second_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_signature, provided_signature)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="freekassa_disabled")
try:
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
return web.Response(status=403)
raw_body = await request.read()
except Exception:
logging.exception("FreeKassa webhook: failed to read request body.")
return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any] = {}
if raw_body:
try:
if request.content_type.startswith("application/json"):
decoded_json = json.loads(raw_body.decode("utf-8"))
if isinstance(decoded_json, dict):
payload_dict = {str(k): v for k, v in decoded_json.items()}
else:
payload_dict = {
str(key): value
for key, value in parse_qsl(
raw_body.decode("utf-8"), keep_blank_values=True
)
}
except Exception:
payload_dict = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id:
return web.Response(status=403)
signature = _get("SIGN") or _get("signature")
if not signature:
return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str:
return web.Response(status=400, text="missing_data")
if not self._validate_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payment_db_id = int(order_id_str)
except (TypeError, ValueError):
logging.error(f"FreeKassa webhook: invalid order_id value '{order_id_str}'")
return web.Response(status=400, text="invalid_order_id")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded":
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
return web.Response(text="YES")
# Optional amount verification
try:
amount_decimal = Decimal(amount_str)
expected_amount = Decimal(str(payment.amount)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
if (
amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
!= expected_amount
):
logging.warning(
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
f"(expected {expected_amount}, got {amount_decimal})"
)
except Exception as e:
logging.warning(
f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}"
)
activation = None
referral_bonus = None
try:
await payment_dal.update_provider_payment_and_status(
session=session,
payment_db_id=payment.payment_id,
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
new_status="succeeded",
)
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(months) if sale_base == "subscription" else int(float(months)),
float(payment.amount),
payment.payment_id,
provider="freekassa",
sale_mode=sale_mode,
traffic_gb=float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(months),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("FreeKassa webhook: failed to process payment %s.", payment_db_id)
return web.Response(status=500, text="processing_error")
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
self.settings, raw_config_link
)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
applied_days = 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
if not final_end and activation and activation.get("end_date"):
final_end = activation["end_date"]
if final_end:
end_date_str = final_end.strftime("%Y-%m-%d")
else:
end_date_str = _("config_link_not_available")
traffic_label = str(int(months)) if float(months).is_integer() else f"{months:g}"
if sale_mode.split("@", 1)[0].split("|", 1)[0] in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}:
text = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=end_date_str if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
text = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d")
if activation and activation.get("end_date")
else end_date_str,
bonus_days=applied_days,
final_end_date=end_date_str,
inviter_name=inviter_name_display,
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=months,
end_date=end_date_str,
config_link=config_link_text,
)
if provider_payment_id:
order_info_text = _(
"free_kassa_order_full",
order_id=provider_payment_id,
date=datetime.now().strftime("%Y-%m-%d"),
)
text = f"{order_info_text}\n{text}"
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception(
"FreeKassa notification: failed to send message to user %s.", payment.user_id
)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.default_currency,
months=int(months) if sale_base == "subscription" else 0,
traffic_gb=float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="freekassa",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("FreeKassa notification: failed to notify admins.")
return web.Response(text="YES")
async def freekassa_webhook_route(request: web.Request) -> web.Response:
service: FreeKassaService = request.app["freekassa_service"]
return await service.webhook_route(request)
+6 -9
View File
@@ -375,15 +375,12 @@ class NotificationService:
username=username,
)
provider_emoji = {
"wata": "💳",
"yookassa": "💳",
"freekassa": "💳",
"cryptopay": "",
"stars": "",
"platega": "💳",
"severpay": "💳",
}.get(payment_provider.lower(), "💰")
try:
from bot.payment_providers import provider_emoji_map
provider_emoji = provider_emoji_map(self.settings).get(payment_provider.lower(), "💰")
except Exception:
provider_emoji = "💰"
if traffic_gb is not None:
traffic_label = self._format_traffic_gb_admin(float(traffic_gb))
-421
View File
@@ -1,421 +0,0 @@
import hmac
import json
import logging
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
from sqlalchemy.orm import sessionmaker
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class PlategaService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
self.sbp_method = settings.platega_sbp_method_resolved
self.crypto_method = settings.PLATEGA_CRYPTO_METHOD
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
self._timeout = ClientTimeout(total=20)
self._session: Optional[ClientSession] = None
self._auth_headers = {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
self.configured: bool = bool(settings.PLATEGA_ENABLED and self.merchant_id and self.secret)
if not self.configured:
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
)
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
self.sbp_method,
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
self.crypto_method,
)
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
async def create_transaction(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
description: str,
payload: Optional[str] = None,
payment_method: Optional[int] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
"paymentMethod": method_id,
"paymentDetails": {"amount": float(amount), "currency": currency_code},
"description": description,
"return": self.return_url,
"failedUrl": self.failed_url,
"payload": payload,
}
# Remove optional keys with falsy values to avoid validation errors
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
safe_headers = {
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info(
"Platega create_transaction request: url=%s headers=%s body=%s",
url,
safe_headers,
clean_body,
)
try:
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error(
"Platega create_transaction: invalid JSON response: %s", response_text
)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if response.status != 200:
logging.error(
"Platega create_transaction: API returned error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("Platega create_transaction: request failed.")
return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="platega_disabled")
try:
data = await request.json()
except Exception:
logging.exception("Platega webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret")
if not (
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
):
logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden")
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
status = str(data.get("status") or "").upper()
amount_raw = data.get("amount")
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not transaction_id or not status:
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
if not payment:
logging.error(
"Platega webhook: payment not found for transaction %s", transaction_id
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if status == "CONFIRMED":
if amount_raw is not None:
try:
incoming_amount = Decimal(str(amount_raw)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
expected_amount = Decimal(str(payment.amount)).quantize(
Decimal("0.01"), rounding=ROUND_HALF_UP
)
if incoming_amount != expected_amount:
logging.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501
payment.payment_id,
expected_amount,
incoming_amount,
)
except Exception as exc:
logging.warning(
"Platega webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months)
if sale_base == "subscription"
else int(float(payment_months)),
float(payment.amount),
payment.payment_id,
provider="platega",
sale_mode=sale_mode,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
referral_bonus = (
await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(payment_months),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to process payment %s.", transaction_id
)
return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
self.settings, raw_config_link
)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
applied_days = 0
applied_promo_days = (
activation.get("applied_promo_bonus_days", 0) if activation else 0
)
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
traffic_label = (
str(int(payment_months))
if float(payment_months).is_integer()
else f"{payment_months:g}"
)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
text = _(
"payment_successful_with_referral_bonus_full",
months=payment_months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d")
if activation and activation.get("end_date")
else final_end.strftime("%Y-%m-%d")
if final_end
else "",
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_days and final_end:
text = _(
"payment_successful_with_promo_full",
months=payment_months,
bonus_days=applied_promo_days,
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=payment_months,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception("Platega webhook: failed to notify user %s.", payment.user_id)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=currency,
months=int(payment_months) if sale_base == "subscription" else 0,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="platega",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("Platega webhook: failed to notify admins.")
return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"canceled",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to cancel payment %s.", transaction_id
)
return web.Response(status=500, text="processing_error")
db_user = await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
return web.Response(text="ok_canceled")
logging.warning(
"Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id
)
return web.Response(status=202, text="status_ignored")
async def platega_webhook_route(request: web.Request) -> web.Response:
service: PlategaService = request.app["platega_service"]
return await service.webhook_route(request)
-444
View File
@@ -1,444 +0,0 @@
import hashlib
import hmac
import json
import logging
import secrets
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
from sqlalchemy.orm import sessionmaker
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class SeverPayService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip(
"/"
)
self.mid = settings.SEVERPAY_MID
self.token = settings.SEVERPAY_TOKEN or ""
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
self._timeout = ClientTimeout(total=15)
self._session: Optional[ClientSession] = None
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
if not self.configured:
logging.warning(
"SeverPayService initialized but not fully configured. Payments disabled."
)
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
@staticmethod
def _format_amount(amount: float) -> str:
quantized = Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
return f"{quantized:.2f}"
def _sign_payload(self, payload: Dict[str, Any]) -> str:
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return hmac.new(
self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
body: Dict[str, Any] = {
"mid": self.mid,
"salt": secrets.token_hex(8),
}
body.update(extra)
sorted_body = dict(sorted(body.items()))
sorted_body["sign"] = self._sign_payload(sorted_body)
return sorted_body
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
provided_sign = str(payload.get("sign") or "")
if not provided_sign or not self.token:
return False
# Webhook signatures are calculated on the original payload order (without sorting).
data = {k: v for k, v in payload.items() if k != "sign"}
expected_sign = self._sign_payload(data)
return hmac.compare_digest(provided_sign, expected_sign)
async def create_payment(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
description: str,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
amount_str = self._format_amount(amount)
body = {
"order_id": str(payment_db_id),
"amount": amount_str,
"currency": currency_code,
"client_email": f"{user_id}@telegram.org",
"client_id": str(user_id),
"url_return": self.return_url,
}
if self.lifetime_minutes:
body["lifetime"] = int(self.lifetime_minutes)
signed_body = self._build_signed_body(body)
try:
async with session.post(url, json=signed_body) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error(
"SeverPay create_payment: invalid JSON response: %s", response_text
)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if response.status != 200 or not response_data.get("status"):
logging.error(
"SeverPay create_payment: API returned error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data.get("data") or response_data
except Exception as exc:
logging.exception("SeverPay create_payment: request failed.")
return False, {"message": str(exc)}
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
try:
payload = await request.json()
except Exception:
logging.exception("SeverPay webhook: failed to parse JSON.")
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict) or not self._validate_signature(payload):
logging.error("SeverPay webhook: invalid signature or payload.")
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
event_type = str(payload.get("type") or "").lower()
data = payload.get("data") or {}
if event_type != "payin" or not isinstance(data, dict):
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
return web.json_response({"status": True})
provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower()
payment_db_id: Optional[int] = None
try:
if isinstance(order_id_raw, int):
payment_db_id = order_id_raw
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
payment_db_id = int(order_id_raw)
except Exception:
payment_db_id = None
async with self.async_session_factory() as session:
payment = None
if payment_db_id is not None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment and provider_payment_id:
payment = await payment_dal.get_payment_by_provider_payment_id(
session, provider_payment_id
)
if not payment:
logging.error(
"SeverPay webhook: payment not found (order_id=%s, provider_id=%s)",
order_id_raw,
provider_payment_id,
)
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if status == "success":
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"succeeded",
)
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_months)
if sale_base == "subscription"
else int(float(payment_months)),
float(payment.amount),
payment.payment_id,
provider="severpay",
sale_mode=sale_mode,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
referral_bonus = (
await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(payment_months),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to process payment %s.", provider_payment_id
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
self.settings, raw_config_link
)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
applied_days = 0
applied_promo_days = (
activation.get("applied_promo_bonus_days", 0) if activation else 0
)
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
traffic_label = (
str(int(payment_months))
if float(payment_months).is_integer()
else f"{payment_months:g}"
)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _(
"payment_successful_traffic_full",
traffic_gb=traffic_label,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username, with_at=False
)
text = _(
"payment_successful_with_referral_bonus_full",
months=payment_months,
base_end_date=activation["end_date"].strftime("%Y-%m-%d")
if activation and activation.get("end_date")
else final_end.strftime("%Y-%m-%d")
if final_end
else "",
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_days and final_end:
text = _(
"payment_successful_with_promo_full",
months=payment_months,
bonus_days=applied_promo_days,
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=payment_months,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception(
"SeverPay webhook: failed to notify user %s.", payment.user_id
)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=payment.currency,
months=int(payment_months) if sale_base == "subscription" else 0,
traffic_gb=float(payment_months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="severpay",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("SeverPay webhook: failed to notify admins.")
return web.json_response({"status": True})
if status in {"fail", "decline"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to mark payment %s as failed.",
provider_payment_id,
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
return web.json_response({"status": True})
if status in {"process", "new"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"pending_severpay",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to update pending status for %s.",
provider_payment_id,
)
return web.json_response({"status": True})
logging.warning(
"SeverPay webhook: unhandled status '%s' for payment %s",
status,
provider_payment_id,
)
return web.json_response({"status": True})
async def severpay_webhook_route(request: web.Request) -> web.Response:
service: SeverPayService = request.app["severpay_service"]
return await service.webhook_route(request)
-240
View File
@@ -1,240 +0,0 @@
import logging
from typing import Optional
from aiogram import Bot, types
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
from .notification_service import NotificationService
from .referral_service import ReferralService
from .subscription_service import SubscriptionService
class StarsService:
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.subscription_service = subscription_service
self.referral_service = referral_service
async def create_invoice(
self,
session: AsyncSession,
user_id: int,
months: int,
stars_price: int,
description: str,
sale_mode: str = "subscription",
) -> Optional[int]:
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"currency": "XTR",
"status": "pending_stars",
"description": description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode.split("@", 1)[1] if "@" in sale_mode else None,
"purchased_gb": float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
session, payment_record_data
)
await session.commit()
except Exception as e_db:
await session.rollback()
logging.error(f"Failed to create stars payment record: {e_db}", exc_info=True)
return None
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
prices = [LabeledPrice(label=description, amount=stars_price)]
try:
await self.bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
provider_token="", # Required to be empty for Telegram Stars (XTR) per Telegram Bot API. # noqa: E501
currency="XTR",
prices=prices,
)
return db_payment_record.payment_id
except Exception as e_inv:
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}", exc_info=True)
return None
async def process_successful_payment(
self,
session: AsyncSession,
message: types.Message,
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict,
sale_mode: str = "subscription",
) -> None:
try:
payment_record = await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
message.successful_payment.provider_payment_charge_id,
"succeeded",
)
target_user_id = (
int(payment_record.user_id)
if payment_record and payment_record.user_id is not None
else int(message.from_user.id)
)
await session.commit()
except Exception as e_upd:
await session.rollback()
logging.error(
f"Failed to update stars payment record {payment_db_id}: {e_upd}", exc_info=True
)
return
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
activation_details = await self.subscription_service.activate_subscription(
session,
target_user_id,
int(months) if sale_base == "subscription" else int(float(months)),
float(stars_amount),
payment_db_id,
provider="telegram_stars",
sale_mode=sale_mode,
traffic_gb=months
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
if not activation_details or not activation_details.get("end_date"):
logging.error(
f"Failed to activate subscription after stars payment for user {target_user_id}"
)
return
referral_bonus = None
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
target_user_id,
int(months) or 1,
current_payment_db_id=payment_db_id,
skip_if_active_before_payment=False,
)
await session.commit()
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
if not final_end:
final_end = activation_details["end_date"]
# Always use user's language from DB for user-facing messages
db_user = await user_dal.get_user_by_id(session, target_user_id)
current_lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
i18n: JsonI18n = i18n_data.get("i18n_instance")
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
raw_config_link = activation_details.get("subscription_url") if activation_details else None
config_link_display, connect_button_url = await prepare_config_links(
self.settings, raw_config_link
)
config_link_text = config_link_display or _("config_link_not_available")
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
success_msg = _(
"payment_successful_traffic_full",
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
db_user = await user_dal.get_user_by_id(session, target_user_id)
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name) if inviter.first_name else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(inviter.username, with_at=False)
success_msg = _(
"payment_successful_with_referral_bonus_full",
months=months,
base_end_date=activation_details["end_date"].strftime("%Y-%m-%d"),
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d"),
inviter_name=inviter_name_display,
config_link=config_link_text,
)
else:
success_msg = _(
"payment_successful_full",
months=months,
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
current_lang,
i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
message.from_user.id,
success_msg,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception as e_send:
logging.error(f"Failed to send stars payment success message: {e_send}")
# Send notification about payment
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
user = await user_dal.get_user_by_id(session, target_user_id)
await notification_service.notify_payment_received(
user_id=target_user_id,
amount=float(stars_amount),
currency="XTR",
months=int(months) if sale_base == "subscription" else 0,
payment_provider="stars",
username=user.username if user else None,
traffic_gb=float(months)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment_record, "tariff_key", None),
)
except Exception as e:
logging.error(f"Failed to send stars payment notification: {e}")
@@ -85,6 +85,14 @@ class PaymentContextMixin:
return
end_date_text = end_date.strftime("%Y-%m-%d") if end_date else ""
try:
from bot.payment_providers import provider_label_map
provider_label = provider_label_map(
self.settings,
db_user.language_code or self.settings.DEFAULT_LANGUAGE,
).get((provider or "").lower())
except Exception:
provider_label = self._PROVIDER_LABELS.get((provider or "").lower())
dashboard_url = (self.settings.SUBSCRIPTION_MINI_APP_URL or "").strip() or None
-489
View File
@@ -1,489 +0,0 @@
import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot
from aiohttp import ClientSession, ClientTimeout, web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from sqlalchemy.orm import sessionmaker
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.middlewares.i18n import JsonI18n
from bot.services.notification_service import NotificationService
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.config_link import prepare_config_links
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from config.settings import Settings
from db.dal import payment_dal, user_dal
class WataService:
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.WATA_BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/")
self.api_token = settings.WATA_API_TOKEN or ""
self.return_url = settings.WATA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.WATA_FAILED_URL or self.return_url
self.payment_link_ttl_days = settings.WATA_PAYMENT_LINK_TTL_DAYS
self.verify_webhook_signature = settings.WATA_WEBHOOK_VERIFY_SIGNATURE
self._public_key_pem = settings.WATA_PUBLIC_KEY
self._timeout = ClientTimeout(total=20)
self._session: Optional[ClientSession] = None
self.configured: bool = bool(settings.WATA_ENABLED and self.api_token)
if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.")
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
@staticmethod
def _format_amount(amount: float) -> Decimal:
return Decimal(str(amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
def _auth_headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
async def create_payment_link(
self,
*,
payment_db_id: int,
amount: float,
currency: Optional[str],
description: str,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
body: Dict[str, Any] = {
"amount": float(self._format_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
"description": description,
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
"failRedirectUrl": self.failed_url,
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
}
try:
async with session.post(
f"{self.base_url}/links",
json=body,
headers=self._auth_headers(),
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("Wata create_payment_link: invalid JSON: %s", response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if response.status != 200:
logging.error(
"Wata create_payment_link: API error (status=%s, body=%s)",
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("Wata create_payment_link: request failed.")
return False, {"message": str(exc)}
async def _get_public_key_pem(self) -> Optional[str]:
if self._public_key_pem:
return self._public_key_pem.replace("\\n", "\n")
session = await self._get_session()
try:
async with session.get(f"{self.base_url}/public-key") as response:
if response.status != 200:
logging.error("Wata public key request failed with status %s", response.status)
return None
data = await response.json()
value = data.get("value") if isinstance(data, dict) else None
if isinstance(value, str) and value.strip():
self._public_key_pem = value
return value.replace("\\n", "\n")
except Exception:
logging.exception("Wata public key request failed.")
return None
async def _verify_signature(self, raw_body: bytes, signature_header: str) -> bool:
if not signature_header:
return False
public_key_pem = await self._get_public_key_pem()
if not public_key_pem:
return False
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
signature = base64.b64decode(signature_header)
public_key.verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA512())
return True
except (InvalidSignature, ValueError, TypeError):
logging.warning("Wata webhook: invalid signature.")
return False
except Exception:
logging.exception("Wata webhook: signature verification failed.")
return False
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="wata_disabled")
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if self.settings.wata_trusted_ips and not ip_in_allowlist(
client_ip, self.settings.wata_trusted_ips
):
logging.warning("Wata webhook denied from unauthorized IP source.")
return web.Response(status=403, text="forbidden")
raw_body = await request.read()
if self.verify_webhook_signature:
signature = request.headers.get("X-Signature", "")
if not await self._verify_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode("utf-8"))
except Exception:
logging.exception("Wata webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
transaction_id = str(payload.get("transactionId") or "").strip()
status = str(payload.get("transactionStatus") or "").strip().lower()
order_id_raw = payload.get("orderId")
amount_raw = payload.get("amount")
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not status or not (transaction_id or order_id_raw):
logging.error("Wata webhook: missing transaction status or ids: %s", payload)
return web.Response(status=400, text="missing_fields")
payment_db_id: Optional[int] = None
if isinstance(order_id_raw, int):
payment_db_id = order_id_raw
elif isinstance(order_id_raw, str) and order_id_raw.isdigit():
payment_db_id = int(order_id_raw)
async with self.async_session_factory() as session:
payment = None
if payment_db_id is not None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment and transaction_id:
payment = await payment_dal.get_payment_by_provider_payment_id(
session, transaction_id
)
if not payment:
logging.error(
"Wata webhook: payment not found (order_id=%s, transaction_id=%s)",
order_id_raw,
transaction_id,
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "paid":
return web.Response(text="ok")
if status == "paid":
return await self._process_paid_payment(
session=session,
payment=payment,
transaction_id=transaction_id or str(payment.payment_id),
amount_raw=amount_raw,
currency=str(currency),
)
if status == "declined":
return await self._process_declined_payment(
session=session,
payment=payment,
transaction_id=transaction_id or str(payment.payment_id),
)
logging.warning(
"Wata webhook: unhandled status '%s' for transaction %s",
status,
transaction_id,
)
return web.Response(status=202, text="status_ignored")
async def _process_paid_payment(
self,
*,
session,
payment,
transaction_id: str,
amount_raw: Any,
currency: str,
) -> web.Response:
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
sale_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
if amount_raw is not None:
try:
incoming_amount = self._format_amount(float(amount_raw))
expected_amount = self._format_amount(float(payment.amount))
if incoming_amount != expected_amount:
logging.warning(
"Wata webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_amount,
incoming_amount,
)
except Exception as exc:
logging.warning(
"Wata webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
activation = await self.subscription_service.activate_subscription(
session,
payment.user_id,
int(payment_units) if sale_base == "subscription" else int(float(payment_units)),
float(payment.amount),
payment.payment_id,
provider="wata",
sale_mode=sale_mode,
traffic_gb=float(payment_units)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
)
referral_bonus = None
if sale_base == "subscription":
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
session,
payment.user_id,
int(payment_units),
current_payment_db_id=payment.payment_id,
skip_if_active_before_payment=False,
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Wata webhook: failed to process payment %s.", transaction_id)
return web.Response(status=500, text="processing_error")
await self._notify_success(
session=session,
payment=payment,
payment_units=payment_units,
sale_base=sale_base,
activation=activation,
referral_bonus=referral_bonus,
currency=currency,
)
return web.Response(text="ok")
async def _process_declined_payment(self, *, session, payment, transaction_id: str):
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Wata webhook: failed to mark payment %s as failed.", transaction_id)
return web.Response(status=500, text="processing_error")
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
logging.exception("Wata webhook: failed to notify user about failed payment.")
return web.Response(text="ok")
async def _notify_success(
self,
*,
session,
payment,
payment_units: float,
sale_base: str,
activation: Optional[Dict[str, Any]],
referral_bonus: Optional[Dict[str, Any]],
currency: str,
) -> None:
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
lang = (
db_user.language_code
if db_user and db_user.language_code
else self.settings.DEFAULT_LANGUAGE
)
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
self.settings,
raw_config_link,
)
config_link_text = config_link_display or _("config_link_not_available")
final_end = activation.get("end_date") if activation else None
applied_days = 0
applied_promo_days = activation.get("applied_promo_bonus_days", 0) if activation else 0
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end = referral_bonus["referee_new_end_date"]
applied_days = referral_bonus.get("referee_bonus_applied_days", 0)
units_label = (
str(int(payment_units)) if float(payment_units).is_integer() else f"{payment_units:g}"
)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
text = _(
"payment_successful_traffic_full",
traffic_gb=units_label,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
elif applied_days:
inviter_name_display = _("friend_placeholder")
if db_user and db_user.referred_by_id:
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if inviter:
safe_name = (
sanitize_display_name(inviter.first_name)
if inviter.first_name
else None
)
if safe_name:
inviter_name_display = safe_name
elif inviter.username:
inviter_name_display = username_for_display(
inviter.username,
with_at=False,
)
text = _(
"payment_successful_with_referral_bonus_full",
months=payment_units,
base_end_date=activation["end_date"].strftime("%Y-%m-%d")
if activation and activation.get("end_date")
else final_end.strftime("%Y-%m-%d")
if final_end
else "",
bonus_days=applied_days,
final_end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
inviter_name=inviter_name_display,
config_link=config_link_text,
)
elif applied_promo_days and final_end:
text = _(
"payment_successful_with_promo_full",
months=payment_units,
bonus_days=applied_promo_days,
end_date=final_end.strftime("%Y-%m-%d"),
config_link=config_link_text,
)
else:
text = _(
"payment_successful_full",
months=payment_units,
end_date=final_end.strftime("%Y-%m-%d") if final_end else "",
config_link=config_link_text,
)
markup = get_connect_and_main_keyboard(
lang,
self.i18n,
self.settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await self.bot.send_message(
payment.user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception("Wata webhook: failed to notify user %s.", payment.user_id)
try:
notification_service = NotificationService(self.bot, self.settings, self.i18n)
await notification_service.notify_payment_received(
user_id=payment.user_id,
amount=float(payment.amount),
currency=currency,
months=int(payment_units) if sale_base == "subscription" else 0,
traffic_gb=float(payment_units)
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else None,
payment_provider="wata",
username=db_user.username if db_user else None,
traffic_is_premium=sale_base == "premium_topup",
tariff_key=getattr(payment, "tariff_key", None),
)
except Exception:
logging.exception("Wata webhook: failed to notify admins.")
async def wata_webhook_route(request: web.Request) -> web.Response:
service: WataService = request.app["wata_service"]
return await service.webhook_route(request)
-268
View File
@@ -1,268 +0,0 @@
import asyncio
import logging
import uuid
from typing import Any, Dict, List, Optional
from yookassa import Configuration
from yookassa import Payment as YooKassaPayment
from yookassa.domain.common.confirmation_type import ConfirmationType
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from config.settings import Settings
class YooKassaService:
def __init__(
self,
shop_id: Optional[str],
secret_key: Optional[str],
configured_return_url: Optional[str],
bot_username_for_default_return: Optional[str] = None,
settings_obj: Optional[Settings] = None,
):
self.settings = settings_obj
if self.settings and not self.settings.YOOKASSA_ENABLED:
logging.warning(
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501
)
self.configured = False
elif not shop_id or not secret_key:
logging.warning(
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
"Payment functionality will be DISABLED."
)
self.configured = False
else:
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logging.info(f"YooKassa SDK configured for shop_id: {shop_id[:5]}...")
except Exception:
logging.exception("Failed to configure YooKassa SDK.")
self.configured = False
if configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}"
logging.info(
f"YOOKASSA_RETURN_URL not set, using dynamic default based on bot username: {self.return_url}" # noqa: E501
)
else:
self.return_url = "https://example.com/payment_error_no_return_url_configured"
logging.warning(
f"CRITICAL: YOOKASSA_RETURN_URL not set AND bot username not provided. "
f"Using placeholder: {self.return_url}. Payments may not complete correctly."
)
logging.info(f"YooKassa Service effective return_url for payments: {self.return_url}")
async def create_payment(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
save_payment_method: bool = False,
payment_method_id: Optional[str] = None,
capture: bool = True,
bind_only: bool = False,
) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error("YooKassa is not configured. Cannot create payment.")
return None
if not self.settings:
logging.error(
"YooKassaService: Settings object not available. Cannot create payment with receipt details." # noqa: E501
)
return {
"error": True,
"internal_message": "Service settings (Settings object) not initialized.",
}
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt["email"] = self.settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logging.error(
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." # noqa: E501
)
return {
"error": True,
"internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", # noqa: E501
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({"value": str(round(amount, 2)), "currency": currency.upper()})
# For binding cards only, do not capture and set minimal amount
if bind_only:
capture = False
amount = max(amount, 1.00)
builder.set_capture(capture)
if not payment_method_id:
# Saved payment_method_id charges must omit confirmation per YooKassa API
builder.set_confirmation(
{"type": ConfirmationType.REDIRECT, "return_url": self.return_url}
)
builder.set_description(description)
builder.set_metadata(metadata)
if save_payment_method:
# Ask YooKassa to save method for off-session charges
builder.set_save_payment_method(True)
if payment_method_id:
# Use a previously saved payment method for merchant-initiated payments
builder.set_payment_method_id(payment_method_id)
receipt_items_list: List[Dict[str, Any]] = [
{
"description": description[:128],
"quantity": "1.00",
"amount": {"value": str(round(amount, 2)), "currency": currency.upper()},
"vat_code": str(self.settings.YOOKASSA_VAT_CODE),
"payment_mode": getattr(
self.settings,
"yk_receipt_payment_mode",
self.settings.YOOKASSA_PAYMENT_MODE,
),
"payment_subject": getattr(
self.settings,
"yk_receipt_payment_subject",
self.settings.YOOKASSA_PAYMENT_SUBJECT,
),
}
]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list,
}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logging.info(
f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). "
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
)
response = await asyncio.to_thread(
YooKassaPayment.create,
payment_request,
idempotence_key,
)
logging.info(
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" # noqa: E501
)
return {
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url
if response.confirmation
else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat()
if hasattr(response.created_at, "isoformat")
else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, "test") else None,
"payment_method": getattr(response, "payment_method", None),
}
except Exception:
logging.exception("YooKassa payment creation failed.")
return None
async def get_payment_info(self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logging.error("YooKassa is not configured. Cannot get payment info.")
return None
try:
logging.info(f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}")
payment_info_yk = await asyncio.to_thread(
YooKassaPayment.find_one,
payment_id_in_yookassa,
)
if payment_info_yk:
logging.info(
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" # noqa: E501
)
pm = getattr(payment_info_yk, "payment_method", None)
pm_payload: Dict[str, Any] = {}
if pm:
# Collect common fields, including id and hints for last4
pm_id = getattr(pm, "id", None)
pm_type = getattr(pm, "type", None)
pm_title = getattr(pm, "title", None)
account_number = getattr(pm, "account_number", None) or getattr(
pm, "account", None
)
card_obj = getattr(pm, "card", None)
last4_val = None
if card_obj and hasattr(card_obj, "last4"):
last4_val = getattr(card_obj, "last4")
elif isinstance(account_number, str) and len(account_number) >= 4:
last4_val = account_number[-4:]
pm_payload = {
"id": pm_id,
"type": pm_type,
"title": pm_title,
"card_last4": last4_val,
}
return {
"id": payment_info_yk.id,
"status": payment_info_yk.status,
"paid": payment_info_yk.paid,
"amount_value": float(payment_info_yk.amount.value),
"amount_currency": payment_info_yk.amount.currency,
"metadata": payment_info_yk.metadata,
"description": payment_info_yk.description,
"refundable": payment_info_yk.refundable,
"created_at": payment_info_yk.created_at.isoformat()
if hasattr(payment_info_yk.created_at, "isoformat")
else str(payment_info_yk.created_at),
"captured_at": payment_info_yk.captured_at.isoformat()
if getattr(payment_info_yk, "captured_at", None)
and hasattr(payment_info_yk.captured_at, "isoformat")
else None,
"payment_method": pm_payload,
"test_mode": getattr(payment_info_yk, "test", None),
}
else:
logging.warning(
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
)
return None
except Exception:
logging.exception("YooKassa get payment info for %s failed.", payment_id_in_yookassa)
return None
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
if not self.configured:
logging.error("YooKassa is not configured. Cannot cancel payment.")
return False
try:
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
return True
except Exception:
logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa)
return False
+48
View File
@@ -289,6 +289,54 @@ class Settings(BaseSettings):
default=None,
description="Comma-separated list of payment methods to show (e.g., severpay,wata,freekassa,yookassa,platega,stars,cryptopay)", # noqa: E501
)
PAYMENT_FREEKASSA_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_FREEKASSA_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_FREEKASSA_WEBAPP_ICON: Optional[str] = None
PAYMENT_FREEKASSA_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_FREEKASSA_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_FREEKASSA_TELEGRAM_EMOJI: Optional[str] = None
PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_PLATEGA_SBP_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_PLATEGA_SBP_WEBAPP_ICON: Optional[str] = None
PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_PLATEGA_SBP_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_PLATEGA_SBP_TELEGRAM_EMOJI: Optional[str] = None
PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_PLATEGA_CRYPTO_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_PLATEGA_CRYPTO_WEBAPP_ICON: Optional[str] = None
PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_PLATEGA_CRYPTO_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_PLATEGA_CRYPTO_TELEGRAM_EMOJI: Optional[str] = None
PAYMENT_SEVERPAY_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_SEVERPAY_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_SEVERPAY_WEBAPP_ICON: Optional[str] = None
PAYMENT_SEVERPAY_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_SEVERPAY_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_SEVERPAY_TELEGRAM_EMOJI: Optional[str] = None
PAYMENT_WATA_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_WATA_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_WATA_WEBAPP_ICON: Optional[str] = None
PAYMENT_WATA_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_WATA_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_WATA_TELEGRAM_EMOJI: Optional[str] = None
PAYMENT_YOOKASSA_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_YOOKASSA_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_YOOKASSA_WEBAPP_ICON: Optional[str] = None
PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_YOOKASSA_TELEGRAM_EMOJI: Optional[str] = None
PAYMENT_STARS_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_STARS_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_STARS_WEBAPP_ICON: Optional[str] = None
PAYMENT_STARS_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_STARS_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_STARS_TELEGRAM_EMOJI: Optional[str] = None
PAYMENT_CRYPTOPAY_WEBAPP_LABEL_RU: Optional[str] = None
PAYMENT_CRYPTOPAY_WEBAPP_LABEL_EN: Optional[str] = None
PAYMENT_CRYPTOPAY_WEBAPP_ICON: Optional[str] = None
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_RU: Optional[str] = None
PAYMENT_CRYPTOPAY_TELEGRAM_LABEL_EN: Optional[str] = None
PAYMENT_CRYPTOPAY_TELEGRAM_EMOJI: Optional[str] = None
MONTH_1_ENABLED: bool = Field(default=True, alias="1_MONTH_ENABLED")
MONTH_3_ENABLED: bool = Field(default=True, alias="3_MONTHS_ENABLED")
+4 -4
View File
@@ -13,16 +13,16 @@ import db.database_setup as database_setup
from app_logging import configure_logging
from bot.app.factories.build_services import build_core_services
from bot.handlers.admin.sync_admin import perform_sync
from bot.handlers.user.payment import (
from bot.infra.redis import close_redis, redis_lock
from bot.infra.webhook_queue import pop_webhook_event, webhook_queue_depth
from bot.middlewares.i18n import get_i18n_instance
from bot.payment_providers.yookassa import (
YOOKASSA_EVENT_PAYMENT_CANCELED,
YOOKASSA_EVENT_PAYMENT_SUCCEEDED,
payment_processing_lock,
process_cancelled_payment,
process_successful_payment,
)
from bot.infra.redis import close_redis, redis_lock
from bot.infra.webhook_queue import pop_webhook_event, webhook_queue_depth
from bot.middlewares.i18n import get_i18n_instance
from bot.services.settings_override_service import load_overrides_from_db
from bot.services.tariff_worker import TariffTrafficWorker
from bot.utils.message_queue import init_queue_manager
+3 -1
View File
@@ -42,11 +42,13 @@
- общие параметры: язык, валюта, ссылки поддержки, документы, обязательный канал и поведение `/start`;
- внешний вид и доступность Web App: название, цвет, логотип, emoji-логотип и `WEBAPP_ENABLED`;
- legacy-цены без JSON-каталога: периоды подписки, RUB/Stars цены и пакеты трафика;
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay и Stars;
- платежные провайдеры: включение методов, порядок кнопок, публичные параметры и секреты YooKassa, FreeKassa, Platega, SeverPay, Wata, CryptoPay и Stars, а также текст и иконки кнопок оплаты;
- пробный период, реферальные бонусы, уведомления, логирование, раздел устройств, лимит устройств и legacy-лимиты трафика.
Секретные поля помечены как secret и не должны использоваться для произвольного просмотра старых значений. Настройки, которых нет в manifest, остаются только в `.env` или коде.
Для каждого платежного метода в разделе провайдера доступны presentation-настройки `PAYMENT_<METHOD>_WEBAPP_LABEL_RU`, `PAYMENT_<METHOD>_WEBAPP_LABEL_EN`, `PAYMENT_<METHOD>_WEBAPP_ICON`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU`, `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` и `PAYMENT_<METHOD>_TELEGRAM_EMOJI`. Пустое значение возвращает мультиязычный дефолт из модуля платежного провайдера. Иконка Web App выбирается из уже подключённых lucide-иконок (`frontend/src/lib/components/ui/icons.js`) через модалку в админке.
## Внешний вид
Раздел **Внешний вид** объединяет настройки бренда и темы Web App. Логотип можно загрузить файлом или по HTTPS-ссылке; backend сохраняет файл в `data/webapp-logo/uploads` и подставляет локальный URL. Если включен emoji-логотип, картинка скрывается, а для emoji можно выбрать системный, Twemoji, Noto Color, animated Noto и другие варианты отрисовки.
+2
View File
@@ -49,6 +49,8 @@ nano .env
| Переменная | Назначение |
| --- | --- |
| `PAYMENT_METHODS_ORDER` | Порядок кнопок оплаты через запятую: `severpay`, `wata`, `freekassa`, `platega`, `yookassa`, `stars`, `cryptopay`. |
| `PAYMENT_<METHOD>_WEBAPP_LABEL_RU` / `PAYMENT_<METHOD>_WEBAPP_LABEL_EN` / `PAYMENT_<METHOD>_WEBAPP_ICON` | Необязательная мультиязычная кастомизация текста и lucide-иконки кнопки оплаты в Web App. |
| `PAYMENT_<METHOD>_TELEGRAM_LABEL_RU` / `PAYMENT_<METHOD>_TELEGRAM_LABEL_EN` / `PAYMENT_<METHOD>_TELEGRAM_EMOJI` | Необязательная мультиязычная кастомизация текста и эмодзи кнопки оплаты в Telegram-боте. |
| `YOOKASSA_ENABLED` | Включает YooKassa. |
| `YOOKASSA_SHOP_ID` / `YOOKASSA_SECRET_KEY` | Данные магазина YooKassa. |
| `YOOKASSA_RETURN_URL` | URL возврата пользователя после оплаты. |
@@ -1,6 +1,8 @@
<script>
import { ChevronRight, Eye, EyeOff, X } from "$components/ui/icons.js";
import { ChevronRight, Eye, EyeOff, Search, X } from "$components/ui/icons.js";
import * as UiIcons from "$components/ui/icons.js";
import { Accordion, Switch } from "$components/ui/primitives.js";
import Dialog from "$components/ui/dialog.svelte";
import {
AdminBadge,
AdminButton,
@@ -22,10 +24,18 @@
let settingsOpenSections = [];
let settingsOpenSubsections = {};
let revealedSecrets = new Set();
let iconPickerField = null;
let iconPickerSearch = "";
$: settingsAllOpen =
visibleSettingsSections.length > 0 &&
settingsOpenSections.length === visibleSettingsSections.length;
$: iconOptions = Object.keys(UiIcons)
.filter((name) => /^[A-Z]/.test(name))
.sort((a, b) => a.localeCompare(b));
$: filteredIconOptions = iconOptions.filter((name) =>
name.toLowerCase().includes(iconPickerSearch.trim().toLowerCase())
);
onMount(() => {
settingsStore.loadSettings().then(() => {
@@ -75,6 +85,27 @@
return field.placeholder || at("settings_secret_empty", {}, "Not set");
}
function iconComponent(name) {
const key = String(name || "").trim();
return key ? UiIcons[key] || null : null;
}
function openIconPicker(field) {
iconPickerField = field;
iconPickerSearch = "";
}
function closeIconPicker() {
iconPickerField = null;
iconPickerSearch = "";
}
function selectIcon(name) {
if (!iconPickerField) return;
settingsStore.markDirty(iconPickerField.key, name);
closeIconPicker();
}
function groupSectionFields(section) {
const groups = new Map();
for (const field of section.fields || []) {
@@ -171,14 +202,37 @@
class="admin-color"
type="color"
value={valueFor(field) || "#00fe7a"}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<input
class="input"
type="text"
value={valueFor(field) || ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.type === "icon"}
{@const selectedIconName = valueFor(field) || ""}
{@const SelectedIcon = iconComponent(selectedIconName)}
<AdminButton
class="admin-icon-picker-trigger"
variant="ghost"
onclick={() => openIconPicker(field)}
>
{#if SelectedIcon}
<svelte:component this={SelectedIcon} size={16} />
{/if}
<span>{selectedIconName || at("settings_icon_empty", {}, "Default icon")}</span>
</AdminButton>
{#if selectedIconName}
<AdminButton
size="sm"
variant="ghost"
onclick={() => settingsStore.markDirty(field.key, "")}
>
<X size={12} />
{at("clear", {}, "Clear")}
</AdminButton>
{/if}
{:else if field.choices && field.choices.length > 0}
<AdminSelect
class="admin-setting-select"
@@ -195,7 +249,7 @@
step={field.type === "float" ? "0.1" : "1"}
placeholder={field.placeholder}
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{:else if field.secret}
<input
@@ -204,7 +258,7 @@
placeholder={secretPlaceholder(field)}
autocomplete="off"
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
<AdminButton
size="sm"
@@ -220,7 +274,7 @@
type="text"
placeholder={field.placeholder}
value={valueFor(field) ?? ""}
on:input={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
oninput={(e) => settingsStore.markDirty(field.key, e.currentTarget.value)}
/>
{/if}
{#if isOverridden(field) || settingsDirty[field.key]}
@@ -360,3 +414,40 @@
{/each}
</Accordion.Root>
{/if}
<Dialog
open={Boolean(iconPickerField)}
title={at("settings_icon_picker_title", {}, "Choose icon")}
description={iconPickerField ? fieldLabelText(iconPickerField) : ""}
closeLabel={at("close", {}, "Close")}
onclose={closeIconPicker}
class="admin-icon-picker-dialog"
>
<div class="admin-icon-picker-body">
<label class="admin-icon-picker-search">
<Search size={15} />
<input
bind:value={iconPickerSearch}
class="input"
type="text"
placeholder={at("search", {}, "Search")}
/>
</label>
<div class="admin-icon-picker-grid">
{#each filteredIconOptions as iconName}
{@const Icon = iconComponent(iconName)}
<button
class:active={iconPickerField && valueFor(iconPickerField) === iconName}
class="admin-icon-picker-option"
type="button"
onclick={() => selectIcon(iconName)}
>
{#if Icon}
<svelte:component this={Icon} size={18} />
{/if}
<span>{iconName}</span>
</button>
{/each}
</div>
</div>
</Dialog>
@@ -1,33 +1,24 @@
<script>
import { Bitcoin, CreditCard } from "$components/ui/icons.js";
import * as Icons from "$components/ui/icons.js";
export let methods = [];
export let selectedMethod = "";
export let t = (key) => key;
export let onSelect = () => {};
function methodMeta(method) {
const id = String(method?.id || "").toLowerCase();
if (id.includes("platega_sbp"))
return { title: t("wa_method_platega_sbp_card"), icon: CreditCard };
if (id.includes("platega_crypto"))
return { title: t("wa_method_platega_crypto"), icon: Bitcoin };
if (id.includes("yookassa") || id.includes("card"))
return { title: t("pay_with_yookassa_button"), icon: null };
if (id.includes("severpay")) return { title: t("pay_with_severpay_button"), icon: null };
if (id.includes("wata")) return { title: t("pay_with_wata_button"), icon: null };
if (id.includes("freekassa")) return { title: t("pay_with_sbp_button"), icon: null };
if (id.includes("cryptopay") || id.includes("crypto"))
return { title: t("pay_with_cryptopay_button"), icon: null };
if (id.includes("stars")) return { title: t("pay_with_stars_button"), icon: null };
if (id.includes("sbp")) return { title: t("pay_with_sbp_button"), icon: null };
return { title: t("wa_method_other_title"), icon: null };
function methodTitle(method) {
return method?.name || t("wa_method_other_title");
}
function methodIcon(method) {
const iconName = String(method?.icon || "").trim();
return iconName ? Icons[iconName] || null : null;
}
</script>
<div class="method-grid">
{#each methods as method}
{@const meta = methodMeta(method)}
{@const icon = methodIcon(method)}
<button
class:active={selectedMethod === method.id}
class="method-card"
@@ -35,10 +26,10 @@
onclick={() => onSelect(method.id)}
>
<span class="method-card-main">
{#if meta.icon}
<svelte:component this={meta.icon} size={19} />
{#if icon}
<svelte:component this={icon} size={19} />
{/if}
<strong>{meta.title}</strong>
<strong>{methodTitle(method)}</strong>
</span>
</button>
{/each}
+1
View File
@@ -42,6 +42,7 @@ export {
RefreshCw,
Repeat2,
Save,
Search,
Send,
Server,
Settings,
+4 -4
View File
@@ -162,10 +162,10 @@ export const DEV_MOCK = {
{ months: 12, price: 2690, currency: "RUB", title: "12 месяцев" },
],
payment_methods: [
{ id: "yookassa", name: "Карта" },
{ id: "platega_sbp", name: "Telegram Pay" },
{ id: "cryptopay", name: "Криптовалюта" },
{ id: "freekassa", name: "Другие способы" },
{ id: "yookassa", name: "Карта", icon: "CreditCard" },
{ id: "platega_sbp", name: "Telegram Pay", icon: "CreditCard" },
{ id: "cryptopay", name: "Криптовалюта", icon: "Bitcoin" },
{ id: "freekassa", name: "Другие способы", icon: "Smartphone" },
],
referral: {
code: "ABCD1234",
+75
View File
@@ -1880,6 +1880,81 @@
min-width: 0;
}
.admin-icon-picker-trigger {
min-width: 180px;
justify-content: flex-start;
}
.admin-icon-picker-trigger span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-icon-picker-dialog {
width: min(100%, 720px);
}
.admin-icon-picker-body {
display: grid;
gap: 14px;
}
.admin-icon-picker-search {
position: relative;
display: block;
}
.admin-icon-picker-search > svg {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--admin-muted);
pointer-events: none;
}
.admin-icon-picker-search .input {
width: 100%;
padding-left: 36px;
}
.admin-icon-picker-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
gap: 8px;
max-height: min(52vh, 460px);
overflow-y: auto;
padding-right: 4px;
}
.admin-icon-picker-option {
display: grid;
grid-template-columns: 20px minmax(0, 1fr);
align-items: center;
gap: 8px;
min-height: 42px;
border: 1px solid var(--admin-border);
border-radius: 8px;
background: var(--surface-muted);
color: var(--admin-text);
padding: 9px 10px;
text-align: left;
cursor: pointer;
}
.admin-icon-picker-option span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-icon-picker-option:hover,
.admin-icon-picker-option.active {
border-color: color-mix(in srgb, var(--accent) 45%, var(--admin-border));
background: color-mix(in srgb, var(--accent) 12%, var(--surface-muted));
}
.admin-setting-control .admin-color {
width: 36px;
height: 36px;
+1 -1
View File
@@ -9,7 +9,7 @@ Prior to the fix, ``charge_subscription_renewal`` did::
yk = None
There is no ``yookassa_service`` module inside ``subscription_service_impl``
(the real one lives in ``bot.services.yookassa_service``), so the import
(the real implementation lives in ``bot.payment_providers.yookassa``), so the import
always raised ``ModuleNotFoundError``, ``yk`` became ``None``, and every
auto-renew silently logged ``YooKassa unavailable for auto-renew`` and
returned False even though ``build_core_services`` had wired a real
+1 -1
View File
@@ -20,9 +20,9 @@ from typing import Any
from unittest.mock import MagicMock
from bot.app.factories.build_services import build_core_services
from bot.payment_providers.yookassa import YooKassaService
from bot.services.panel_webhook_service import PanelWebhookService
from bot.services.subscription_service import SubscriptionService
from bot.services.yookassa_service import YooKassaService
from config.settings import Settings
+291
View File
@@ -0,0 +1,291 @@
import importlib
from pathlib import Path
from types import SimpleNamespace
from bot.keyboards.inline.user_keyboards import get_payment_method_keyboard
from bot.payment_providers import (
get_provider_spec,
iter_provider_specs,
iter_service_keys,
pending_statuses,
provider_emoji_map,
provider_label_map,
provider_telegram_button_text,
resolve_provider_presentation,
)
from bot.payment_providers.shared import (
format_number_for_payload,
payment_record_amounts,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
from config.settings import Settings
_LEGACY_PROVIDER_FILES = [
"backend/bot/services/yookassa_service.py",
"backend/bot/services/freekassa_service.py",
"backend/bot/services/platega_service.py",
"backend/bot/services/severpay_service.py",
"backend/bot/services/crypto_pay_service.py",
"backend/bot/services/stars_service.py",
"backend/bot/services/wata_service.py",
"backend/bot/handlers/user/payment.py",
"backend/bot/handlers/user/subscription/payment_methods.py",
"backend/bot/handlers/user/subscription/payments_yookassa.py",
"backend/bot/handlers/user/subscription/payments_freekassa.py",
"backend/bot/handlers/user/subscription/payments_platega.py",
"backend/bot/handlers/user/subscription/payments_severpay.py",
"backend/bot/handlers/user/subscription/payments_crypto.py",
"backend/bot/handlers/user/subscription/payments_stars.py",
"backend/bot/handlers/user/subscription/payments_wata.py",
]
_PROVIDER_MODULES = {
"yookassa": "YooKassaService",
"freekassa": "FreeKassaService",
"platega": "PlategaService",
"severpay": "SeverPayService",
"cryptopay": "CryptoPayService",
"stars": "StarsService",
"wata": "WataService",
}
def test_legacy_provider_integration_files_are_removed():
repo_root = Path(__file__).resolve().parents[1]
for relative_path in _LEGACY_PROVIDER_FILES:
assert not (repo_root / relative_path).exists()
def test_every_provider_module_owns_its_service_and_spec():
for module_name, service_class_name in _PROVIDER_MODULES.items():
module = importlib.import_module(f"bot.payment_providers.{module_name}")
assert hasattr(module, service_class_name)
assert hasattr(module, "SPEC") or hasattr(module, "SPECS")
def test_wata_is_registered_as_single_provider_module():
spec = get_provider_spec("wata")
assert spec is not None
assert spec.service_key == "wata_service"
assert spec.pending_status == "pending_wata"
assert spec.button_text_key == "pay_with_wata_button"
assert spec.callback_prefix == "pay_wata"
assert spec.router is not None
assert spec.create_service is not None
assert spec.webhook_route is not None
assert spec.create_webapp_payment is not None
def test_yookassa_provider_keeps_autorenew_entrypoints_local():
yookassa = importlib.import_module("bot.payment_providers.yookassa")
spec = get_provider_spec("yookassa")
assert spec is not None
assert spec.service_key == "yookassa_service"
assert spec.create_service is not None
assert spec.webhook_route is yookassa.yookassa_webhook_route
assert yookassa.payment_processing_lock is not None
assert callable(yookassa.process_successful_payment)
assert callable(yookassa.process_cancelled_payment)
assert callable(yookassa.yookassa_webhook_route)
def test_every_payment_method_has_registry_driven_webapp_creator():
missing = [
spec.id
for spec in iter_provider_specs()
if spec.create_webapp_payment is None
]
assert missing == []
def test_service_keys_and_statuses_come_from_provider_specs():
assert set(iter_service_keys()) == {
"yookassa_service",
"freekassa_service",
"platega_service",
"severpay_service",
"wata_service",
"stars_service",
"cryptopay_service",
}
assert set(pending_statuses()) >= {
"pending",
"pending_yookassa",
"pending_freekassa",
"pending_platega",
"pending_severpay",
"pending_wata",
"pending_cryptopay",
"pending_stars",
}
def test_provider_labels_and_emojis_include_storage_keys_and_method_aliases():
labels = provider_label_map()
emojis = provider_emoji_map()
assert labels["wata"] == "Wata"
assert labels["telegram_stars"] == "Telegram Stars"
assert labels["stars"] == "Telegram Stars"
assert labels["platega"] == "Platega"
assert labels["platega_sbp"] == "Platega"
assert labels["platega_crypto"] == "Platega"
assert emojis["stars"] == get_provider_spec("stars").default_telegram_emoji
assert emojis["telegram_stars"] == get_provider_spec("stars").default_telegram_emoji
assert emojis["cryptopay"] == get_provider_spec("cryptopay").default_telegram_emoji
def test_provider_presentation_resolves_defaults_and_overrides():
spec = get_provider_spec("yookassa")
assert spec is not None
default = resolve_provider_presentation(spec)
assert default.webapp_label == spec.webapp_label
assert default.webapp_icon == "CreditCard"
assert default.telegram_label == spec.telegram_labels["ru"]
assert default.telegram_emoji == spec.default_telegram_emoji
assert not default.telegram_customized
settings = SimpleNamespace(
PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Card in app",
PAYMENT_YOOKASSA_WEBAPP_ICON="WalletCards",
PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN="Card in bot",
PAYMENT_YOOKASSA_TELEGRAM_EMOJI="💸",
)
custom = resolve_provider_presentation(spec, settings, language="en")
assert custom.webapp_label == "Card in app"
assert custom.webapp_icon == "WalletCards"
assert custom.telegram_label == "Card in bot"
assert custom.telegram_emoji == "💸"
assert custom.telegram_customized
def test_provider_telegram_button_text_uses_provider_defaults_until_customized():
spec = get_provider_spec("wata")
assert spec is not None
translate = lambda key: f"i18n:{key}"
assert (
provider_telegram_button_text(spec, SimpleNamespace(), translate, language="en")
== f"{spec.default_telegram_emoji} Wata"
)
settings = SimpleNamespace(PAYMENT_WATA_TELEGRAM_LABEL_EN="Pay Wata")
assert (
provider_telegram_button_text(spec, settings, translate, language="en")
== f"{spec.default_telegram_emoji} Pay Wata"
)
def test_provider_presentation_ignores_cross_language_override():
spec = get_provider_spec("yookassa")
assert spec is not None
settings = SimpleNamespace(PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта")
assert (
resolve_provider_presentation(spec, settings, language="en").webapp_label
== "Bank card"
)
def test_payment_method_keyboard_uses_custom_telegram_text_without_changing_callback():
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
TARIFFS_CONFIG_PATH="missing-tariffs.json",
PAYMENT_METHODS_ORDER="wata",
WATA_ENABLED=True,
PAYMENT_WATA_TELEGRAM_LABEL_EN="Wata custom",
PAYMENT_WATA_TELEGRAM_EMOJI="💸",
)
i18n = SimpleNamespace(gettext=lambda _lang, key, **_kwargs: key)
markup = get_payment_method_keyboard(
months=1,
price=150,
stars_price=None,
currency_symbol_val="RUB",
lang="en",
i18n_instance=i18n,
settings=settings,
)
button = markup.inline_keyboard[0][0]
assert button.text == "💸 Wata custom"
assert button.callback_data == "pay_wata:1:150:subscription"
def test_provider_callbacks_are_built_from_specs():
wata = get_provider_spec("wata")
stars = get_provider_spec("stars")
assert wata is not None
assert stars is not None
assert (
wata.callback_data(
value="1",
rub_price=150,
stars_price=None,
sale_mode="subscription",
)
== "pay_wata:1:150:subscription"
)
assert (
stars.callback_data(
value="1",
rub_price=150,
stars_price=42,
sale_mode="subscription",
)
== "pay_stars:1:42:subscription"
)
assert stars.callback_data(
value="1",
rub_price=150,
stars_price=None,
sale_mode="subscription",
) is None
def test_provider_visibility_uses_service_configuration():
spec = get_provider_spec("wata")
assert spec is not None
settings = SimpleNamespace(WATA_ENABLED=True)
assert spec.is_visible(settings, {"wata_service": SimpleNamespace(configured=True)})
assert not spec.is_visible(settings, {"wata_service": SimpleNamespace(configured=False)})
assert not spec.is_visible(SimpleNamespace(WATA_ENABLED=False), {})
def test_common_sale_mode_helpers_cover_provider_payment_records():
assert sale_mode_base("traffic_package@premium|anything") == "traffic_package"
assert sale_mode_is_traffic("premium_topup@vip")
assert sale_mode_is_hwid_devices("hwid_devices@vip")
assert sale_mode_tariff_key("subscription@vip") == "vip"
assert format_number_for_payload(10.0) == "10"
assert format_number_for_payload(10.5) == "10.5"
traffic = payment_record_amounts(months=20, traffic_gb=20.5, sale_mode="topup@vip")
assert traffic.months == 20
assert traffic.purchased_gb == 20.5
assert traffic.purchased_hwid_devices is None
assert traffic.tariff_key == "vip"
assert traffic.traffic_sale
hwid = payment_record_amounts(months=3, sale_mode="hwid_devices@vip")
assert hwid.months == 3
assert hwid.purchased_gb is None
assert hwid.purchased_hwid_devices == 3
assert hwid.tariff_key == "vip"
assert hwid.hwid_devices_sale
+3 -3
View File
@@ -15,9 +15,9 @@ from bot.app.web.webapp_auth import (
create_webapp_session_token,
verify_telegram_oauth_nonce,
)
from bot.handlers.user.payment import yookassa_webhook_route
from bot.services.crypto_pay_service import CryptoPayService
from bot.services.freekassa_service import FreeKassaService
from bot.payment_providers.cryptopay import CryptoPayService
from bot.payment_providers.freekassa import FreeKassaService
from bot.payment_providers.yookassa import yookassa_webhook_route
from bot.utils.request_security import request_client_ip
from config.settings import Settings
from db.database_setup import redacted_database_url
+21
View File
@@ -152,6 +152,27 @@ class SettingsTests(unittest.TestCase):
self.assertEqual(settings.TRIAL_TRAFFIC_STRATEGY, "WEEK")
def test_payment_button_presentation_env_values_are_available(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта",
PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Card",
PAYMENT_YOOKASSA_WEBAPP_ICON="CreditCard",
PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU="Банковская карта",
PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN="Bank card",
PAYMENT_YOOKASSA_TELEGRAM_EMOJI="💳",
)
self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_LABEL_RU, "Карта")
self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_LABEL_EN, "Card")
self.assertEqual(settings.PAYMENT_YOOKASSA_WEBAPP_ICON, "CreditCard")
self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU, "Банковская карта")
self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN, "Bank card")
self.assertEqual(settings.PAYMENT_YOOKASSA_TELEGRAM_EMOJI, "💳")
def test_tariff_warning_levels_are_parsed(self):
settings = Settings(
_env_file=None,
+22
View File
@@ -358,6 +358,28 @@ class WebAppAssetTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(methods, [])
def test_serialize_payment_methods_includes_provider_presentation(self):
settings = Settings(
_env_file=None,
BOT_TOKEN="token",
POSTGRES_USER="app_user",
POSTGRES_PASSWORD="app_password",
TARIFFS_CONFIG_PATH="missing-tariffs.json",
PAYMENT_METHODS_ORDER="yookassa",
YOOKASSA_ENABLED=True,
PAYMENT_YOOKASSA_WEBAPP_LABEL_RU="Карта",
PAYMENT_YOOKASSA_WEBAPP_LABEL_EN="Bank card",
PAYMENT_YOOKASSA_WEBAPP_ICON="WalletCards",
)
app = {"yookassa_service": SimpleNamespace(configured=True)}
methods = subscription_webapp._serialize_payment_methods(settings, app, "en")
self.assertEqual(
methods,
[{"id": "yookassa", "name": "Bank card", "icon": "WalletCards"}],
)
def test_serialize_plans_includes_stars_only_subscription_options(self):
settings = Settings(
_env_file=None,