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
-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]
+18 -616
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(
session=session,
user_id=user_id,
months=months,
amount=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 await provider_spec.create_webapp_payment(
WebAppPaymentContext(
request=request,
session=session,
user_id=user_id,
method=method,
months=months,
price=price,
stars_price=stars_price,
description=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