refactor: payments providers
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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("/"):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 "❌"
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
+173
-145
@@ -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="₿",
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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="⭐",
|
||||
)
|
||||
@@ -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
@@ -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)
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
@@ -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
@@ -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 и другие варианты отрисовки.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -42,6 +42,7 @@ export {
|
||||
RefreshCw,
|
||||
Repeat2,
|
||||
Save,
|
||||
Search,
|
||||
Send,
|
||||
Server,
|
||||
Settings,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user