refactor: payments providers

This commit is contained in:
3252a8
2026-05-18 10:29:00 +03:00
parent fff7e90e14
commit 51c9c8b4f0
63 changed files with 7480 additions and 6843 deletions
+37
View File
@@ -0,0 +1,37 @@
from .base import (
PaymentProviderPresentation,
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .registry import (
PAYMENT_PROVIDER_SPECS,
build_provider_services,
get_provider_spec,
iter_provider_specs,
iter_service_keys,
iter_unique_provider_routers,
pending_statuses,
provider_emoji_map,
provider_label_map,
provider_telegram_button_text,
resolve_provider_presentation,
)
__all__ = [
"PAYMENT_PROVIDER_SPECS",
"PaymentProviderPresentation",
"PaymentProviderSpec",
"ServiceFactoryContext",
"WebAppPaymentContext",
"build_provider_services",
"get_provider_spec",
"iter_provider_specs",
"iter_service_keys",
"iter_unique_provider_routers",
"pending_statuses",
"provider_telegram_button_text",
"provider_emoji_map",
"provider_label_map",
"resolve_provider_presentation",
]
+122
View File
@@ -0,0 +1,122 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Awaitable, Callable, Mapping, Optional, Sequence
@dataclass(frozen=True)
class ServiceFactoryContext:
settings: Any
bot: Any
async_session_factory: Any
i18n: Any
bot_username_for_default_return: str
subscription_service: Any
referral_service: Any
@dataclass(frozen=True)
class WebAppPaymentContext:
request: Any
session: Any
user_id: int
method: str
months: Any
price: float
stars_price: Optional[int]
description: str
sale_mode: str
traffic_gb: Optional[float] = None
EnabledPredicate = Callable[[Any], bool]
ServiceFactory = Callable[[ServiceFactoryContext], Any]
WebhookPathGetter = Callable[[Any], str]
WebhookRoute = Callable[[Any], Awaitable[Any]]
WebAppPaymentFactory = Callable[[WebAppPaymentContext], Awaitable[Any]]
@dataclass(frozen=True)
class PaymentProviderSpec:
id: str
provider_key: str
label: str
pending_status: str
enabled: EnabledPredicate
service_key: Optional[str] = None
button_text_key: Optional[str] = None
callback_prefix: Optional[str] = None
webapp_label: Optional[str] = None
webapp_labels: Optional[Mapping[str, str]] = None
telegram_labels: Optional[Mapping[str, str]] = None
aliases: Sequence[str] = ()
router: Any = None
create_service: Optional[ServiceFactory] = None
webhook_path: Optional[WebhookPathGetter] = None
webhook_route: Optional[WebhookRoute] = None
webhook_requires_base_url: bool = False
create_webapp_payment: Optional[WebAppPaymentFactory] = None
requires_configured_service: bool = True
price_source: str = "rub"
emoji: str = "💳"
webapp_icon: Optional[str] = None
telegram_emoji: Optional[str] = None
@property
def settings_key(self) -> str:
return self.id.upper()
@property
def default_telegram_emoji(self) -> str:
return self.telegram_emoji or self.emoji
@property
def method_ids(self) -> tuple[str, ...]:
return (self.id, *tuple(self.aliases))
def is_enabled(self, settings: Any) -> bool:
return bool(self.enabled(settings))
def is_service_configured(self, app: Any) -> bool:
if not self.requires_configured_service:
return True
if not self.service_key:
return True
service = app.get(self.service_key) if hasattr(app, "get") else None
return bool(service and getattr(service, "configured", False))
def is_visible(self, settings: Any, app: Any) -> bool:
return self.is_enabled(settings) and self.is_service_configured(app)
def load_router(self) -> Any:
return self.router
def load_webhook_route(self) -> Optional[WebhookRoute]:
return self.webhook_route
def callback_data(
self,
*,
value: str,
rub_price: float,
stars_price: Optional[int],
sale_mode: str,
) -> Optional[str]:
if not self.callback_prefix:
return None
if self.price_source == "stars":
if stars_price is None:
return None
price: Any = stars_price
else:
price = rub_price
return f"{self.callback_prefix}:{value}:{price}:{sale_mode}"
@dataclass(frozen=True)
class PaymentProviderPresentation:
webapp_label: str
webapp_icon: Optional[str]
telegram_label: str
telegram_emoji: str
telegram_customized: bool
+377
View File
@@ -0,0 +1,377 @@
import hashlib
import hmac
import json
import logging
from typing import Optional
from aiocryptopay import AioCryptoPay, Networks
from aiocryptopay.models.update import Update
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 (
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:
def __init__(
self,
token: Optional[str],
network: str,
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.token = token
if token:
net = Networks.TEST_NET if str(network).lower() == "testnet" else Networks.MAIN_NET
self.client = AioCryptoPay(token=token, network=net)
self.client.register_pay_handler(self._invoice_paid_handler)
self.configured = True
else:
logging.warning("CryptoPay token not provided. CryptoPay disabled")
self.client = None
self.configured = False
async def close(self):
if self.client:
try:
await self.client.close()
logging.info("CryptoPay client session closed.")
except Exception as e:
logging.warning("Failed to close CryptoPay client: %s", e)
async def create_invoice(
self,
session: AsyncSession,
user_id: int,
months: int,
amount: float,
description: str,
sale_mode: str = "subscription",
url_kind: str = "bot",
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": (
int(months) if sale_base == "subscription" else None
),
"provider": "cryptopay",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
},
)
await session.commit()
except Exception:
await session.rollback()
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 is_traffic else None,
}
)
try:
invoice = await self.client.create_invoice(
amount=amount,
currency_type=self.settings.CRYPTOPAY_CURRENCY_TYPE,
fiat=self.settings.CRYPTOPAY_ASSET
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "fiat"
else None,
asset=self.settings.CRYPTOPAY_ASSET
if self.settings.CRYPTOPAY_CURRENCY_TYPE == "crypto"
else None,
description=description,
payload=payload,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_record.payment_id,
str(invoice.invoice_id),
str(invoice.status),
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Failed to update cryptopay payment record %s.",
payment_record.payment_id,
)
return None
if url_kind == "web":
return (
getattr(invoice, "web_app_invoice_url", None)
or getattr(invoice, "mini_app_invoice_url", None)
or invoice.bot_invoice_url
)
return invoice.bot_invoice_url
except Exception:
logging.exception("CryptoPay invoice creation failed.")
return None
async def _invoice_paid_handler(self, update: Update, app: web.Application):
invoice = update.payload
if not invoice.payload:
logging.warning("CryptoPay webhook without payload")
return
try:
meta = json.loads(invoice.payload)
user_id = int(meta["user_id"])
months = float(meta.get("subscription_months") or 0)
payment_db_id = int(meta["payment_db_id"])
sale_mode = meta.get("sale_mode") or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
traffic_gb = float(meta.get("traffic_gb")) if meta.get("traffic_gb") else months
except Exception:
logging.exception("Failed to parse CryptoPay payload.")
return
async_session_factory: sessionmaker = app["async_session_factory"]
bot: Bot = app["bot"]
settings: Settings = app["settings"]
i18n: JsonI18n = app["i18n"]
subscription_service: SubscriptionService = app["subscription_service"]
referral_service: ReferralService = app["referral_service"]
async with async_session_factory() as session:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
str(invoice.invoice_id),
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Failed to mark CryptoPay invoice %s as succeeded.",
payment_db_id,
)
return
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,
)
return
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=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",
)
)
def _validate_webhook_signature(self, raw_body: bytes, signature: str) -> bool:
if not self.token:
return False
expected_signature = hmac.new(
hashlib.sha256(self.token.encode("utf-8")).digest(),
raw_body,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected_signature, signature or ""):
logger.error("CryptoPay signature mismatch")
return False
return True
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured or not self.client:
return web.Response(status=503, text="cryptopay_disabled")
raw_body = await request.read()
signature = request.headers.get("crypto-pay-api-signature", "")
if not self._validate_webhook_signature(raw_body, signature):
return web.Response(status=401)
return await self.client.get_updates(request)
async def cryptopay_webhook_route(request: web.Request) -> web.Response:
service: CryptoPayService = request.app["cryptopay_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_crypto_router")
@router.callback_query(F.data.startswith("pay_crypto:"))
async def pay_crypto_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if (
not settings.CRYPTOPAY_ENABLED
or not cryptopay_service
or not getattr(cryptopay_service, "configured", False)
):
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
payment_description = describe_payment(translator, parts)
invoice_url = await cryptopay_service.create_invoice(
session=session,
user_id=callback.from_user.id,
months=parts.months,
amount=parts.price,
description=payment_description,
sale_mode=parts.sale_mode,
)
if invoice_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=invoice_url,
log_prefix=_LOG,
)
return
from .shared import safe_callback_answer
await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True)
def create_service(ctx: ServiceFactoryContext) -> CryptoPayService:
return CryptoPayService(
ctx.settings.CRYPTOPAY_TOKEN,
ctx.settings.CRYPTOPAY_NETWORK,
ctx.bot,
ctx.settings,
ctx.i18n,
ctx.async_session_factory,
ctx.subscription_service,
ctx.referral_service,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: CryptoPayService = ctx.request.app["cryptopay_service"]
if not service or not service.configured:
return payment_unavailable()
url = await service.create_invoice(
session=ctx.session,
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
description=ctx.description,
sale_mode=ctx.sale_mode,
url_kind="web",
)
if not url:
return payment_failed()
return payment_link_response(payment_url=url, payment_id=None)
SPEC = PaymentProviderSpec(
id="cryptopay",
provider_key="cryptopay",
label="CryptoPay",
webapp_label="CryptoPay",
webapp_labels={"ru": "CryptoPay", "en": "CryptoPay"},
webapp_icon="Bitcoin",
telegram_labels={"ru": "CryptoBot", "en": "CryptoBot"},
pending_status="pending_cryptopay",
enabled=lambda settings: settings.CRYPTOPAY_ENABLED,
service_key="cryptopay_service",
button_text_key="pay_with_cryptopay_button",
callback_prefix="pay_crypto",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.cryptopay_webhook_path,
webhook_route=cryptopay_webhook_route,
create_webapp_payment=create_webapp_payment,
emoji="",
telegram_emoji="",
)
+501
View File
@@ -0,0 +1,501 @@
import asyncio
import hashlib
import hmac
import json
import logging
import time
from datetime import datetime
from typing import Any, Dict, Optional, Tuple
from urllib.parse import parse_qsl
from aiogram import Bot, F, Router, types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_unavailable,
post_json_request,
render_link_or_fail,
)
_LOG = "freekassa"
class FreeKassaService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.shop_id: Optional[str] = settings.FREEKASSA_MERCHANT_ID
self.api_key: Optional[str] = settings.FREEKASSA_API_KEY
self.second_secret: Optional[str] = settings.FREEKASSA_SECOND_SECRET
self.default_currency: str = (settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
self.server_ip: Optional[str] = settings.FREEKASSA_PAYMENT_IP
self.payment_method_id: Optional[int] = settings.FREEKASSA_PAYMENT_METHOD_ID
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
self.configured: bool = bool(settings.FREEKASSA_ENABLED and self.shop_id and self.api_key)
if not self.configured:
logging.warning(
"FreeKassaService initialized but not fully configured. Payments disabled."
)
if settings.FREEKASSA_ENABLED and not self.server_ip:
logging.warning(
"FreeKassaService: FREEKASSA_PAYMENT_IP is not set. Requests may be rejected by the provider." # noqa: E501
)
async def create_order(
self,
*,
payment_db_id: int,
user_id: int,
months: int,
amount: float,
currency: Optional[str],
email: Optional[str] = None,
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
ip_address = ip_address or self.server_ip
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
return False, {"message": "missing_ip"}
email = email or f"{user_id}@telegram.org"
currency_code = (currency or self.default_currency or "RUB").upper()
payload: Dict[str, Any] = {
"shopId": int(self.shop_id),
"nonce": await self._generate_nonce(),
"paymentId": str(payment_db_id),
"i": int(payment_method_id),
"amount": f"{format_decimal_amount(amount):.2f}",
"currency": currency_code,
"email": email,
"ip": ip_address,
"us_user_id": str(user_id),
"us_months": str(months),
"us_payment_db_id": str(payment_db_id),
}
if extra_params:
for key, value in extra_params.items():
if value is None:
continue
payload[key] = value
payload["signature"] = self._sign_payload(payload)
session = await self._get_session()
return await post_json_request(
session,
f"{self.api_base_url}/orders/create",
body=payload,
log_prefix="FreeKassa create_order",
# FreeKassa returns ``{"type": "success", ...}`` on success.
is_success=lambda status, data: status == 200 and (data or {}).get("type") == "success",
)
async def _generate_nonce(self) -> int:
async with self._nonce_lock:
candidate = int(time.time() * 1000)
if candidate <= self._last_nonce:
candidate = self._last_nonce + 1
self._last_nonce = candidate
return candidate
def _sign_payload(self, payload: Dict[str, Any]) -> str:
if not self.api_key:
raise RuntimeError("FreeKassa API key is not configured.")
items = [
(key, value)
for key, value in payload.items()
if key != "signature" and value is not None
]
items.sort(key=lambda pair: pair[0])
message = "|".join(str(value) for _, value in items)
return hmac.new(
self.api_key.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
def _validate_signature(self, raw_body: bytes, provided_signature: str) -> bool:
if not provided_signature or not self.second_secret:
return False
expected_signature = hmac.new(
self.second_secret.encode("utf-8"),
raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected_signature, provided_signature)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="freekassa_disabled")
try:
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if not ip_in_allowlist(client_ip, self.settings.freekassa_trusted_ips):
return web.Response(status=403)
raw_body = await request.read()
except Exception:
logging.exception("FreeKassa webhook: failed to read request body.")
return web.Response(status=400, text="bad_request")
payload_dict: Dict[str, Any] = {}
if raw_body:
try:
if request.content_type.startswith("application/json"):
decoded_json = json.loads(raw_body.decode("utf-8"))
if isinstance(decoded_json, dict):
payload_dict = {str(k): v for k, v in decoded_json.items()}
else:
payload_dict = {
str(key): value
for key, value in parse_qsl(
raw_body.decode("utf-8"), keep_blank_values=True
)
}
except Exception:
payload_dict = {}
def _get(key: str, default: Optional[str] = None) -> Optional[str]:
return payload_dict.get(key) or payload_dict.get(key.lower()) or default
merchant_id = _get("MERCHANT_ID")
if merchant_id != self.shop_id:
return web.Response(status=403)
signature = _get("SIGN") or _get("signature")
if not signature:
return web.Response(status=400, text="missing_signature")
order_id_str = _get("MERCHANT_ORDER_ID") or _get("ORDER_ID") or _get("o")
amount_str = _get("AMOUNT") or _get("OA") or _get("amount")
provider_payment_id = _get("intid") or _get("payment_id") or _get("transaction_id")
if not order_id_str or not amount_str:
return web.Response(status=400, text="missing_data")
if not self._validate_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payment_db_id = int(order_id_str)
except (TypeError, ValueError):
logging.error("FreeKassa webhook: invalid order_id value %r", order_id_str)
return web.Response(status=400, text="invalid_order_id")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("FreeKassa webhook: payment %s not found", payment_db_id)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded":
logging.info("FreeKassa webhook: payment %s already succeeded", payment_db_id)
return web.Response(text="YES")
try:
if not decimal_amounts_equal(amount_str, payment.amount):
logging.warning(
"FreeKassa webhook: amount mismatch for payment %s (expected %s, got %s)",
payment_db_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_str),
)
except Exception as exc:
logging.warning(
"FreeKassa webhook: failed to compare amount for payment %s: %s",
payment_db_id,
exc,
)
resolved_provider_id = str(provider_payment_id or f"freekassa:{order_id_str}")
try:
await payment_dal.update_provider_payment_and_status(
session=session,
payment_db_id=payment.payment_id,
provider_payment_id=resolved_provider_id,
new_status="succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"FreeKassa webhook: failed to mark payment %s as succeeded.", payment_db_id
)
return web.Response(status=500, text="processing_error")
months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
success_prefix: Optional[str] = None
if provider_payment_id:
# FreeKassa-specific: prepend "Order N from YYYY-MM-DD" to the success text.
# ``i18n`` is resolved inside ``finalize_successful_payment`` per user language,
# so the prefix is built in the admin/default language too — kept here for parity
# with the original behavior, which used the same key.
admin_lang = self.settings.DEFAULT_LANGUAGE
translator = make_translator(self.i18n, admin_lang)
success_prefix = translator(
"free_kassa_order_full",
order_id=provider_payment_id,
date=datetime.now().strftime("%Y-%m-%d"),
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=self.default_currency,
sale_mode=sale_mode,
months=months,
traffic_amount=float(months),
provider_subscription="freekassa",
provider_notification="freekassa",
db_user=payment.user,
log_prefix="FreeKassa webhook",
text_prefix=success_prefix,
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="YES")
async def freekassa_webhook_route(request: web.Request) -> web.Response:
service: FreeKassaService = request.app["freekassa_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_freekassa_router")
@router.callback_query(F.data.startswith("pay_fk:"))
async def pay_fk_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not freekassa_service or not freekassa_service.configured:
logging.error("FreeKassa service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_fk data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = (
getattr(freekassa_service, "default_currency", None)
or settings.DEFAULT_CURRENCY_SYMBOL
or "RUB"
)
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_freekassa",
description=payment_description,
months=parts.months,
provider="freekassa",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"FreeKassa: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await freekassa_service.create_order(
payment_db_id=payment_record.payment_id,
user_id=payment_record.user_id,
months=parts.months,
amount=parts.price,
currency=freekassa_service.default_currency,
payment_method_id=freekassa_service.payment_method_id,
ip_address=freekassa_service.server_ip,
extra_params={
"us_method": freekassa_service.payment_method_id,
},
)
location = first_value(response_data, "location")
provider_identifier = first_value(response_data, "orderHash", "orderId")
lead_text: Optional[str] = None
if success and location:
order_id_display = first_value(response_data, "orderId") or provider_identifier or str(
payment_record.payment_id
)
lead_text = translator(
"free_kassa_order_info",
order_id=order_id_display,
date=datetime.now().strftime("%Y-%m-%d"),
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=location,
provider_payment_id=provider_identifier,
lead_text=lead_text,
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> FreeKassaService:
return FreeKassaService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
service: FreeKassaService = ctx.request.app["freekassa_service"]
if not service or not service.configured or not service.payment_method_id:
return payment_unavailable()
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=service.default_currency,
status="pending_freekassa",
provider="freekassa",
)
success, response_data = await service.create_order(
payment_db_id=payment.payment_id,
user_id=ctx.user_id,
months=ctx.months,
amount=ctx.price,
currency=service.default_currency,
payment_method_id=service.payment_method_id,
ip_address=service.server_ip,
extra_params={"us_method": service.payment_method_id},
)
except Exception:
await ctx.session.rollback()
logging.exception("FreeKassa WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(response_data, "location") if success else None,
provider_payment_id=first_value(response_data, "orderHash", "orderId"),
log_prefix="FreeKassa",
)
SPEC = PaymentProviderSpec(
id="freekassa",
provider_key="freekassa",
label="FreeKassa",
webapp_label="FreeKassa / СБП",
webapp_labels={"ru": "FreeKassa / СБП", "en": "FreeKassa / SBP"},
webapp_icon="Smartphone",
telegram_labels={"ru": "СБП", "en": "SBP"},
telegram_emoji="📱",
pending_status="pending_freekassa",
enabled=lambda settings: settings.FREEKASSA_ENABLED,
service_key="freekassa_service",
button_text_key="pay_with_sbp_button",
callback_prefix="pay_fk",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.freekassa_webhook_path,
webhook_route=freekassa_webhook_route,
create_webapp_payment=create_webapp_payment,
)
+529
View File
@@ -0,0 +1,529 @@
import hmac
import json
import logging
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
format_number_for_payload,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
post_json_request,
render_link_or_fail,
safe_callback_answer,
)
_LOG = "platega"
class PlategaService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.PLATEGA_BASE_URL or "https://app.platega.io").rstrip("/")
self.merchant_id = settings.PLATEGA_MERCHANT_ID
self.secret = settings.PLATEGA_SECRET
self.payment_method = settings.PLATEGA_PAYMENT_METHOD
self.sbp_method = settings.platega_sbp_method_resolved
self.crypto_method = settings.PLATEGA_CRYPTO_METHOD
self.return_url = settings.PLATEGA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.PLATEGA_FAILED_URL or self.return_url
self._init_http_client(total_timeout=20)
self._auth_headers = {
"X-MerchantId": self.merchant_id or "",
"X-Secret": self.secret or "",
"Content-Type": "application/json",
}
self.configured: bool = bool(settings.PLATEGA_ENABLED and self.merchant_id and self.secret)
if not self.configured:
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
)
else:
logging.info(
"PlategaService configured. SBP button: %s (method=%s), Crypto button: %s (method=%s)", # noqa: E501
"ON" if settings.PLATEGA_SBP_ENABLED else "OFF",
self.sbp_method,
"ON" if settings.PLATEGA_CRYPTO_ENABLED else "OFF",
self.crypto_method,
)
async def create_transaction(
self,
*,
amount: float,
currency: Optional[str],
description: str,
payload: Optional[str] = None,
payment_method: Optional[int] = None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
method_id = int(payment_method if payment_method is not None else self.payment_method)
body: Dict[str, Any] = {
"paymentMethod": method_id,
"paymentDetails": {"amount": float(amount), "currency": currency_code},
"description": description,
"return": self.return_url,
"failedUrl": self.failed_url,
"payload": payload,
}
# Remove optional keys with falsy values to avoid validation errors
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
safe_headers = {
"X-MerchantId": self._auth_headers.get("X-MerchantId"),
"X-Secret": "***" if self._auth_headers.get("X-Secret") else "",
"Content-Type": self._auth_headers.get("Content-Type"),
}
logging.info(
"Platega create_transaction request: url=%s headers=%s body=%s",
url,
safe_headers,
clean_body,
)
return await post_json_request(
session,
url,
body=clean_body,
headers=self._auth_headers,
log_prefix="Platega create_transaction",
)
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="platega_disabled")
try:
data = await request.json()
except Exception:
logging.exception("Platega webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
header_merchant = request.headers.get("X-MerchantId")
header_secret = request.headers.get("X-Secret")
if not (
hmac.compare_digest(str(header_merchant or ""), str(self.merchant_id or ""))
and hmac.compare_digest(str(header_secret or ""), str(self.secret or ""))
):
logging.error("Platega webhook: invalid auth headers")
return web.Response(status=403, text="forbidden")
transaction_id = str(data.get("id") or data.get("transactionId") or "").strip()
status = str(data.get("status") or "").upper()
amount_raw = data.get("amount")
currency = data.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not transaction_id or not status:
logging.error("Platega webhook: missing transaction id or status in payload: %s", data)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await payment_dal.get_payment_by_provider_payment_id(session, transaction_id)
if not payment:
logging.error(
"Platega webhook: payment not found for transaction %s", transaction_id
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "CONFIRMED":
return web.Response(text="ok")
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
if status == "CONFIRMED":
if amount_raw is not None:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)", # noqa: E501
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"Platega webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to mark payment %s as succeeded.", transaction_id
)
return web.Response(status=500, text="processing_error")
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_months,
traffic_amount=float(payment_months),
provider_subscription="platega",
provider_notification="platega",
log_prefix="Platega webhook",
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
transaction_id,
"canceled",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega webhook: failed to cancel payment %s.", transaction_id
)
return web.Response(status=500, text="processing_error")
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.Response(text="ok_canceled")
logging.warning(
"Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id
)
return web.Response(status=202, text="status_ignored")
async def platega_webhook_route(request: web.Request) -> web.Response:
service: PlategaService = request.app["platega_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_platega_router")
def _resolve_platega_variant(callback_prefix: str, settings: Settings) -> Optional[Tuple[str, int]]:
"""Map the callback prefix to (variant, payment_method_id) or ``None`` if disabled."""
if callback_prefix == "pay_platega_crypto":
if not settings.PLATEGA_CRYPTO_ENABLED:
return None
return "crypto", settings.PLATEGA_CRYPTO_METHOD
if callback_prefix == "pay_platega_sbp":
if not settings.PLATEGA_SBP_ENABLED:
return None
return "sbp", settings.platega_sbp_method_resolved
# Legacy "pay_platega:" callback — keep working as SBP.
return "sbp", settings.platega_sbp_method_resolved
@router.callback_query(
F.data.startswith("pay_platega_sbp:")
| F.data.startswith("pay_platega_crypto:")
| F.data.startswith("pay_platega:")
)
async def pay_platega_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
from .shared import make_translator
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
callback_prefix, _, _ = (callback.data or "").partition(":")
variant = _resolve_platega_variant(callback_prefix, settings)
if variant is None:
await safe_callback_answer(callback)
return
platega_variant, platega_method_id = variant
if not platega_service or not platega_service.configured:
logging.error("Platega service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_platega data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_platega",
description=payment_description,
months=parts.months,
provider="platega",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Platega: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
payload_meta = json.dumps(
{
"payment_db_id": payment_record.payment_id,
"user_id": callback.from_user.id,
"months": parts.months,
"sale_mode": parts.sale_mode,
"platega_variant": platega_variant,
}
)
success, response_data = await platega_service.create_transaction(
amount=parts.price,
currency=currency_code,
description=payment_description,
payload=payload_meta,
payment_method=platega_method_id,
)
transaction_id = first_value(response_data, "transactionId", "id")
redirect_url = first_value(response_data, "redirect", "url", "paymentUrl")
provider_status = str((response_data or {}).get("status") or payment_record.status)
# Platega requires *both* a transaction id and a redirect url to count as a
# usable payment — neither field is sufficient on its own. Skipping the
# persistence step when the redirect is missing matches the pre-refactor
# behavior (we never stored a transaction id without a link).
persistable_id = transaction_id if redirect_url else None
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=redirect_url,
provider_payment_id=persistable_id,
new_status=provider_status if persistable_id else None,
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> PlategaService:
return PlategaService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
async def _create_webapp_payment(ctx: WebAppPaymentContext, variant: str) -> web.Response:
settings = ctx.request.app["settings"]
service: PlategaService = ctx.request.app["platega_service"]
if not service or not service.configured:
return payment_unavailable()
if variant == "platega_crypto":
if not settings.PLATEGA_CRYPTO_ENABLED:
return payment_unavailable()
platega_method_id = settings.PLATEGA_CRYPTO_METHOD
else:
if variant == "platega_sbp" and not settings.PLATEGA_SBP_ENABLED:
return payment_unavailable()
platega_method_id = settings.platega_sbp_method_resolved
try:
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
status="pending_platega",
provider="platega",
)
payload = json.dumps(
{
"payment_db_id": payment.payment_id,
"user_id": ctx.user_id,
"months": amounts.months if not amounts.traffic_sale else 0,
"sale_mode": ctx.sale_mode,
"traffic_gb": format_number_for_payload(ctx.traffic_gb or ctx.months)
if amounts.traffic_sale
else None,
"hwid_devices": amounts.purchased_hwid_devices,
"source": "webapp",
"platega_variant": "crypto" if variant == "platega_crypto" else "sbp",
}
)
success, response_data = await service.create_transaction(
amount=ctx.price,
currency=settings.DEFAULT_CURRENCY_SYMBOL or "RUB",
description=ctx.description,
payload=payload,
payment_method=platega_method_id,
)
except Exception:
await ctx.session.rollback()
logging.exception("Platega WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=(
first_value(response_data, "redirect", "url", "paymentUrl") if success else None
),
provider_payment_id=first_value(response_data, "transactionId", "id"),
new_status=str((response_data or {}).get("status") or payment.status),
log_prefix="Platega",
)
async def create_sbp_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return await _create_webapp_payment(ctx, "platega_sbp")
async def create_crypto_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
return await _create_webapp_payment(ctx, "platega_crypto")
SBP_SPEC = PaymentProviderSpec(
id="platega_sbp",
provider_key="platega",
label="Platega",
webapp_label="Platega · СБП",
webapp_labels={"ru": "Оплата картой (СБП)", "en": "Pay with card (SBP)"},
webapp_icon="CreditCard",
telegram_labels={"ru": "Оплата через СБП", "en": "Pay via SBP"},
telegram_emoji="🏦",
pending_status="pending_platega",
enabled=lambda settings: settings.PLATEGA_ENABLED and settings.PLATEGA_SBP_ENABLED,
service_key="platega_service",
button_text_key="pay_with_platega_sbp_button",
callback_prefix="pay_platega_sbp",
aliases=("platega",),
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.platega_webhook_path,
webhook_route=platega_webhook_route,
create_webapp_payment=create_sbp_webapp_payment,
)
CRYPTO_SPEC = PaymentProviderSpec(
id="platega_crypto",
provider_key="platega",
label="Platega",
webapp_label="Platega · Crypto",
webapp_labels={"ru": "Крипта", "en": "Crypto"},
webapp_icon="Bitcoin",
telegram_labels={"ru": "Оплата криптой", "en": "Pay with crypto"},
telegram_emoji="🪙",
pending_status="pending_platega",
enabled=lambda settings: settings.PLATEGA_ENABLED and settings.PLATEGA_CRYPTO_ENABLED,
service_key="platega_service",
button_text_key="pay_with_platega_crypto_button",
callback_prefix="pay_platega_crypto",
create_webapp_payment=create_crypto_webapp_payment,
)
SPECS = (SBP_SPEC, CRYPTO_SPEC)
+211
View File
@@ -0,0 +1,211 @@
from __future__ import annotations
from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional
from . import cryptopay, freekassa, platega, severpay, stars, wata, yookassa
from .base import PaymentProviderPresentation, PaymentProviderSpec, ServiceFactoryContext
PAYMENT_PROVIDER_SPECS: tuple[PaymentProviderSpec, ...] = (
freekassa.SPEC,
platega.SBP_SPEC,
platega.CRYPTO_SPEC,
severpay.SPEC,
wata.SPEC,
yookassa.SPEC,
stars.SPEC,
cryptopay.SPEC,
)
def iter_provider_specs() -> Iterable[PaymentProviderSpec]:
return PAYMENT_PROVIDER_SPECS
def get_provider_spec(method: str) -> Optional[PaymentProviderSpec]:
normalized = str(method or "").strip().lower()
for spec in PAYMENT_PROVIDER_SPECS:
if normalized in spec.method_ids:
return spec
return None
def _setting_value(settings: Any, key: str) -> Optional[str]:
if settings is None:
return None
value = getattr(settings, key, None)
if value is None:
return None
value = str(value).strip()
return value or None
def _presentation_setting(spec: PaymentProviderSpec, suffix: str) -> str:
return f"PAYMENT_{spec.settings_key}_{suffix}"
def _normalize_language(language: Optional[str], settings: Any = None) -> str:
value = language or getattr(settings, "DEFAULT_LANGUAGE", None) or "ru"
normalized = str(value).strip().lower().split("-", 1)[0].split("_", 1)[0]
return normalized or "ru"
def _localized_setting_value(
settings: Any,
spec: PaymentProviderSpec,
suffix: str,
language: str,
) -> Optional[str]:
return _setting_value(
settings,
_presentation_setting(spec, f"{suffix}_{language.upper()}"),
)
def _localized_default(
values: Optional[Mapping[str, str]],
language: str,
fallback: Optional[str],
) -> Optional[str]:
if not values:
return fallback
return (
values.get(language)
or values.get("en")
or values.get("ru")
or next(iter(values.values()), None)
or fallback
)
def resolve_provider_presentation(
spec: PaymentProviderSpec,
settings: Any = None,
*,
language: Optional[str] = None,
translate: Optional[Callable[[str], str]] = None,
) -> PaymentProviderPresentation:
lang = _normalize_language(language, settings)
webapp_label = (
_localized_setting_value(settings, spec, "WEBAPP_LABEL", lang)
or _localized_default(spec.webapp_labels, lang, spec.webapp_label)
or spec.label
)
webapp_icon = (
_setting_value(settings, _presentation_setting(spec, "WEBAPP_ICON"))
or spec.webapp_icon
)
telegram_label_override = _localized_setting_value(
settings,
spec,
"TELEGRAM_LABEL",
lang,
)
telegram_emoji_override = _setting_value(
settings, _presentation_setting(spec, "TELEGRAM_EMOJI")
)
telegram_label = (
telegram_label_override
or _localized_default(spec.telegram_labels, lang, None)
or spec.label
)
telegram_emoji = telegram_emoji_override or spec.default_telegram_emoji
if (
not telegram_label_override
and not spec.telegram_labels
and translate
and spec.button_text_key
):
telegram_label = translate(spec.button_text_key)
return PaymentProviderPresentation(
webapp_label=webapp_label,
webapp_icon=webapp_icon,
telegram_label=telegram_label,
telegram_emoji=telegram_emoji,
telegram_customized=bool(telegram_label_override or telegram_emoji_override),
)
def provider_telegram_button_text(
spec: PaymentProviderSpec,
settings: Any,
translate: Callable[[str], str],
*,
language: Optional[str] = None,
) -> str:
presentation = resolve_provider_presentation(spec, settings, language=language)
if presentation.telegram_emoji:
return f"{presentation.telegram_emoji} {presentation.telegram_label}".strip()
return presentation.telegram_label
def iter_unique_provider_routers():
seen: set[int] = set()
for spec in PAYMENT_PROVIDER_SPECS:
router = spec.load_router()
if not router:
continue
marker = id(router)
if marker in seen:
continue
seen.add(marker)
yield router
def iter_service_keys() -> Iterable[str]:
seen: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
if not spec.service_key or spec.service_key in seen:
continue
seen.add(spec.service_key)
yield spec.service_key
def iter_service_specs() -> Iterable[PaymentProviderSpec]:
seen: set[str] = set()
for spec in PAYMENT_PROVIDER_SPECS:
if not spec.service_key or not spec.create_service or spec.service_key in seen:
continue
seen.add(spec.service_key)
yield spec
def build_provider_services(ctx: ServiceFactoryContext) -> Dict[str, Any]:
services: Dict[str, Any] = {}
for spec in iter_service_specs():
services[spec.service_key] = spec.create_service(ctx)
return services
def provider_label_map(settings: Any = None, language: Optional[str] = None) -> Dict[str, str]:
labels: Dict[str, str] = {}
for spec in PAYMENT_PROVIDER_SPECS:
presentation = resolve_provider_presentation(
spec,
settings,
language=language,
)
label = presentation.telegram_label if presentation.telegram_customized else spec.label
labels.setdefault(spec.provider_key, label)
for method in spec.method_ids:
labels.setdefault(method, label)
return labels
def provider_emoji_map(settings: Any = None) -> Dict[str, str]:
emojis: Dict[str, str] = {}
for spec in PAYMENT_PROVIDER_SPECS:
emoji = resolve_provider_presentation(spec, settings).telegram_emoji
emojis.setdefault(spec.provider_key, emoji)
for method in spec.method_ids:
emojis.setdefault(method, emoji)
return emojis
def pending_statuses() -> List[str]:
statuses = ["pending"]
for spec in PAYMENT_PROVIDER_SPECS:
if spec.pending_status not in statuses:
statuses.append(spec.pending_status)
return statuses
+444
View File
@@ -0,0 +1,444 @@
import hashlib
import hmac
import json
import logging
import secrets
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import payment_dal
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
lookup_payment_by_order_or_provider_id,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_unavailable,
post_json_request,
render_link_or_fail,
)
_LOG = "severpay"
class SeverPayService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.SEVERPAY_BASE_URL or "https://severpay.io/api/merchant").rstrip(
"/"
)
self.mid = settings.SEVERPAY_MID
self.token = settings.SEVERPAY_TOKEN or ""
self.return_url = settings.SEVERPAY_RETURN_URL or f"https://t.me/{default_return_url}"
self.lifetime_minutes = settings.SEVERPAY_LIFETIME_MINUTES
self._init_http_client(total_timeout=15)
self.configured: bool = bool(settings.SEVERPAY_ENABLED and self.mid and self.token)
if not self.configured:
logging.warning(
"SeverPayService initialized but not fully configured. Payments disabled."
)
@staticmethod
def _format_amount(amount: float) -> str:
return f"{format_decimal_amount(amount):.2f}"
def _sign_payload(self, payload: Dict[str, Any]) -> str:
message = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
return hmac.new(
self.token.encode("utf-8"), message.encode("utf-8"), hashlib.sha256
).hexdigest()
def _build_signed_body(self, extra: Dict[str, Any]) -> Dict[str, Any]:
body: Dict[str, Any] = {
"mid": self.mid,
"salt": secrets.token_hex(8),
}
body.update(extra)
sorted_body = dict(sorted(body.items()))
sorted_body["sign"] = self._sign_payload(sorted_body)
return sorted_body
def _validate_signature(self, payload: Dict[str, Any]) -> bool:
provided_sign = str(payload.get("sign") or "")
if not provided_sign or not self.token:
return False
# Webhook signatures are calculated on the original payload order (without sorting).
data = {k: v for k, v in payload.items() if k != "sign"}
expected_sign = self._sign_payload(data)
return hmac.compare_digest(provided_sign, expected_sign)
async def create_payment(
self,
*,
payment_db_id: int,
user_id: int,
amount: float,
currency: Optional[str],
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
body = {
"order_id": str(payment_db_id),
"amount": self._format_amount(amount),
"currency": currency_code,
"client_email": f"{user_id}@telegram.org",
"client_id": str(user_id),
"url_return": self.return_url,
}
if self.lifetime_minutes:
body["lifetime"] = int(self.lifetime_minutes)
success, response_data = await post_json_request(
session,
url,
body=self._build_signed_body(body),
log_prefix="SeverPay create_payment",
# SeverPay marks success with both HTTP 200 *and* the top-level ``status`` flag.
is_success=lambda status, data: status == 200 and bool((data or {}).get("status")),
)
if success:
# SeverPay wraps the useful response inside ``data``; unwrap so callers
# don't have to know that detail.
return True, response_data.get("data") or response_data
return False, response_data
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.json_response({"status": False, "msg": "severpay_disabled"}, status=503)
try:
payload = await request.json()
except Exception:
logging.exception("SeverPay webhook: failed to parse JSON.")
return web.json_response({"status": False, "msg": "bad_request"}, status=400)
if not isinstance(payload, dict) or not self._validate_signature(payload):
logging.error("SeverPay webhook: invalid signature or payload.")
return web.json_response({"status": False, "msg": "invalid_signature"}, status=403)
event_type = str(payload.get("type") or "").lower()
data = payload.get("data") or {}
if event_type != "payin" or not isinstance(data, dict):
logging.warning("SeverPay webhook: unsupported event type '%s'", event_type)
return web.json_response({"status": True})
provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower()
async with self.async_session_factory() as session:
payment = await lookup_payment_by_order_or_provider_id(
session,
order_id_raw=order_id_raw,
provider_payment_id=provider_payment_id or None,
)
if not payment:
logging.error(
"SeverPay webhook: payment not found (order_id=%s, provider_id=%s)",
order_id_raw,
provider_payment_id,
)
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
resolved_provider_id = provider_payment_id or str(payment.payment_id)
payment_months = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
if status == "success":
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to mark payment %s as succeeded.",
resolved_provider_id,
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=payment.currency,
sale_mode=sale_mode,
months=payment_months,
traffic_amount=float(payment_months),
provider_subscription="severpay",
provider_notification="severpay",
db_user=payment.user,
log_prefix="SeverPay webhook",
)
)
if outcome is None:
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
return web.json_response({"status": True})
if status in {"fail", "decline"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to mark payment %s as failed.",
resolved_provider_id,
)
return web.json_response(
{"status": False, "msg": "processing_error"}, status=500
)
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.json_response({"status": True})
if status in {"process", "new"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_provider_id,
"pending_severpay",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay webhook: failed to update pending status for %s.",
resolved_provider_id,
)
return web.json_response({"status": True})
logging.warning(
"SeverPay webhook: unhandled status '%s' for payment %s",
status,
resolved_provider_id,
)
return web.json_response({"status": True})
async def severpay_webhook_route(request: web.Request) -> web.Response:
service: SeverPayService = request.app["severpay_service"]
return await service.webhook_route(request)
router = Router(name="user_subscription_payments_severpay_router")
@router.callback_query(F.data.startswith("pay_severpay:"))
async def pay_severpay_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not severpay_service or not severpay_service.configured:
logging.error("SeverPay service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_severpay data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_severpay",
description=payment_description,
months=parts.months,
provider="severpay",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"SeverPay: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await severpay_service.create_payment(
payment_db_id=payment_record.payment_id,
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=first_value(response_data, "url", "payment_url", "paymentUrl"),
provider_payment_id=first_value(response_data, "id", "uid"),
log_prefix=_LOG,
)
def create_service(ctx: ServiceFactoryContext) -> SeverPayService:
return SeverPayService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
settings = ctx.request.app["settings"]
service: SeverPayService = ctx.request.app["severpay_service"]
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=currency,
status="pending_severpay",
provider="severpay",
)
success, response_data = await service.create_payment(
payment_db_id=payment.payment_id,
user_id=ctx.user_id,
amount=ctx.price,
currency=currency,
)
except Exception:
await ctx.session.rollback()
logging.exception("SeverPay WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=(
first_value(response_data, "url", "payment_url", "paymentUrl") if success else None
),
provider_payment_id=first_value(response_data, "id", "uid"),
log_prefix="SeverPay",
)
SPEC = PaymentProviderSpec(
id="severpay",
provider_key="severpay",
label="SeverPay",
webapp_label="SeverPay",
webapp_labels={"ru": "SeverPay", "en": "SeverPay"},
webapp_icon="CreditCard",
telegram_labels={"ru": "SeverPay", "en": "SeverPay"},
telegram_emoji="💳",
pending_status="pending_severpay",
enabled=lambda settings: settings.SEVERPAY_ENABLED,
service_key="severpay_service",
button_text_key="pay_with_severpay_button",
callback_prefix="pay_severpay",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.severpay_webhook_path,
webhook_route=severpay_webhook_route,
create_webapp_payment=create_webapp_payment,
)
@@ -0,0 +1,128 @@
"""Cross-provider helpers.
Provider modules at ``bot.payment_providers.<name>`` import the building
blocks they need from here. Nothing in this package depends on any
specific provider it is the layer below them.
"""
from .callbacks import (
PaymentCallbackParts,
describe_payment,
edit_or_answer,
notify_callback_parse_error,
notify_payment_gateway_failure,
notify_payment_record_failure,
notify_service_unavailable,
parse_payment_callback,
payment_link_message_text,
render_link_or_fail,
render_payment_link,
safe_callback_answer,
safe_mark_failed_creation,
safe_store_provider_payment_id,
)
from .common import (
PaymentRecordAmounts,
Translator,
build_payment_description,
build_payment_record_payload,
create_base_payment_record,
create_webapp_payment_record,
decimal_amounts_equal,
format_decimal_amount,
format_human_units,
format_number_for_payload,
json_error,
make_translator,
mark_payment_failed_creation,
payment_failed,
payment_link_response,
payment_record_amounts,
payment_unavailable,
sale_mode_base,
sale_mode_is_hwid_devices,
sale_mode_is_traffic,
sale_mode_tariff_key,
)
from .http_client import (
HttpClientMixin,
SuccessCheck,
first_value,
http_ok,
post_json_request,
)
from .success import (
PaymentSuccessOutcome,
PaymentSuccessRequest,
SuccessMessage,
build_success_message,
finalize_successful_payment,
is_traffic_sale_base,
notify_admins_payment_received,
resolve_inviter_name,
resolve_user_language,
send_success_message_to_user,
)
from .webapp import finalize_webapp_link_payment
from .webhooks import (
coerce_payment_db_id,
lookup_payment_by_order_or_provider_id,
notify_user_payment_failed,
)
__all__ = [
"HttpClientMixin",
"PaymentCallbackParts",
"PaymentRecordAmounts",
"PaymentSuccessOutcome",
"PaymentSuccessRequest",
"SuccessCheck",
"SuccessMessage",
"Translator",
"build_payment_description",
"build_payment_record_payload",
"build_success_message",
"coerce_payment_db_id",
"create_base_payment_record",
"create_webapp_payment_record",
"decimal_amounts_equal",
"describe_payment",
"edit_or_answer",
"finalize_successful_payment",
"finalize_webapp_link_payment",
"first_value",
"format_decimal_amount",
"format_human_units",
"format_number_for_payload",
"http_ok",
"is_traffic_sale_base",
"json_error",
"lookup_payment_by_order_or_provider_id",
"make_translator",
"mark_payment_failed_creation",
"notify_admins_payment_received",
"notify_callback_parse_error",
"notify_payment_gateway_failure",
"notify_payment_record_failure",
"notify_service_unavailable",
"notify_user_payment_failed",
"parse_payment_callback",
"payment_failed",
"payment_link_message_text",
"payment_link_response",
"payment_record_amounts",
"payment_unavailable",
"post_json_request",
"render_link_or_fail",
"render_payment_link",
"resolve_inviter_name",
"resolve_user_language",
"safe_callback_answer",
"safe_mark_failed_creation",
"safe_store_provider_payment_id",
"sale_mode_base",
"sale_mode_is_hwid_devices",
"sale_mode_is_traffic",
"sale_mode_tariff_key",
"send_success_message_to_user",
]
@@ -0,0 +1,322 @@
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import Optional
from aiogram import types
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_payment_url_keyboard
from bot.middlewares.i18n import JsonI18n
from db.dal import payment_dal
from db.models import Payment
from .common import (
Translator,
build_payment_description,
format_human_units,
mark_payment_failed_creation,
sale_mode_base,
)
@dataclass(frozen=True)
class PaymentCallbackParts:
months: float
price: float
sale_mode: str
@property
def human_value(self) -> str:
return format_human_units(self.months)
@property
def sale_base(self) -> str:
return sale_mode_base(self.sale_mode)
def parse_payment_callback(callback_data: str) -> Optional[PaymentCallbackParts]:
"""Parse the ``<prefix>:<value>:<price>:<sale_mode>`` payload all providers use.
Returns ``None`` if the payload doesn't have the expected shape — callers
answer with ``error_try_again`` in that case.
"""
try:
_, data_payload = callback_data.split(":", 1)
parts = data_payload.split(":")
months = float(parts[0])
price = float(parts[1])
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except (ValueError, IndexError):
return None
return PaymentCallbackParts(months=months, price=price, sale_mode=sale_mode)
async def safe_callback_answer(
callback: types.CallbackQuery,
text: Optional[str] = None,
*,
show_alert: bool = False,
) -> None:
"""``callback.answer`` that never raises (Telegram occasionally 400s)."""
try:
if text is None:
await callback.answer()
else:
await callback.answer(text, show_alert=show_alert)
except Exception:
pass
async def edit_or_answer(
callback: types.CallbackQuery,
text: str,
*,
reply_markup=None,
disable_web_page_preview: bool = False,
log_prefix: str = "payment_providers",
) -> None:
"""Edit the callback message if possible, else send a fresh reply."""
if not callback.message:
return
try:
await callback.message.edit_text(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
return
except Exception as exc:
logging.warning("%s: failed to edit message (%s), sending new one.", log_prefix, exc)
try:
await callback.message.answer(
text,
reply_markup=reply_markup,
disable_web_page_preview=disable_web_page_preview,
)
except Exception:
pass
def describe_payment(translator: Translator, parts: PaymentCallbackParts) -> str:
"""Shortcut around ``build_payment_description`` for callback usage."""
return build_payment_description(
translator,
months=parts.months,
sale_mode=parts.sale_mode,
human_value=parts.human_value,
)
def payment_link_message_text(
translator: Translator,
parts: PaymentCallbackParts,
*,
lead_text: Optional[str] = None,
) -> str:
"""Build the ``payment_link_message`` text (with optional lead block)."""
traffic_like = sale_mode_base(parts.sale_mode) in {
"traffic",
"traffic_package",
"topup",
"premium_topup",
}
key = (
"payment_link_message_traffic"
if traffic_like
else "payment_link_message"
)
body = translator(
key,
months=int(parts.months),
traffic_gb=parts.human_value,
)
if lead_text:
return f"{lead_text}\n\n{body}"
return body
async def render_payment_link(
callback: types.CallbackQuery,
*,
translator: Translator,
current_lang: str,
i18n: Optional[JsonI18n],
parts: PaymentCallbackParts,
payment_url: str,
lead_text: Optional[str] = None,
back_text_key: str = "back_to_payment_methods_button",
log_prefix: str = "payment_providers",
) -> None:
"""Show the payment link with the standard back button and shared fallbacks."""
text = payment_link_message_text(translator, parts, lead_text=lead_text)
keyboard = get_payment_url_keyboard(
payment_url,
current_lang,
i18n,
back_callback=f"subscribe_period:{parts.human_value}",
back_text_key=back_text_key,
)
await edit_or_answer(
callback,
text,
reply_markup=keyboard,
log_prefix=log_prefix,
)
await safe_callback_answer(callback)
async def notify_service_unavailable(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""Render the standard ``payment_service_unavailable`` UX."""
await safe_callback_answer(
callback,
translator("payment_service_unavailable_alert"),
show_alert=True,
)
if callback.message:
try:
await callback.message.edit_text(translator("payment_service_unavailable"))
except Exception:
pass
async def notify_callback_parse_error(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""The 4-line "callback payload looked wrong" guard every provider repeats."""
await safe_callback_answer(callback, translator("error_try_again"), show_alert=True)
async def notify_payment_record_failure(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""Both error_creating_payment_record + error_try_again shown after DB failure."""
if callback.message:
try:
await callback.message.edit_text(translator("error_creating_payment_record"))
except Exception:
pass
await safe_callback_answer(callback, translator("error_try_again"), show_alert=True)
async def notify_payment_gateway_failure(
callback: types.CallbackQuery,
translator: Translator,
) -> None:
"""``error_payment_gateway`` shown both inline and as alert."""
if callback.message:
try:
await callback.message.edit_text(translator("error_payment_gateway"))
except Exception:
pass
await safe_callback_answer(
callback,
translator("error_payment_gateway"),
show_alert=True,
)
async def safe_store_provider_payment_id(
session: AsyncSession,
payment: Payment,
*,
provider_payment_id: str,
new_status: Optional[str] = None,
log_prefix: str,
) -> bool:
"""Persist ``(provider_payment_id, status)`` on the payment with rollback-on-fail.
Returns True on success; logs and rolls back on failure. ``new_status``
defaults to the payment's existing status (used after a successful API call
that doesn't change the pending state).
"""
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
)
await session.commit()
return True
except Exception:
await session.rollback()
logging.exception(
"%s: failed to store provider payment id for payment %s.",
log_prefix,
payment.payment_id,
)
return False
async def safe_mark_failed_creation(
session: AsyncSession,
payment: Payment,
*,
log_prefix: str,
) -> None:
"""Mark the payment as ``failed_creation``; swallow + log on failure."""
try:
await mark_payment_failed_creation(session, payment.payment_id)
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark payment %s as failed_creation.",
log_prefix,
payment.payment_id,
)
async def render_link_or_fail(
callback: types.CallbackQuery,
*,
translator: Translator,
current_lang: str,
i18n: Optional[JsonI18n],
parts: "PaymentCallbackParts",
session: AsyncSession,
payment: Payment,
api_success: bool,
payment_url: Optional[str],
provider_payment_id: Optional[str] = None,
new_status: Optional[str] = None,
lead_text: Optional[str] = None,
log_prefix: str,
) -> None:
"""Finalize the link-based callback flow after the provider API responded.
Persists the provider payment id (when one was returned), shows the
payment link, or falls through to ``error_payment_gateway`` and marks the
payment as ``failed_creation``. Every link-style provider used to inline
this same sequence.
"""
if api_success and provider_payment_id:
await safe_store_provider_payment_id(
session,
payment,
provider_payment_id=provider_payment_id,
new_status=new_status,
log_prefix=log_prefix,
)
if api_success and payment_url:
await render_payment_link(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
payment_url=payment_url,
lead_text=lead_text,
log_prefix=log_prefix,
)
return
await safe_mark_failed_creation(session, payment, log_prefix=log_prefix)
await notify_payment_gateway_failure(callback, translator)
@@ -0,0 +1,247 @@
from __future__ import annotations
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal
from typing import Any, Callable, Optional
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import payment_dal
from db.models import Payment
from ..base import WebAppPaymentContext
Translator = Callable[..., str]
def make_translator(i18n: Any, language: str) -> Translator:
"""Return a ``_(key, **kw)`` callable that falls back to the key when i18n is absent."""
def _(key: str, **kwargs: Any) -> str:
if i18n is None:
return key
return i18n.gettext(language, key, **kwargs)
return _
def format_decimal_amount(amount: Any, places: int = 2) -> Decimal:
"""Quantize ``amount`` to the given decimal places using bank rounding."""
return Decimal(str(amount)).quantize(Decimal(10) ** -places, rounding=ROUND_HALF_UP)
def decimal_amounts_equal(left: Any, right: Any, places: int = 2) -> bool:
"""True when both values round to the same fixed-point representation."""
return format_decimal_amount(left, places) == format_decimal_amount(right, places)
def format_human_units(value: Any) -> str:
"""Render numeric units the way the UI expects: integers w/o decimals, floats with %g."""
numeric = float(value)
return str(int(numeric)) if numeric.is_integer() else f"{numeric:g}"
def build_payment_description(
translator: Translator,
*,
months: Any,
sale_mode: str,
human_value: Optional[str] = None,
) -> str:
"""Render the standard user-visible payment description.
Mirrors the branching every callback handler used to repeat
(traffic / hwid_devices / subscription).
"""
base = sale_mode_base(sale_mode)
if base in {"traffic", "traffic_package", "topup", "premium_topup"}:
return translator(
"payment_description_traffic",
traffic_gb=human_value if human_value is not None else format_human_units(months),
)
if base in {"hwid_device", "hwid_devices"}:
return translator("payment_description_hwid_devices", count=int(float(months)))
return translator("payment_description_subscription", months=int(float(months)))
def build_payment_record_payload(
*,
user_id: int,
amount: float,
currency: str,
status: str,
description: str,
months: Any,
provider: str,
sale_mode: str,
) -> dict:
"""Assemble the payment-record dict that every callback handler used to inline.
For the ``traffic`` sale modes, ``purchased_gb`` is taken from ``months``
(callbacks encode the GB amount in the ``months`` slot); webapp creators
use the ``payment_record_amounts`` helper directly to split the two.
"""
base = sale_mode_base(sale_mode)
is_traffic = sale_mode_is_traffic(sale_mode)
is_hwid = sale_mode_is_hwid_devices(sale_mode)
return {
"user_id": user_id,
"amount": amount,
"currency": currency,
"status": status,
"description": description,
"subscription_duration_months": int(float(months)) if base == "subscription" else None,
"provider": provider,
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
"purchased_hwid_devices": int(float(months)) if is_hwid else None,
}
@dataclass(frozen=True)
class PaymentRecordAmounts:
months: int
purchased_gb: Optional[float]
purchased_hwid_devices: Optional[int]
tariff_key: Optional[str]
traffic_sale: bool
hwid_devices_sale: bool
def sale_mode_base(sale_mode: str) -> str:
return str(sale_mode or "").split("@", 1)[0].split("|", 1)[0]
def sale_mode_is_traffic(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"traffic", "traffic_package", "topup", "premium_topup"}
def sale_mode_is_hwid_devices(sale_mode: str) -> bool:
return sale_mode_base(sale_mode) in {"hwid_device", "hwid_devices"}
def sale_mode_tariff_key(sale_mode: str) -> Optional[str]:
return str(sale_mode or "").split("@", 1)[1] if "@" in str(sale_mode or "") else None
def format_number_for_payload(value: Any) -> str:
value_float = float(value)
return str(int(value_float)) if value_float.is_integer() else f"{value_float:g}"
def payment_record_amounts(
*,
months: Any,
sale_mode: str,
traffic_gb: Optional[float] = None,
) -> PaymentRecordAmounts:
traffic_sale = sale_mode_is_traffic(sale_mode)
hwid_devices_sale = sale_mode_is_hwid_devices(sale_mode)
units = traffic_gb if traffic_sale and traffic_gb is not None else months
return PaymentRecordAmounts(
months=int(float(units)) if traffic_sale else int(float(months)),
purchased_gb=float(units) if traffic_sale else None,
purchased_hwid_devices=int(float(months)) if hwid_devices_sale else None,
tariff_key=sale_mode_tariff_key(sale_mode),
traffic_sale=traffic_sale,
hwid_devices_sale=hwid_devices_sale,
)
def json_error(status: int, code: str, message: str) -> web.Response:
return web.json_response({"ok": False, "error": code, "message": message}, status=status)
def payment_unavailable() -> web.Response:
return json_error(400, "payment_unavailable", "Payment method unavailable")
def payment_failed(message: str = "Failed to create payment") -> web.Response:
return json_error(502, "payment_failed", message)
def payment_link_response(
*,
payment_url: str,
payment_id: Optional[int],
action: str = "open_link",
) -> web.Response:
return web.json_response(
{
"ok": True,
"action": action,
"payment_url": payment_url,
"payment_id": payment_id,
}
)
async def create_base_payment_record(
session: AsyncSession,
*,
user_id: int,
amount: float,
currency: str,
status: str,
description: str,
months: int,
provider: str,
sale_mode: Optional[str] = None,
tariff_key: Optional[str] = None,
purchased_gb: Optional[float] = None,
purchased_hwid_devices: Optional[int] = None,
) -> Payment:
payment = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": amount,
"currency": currency,
"status": status,
"description": description,
"subscription_duration_months": months,
"provider": provider,
"sale_mode": sale_mode,
"tariff_key": tariff_key,
"purchased_gb": purchased_gb,
"purchased_hwid_devices": purchased_hwid_devices,
},
)
await session.commit()
return payment
async def create_webapp_payment_record(
ctx: WebAppPaymentContext,
*,
amount: float,
currency: str,
status: str,
provider: str,
) -> Payment:
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
return await create_base_payment_record(
ctx.session,
user_id=ctx.user_id,
amount=amount,
currency=currency,
status=status,
description=ctx.description,
months=amounts.months,
provider=provider,
sale_mode=ctx.sale_mode,
tariff_key=amounts.tariff_key,
purchased_gb=amounts.purchased_gb,
purchased_hwid_devices=amounts.purchased_hwid_devices,
)
async def mark_payment_failed_creation(session: AsyncSession, payment_id: int) -> None:
await payment_dal.update_payment_status_by_db_id(session, payment_id, "failed_creation")
await session.commit()
@@ -0,0 +1,95 @@
from __future__ import annotations
import json
import logging
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
from aiohttp import ClientSession, ClientTimeout
SuccessCheck = Callable[[int, Any], bool]
def http_ok(status: int, _body: Any) -> bool:
"""Default success criterion — HTTP 200 with any body."""
return status == 200
async def post_json_request(
session: ClientSession,
url: str,
*,
body: Any,
headers: Optional[Mapping[str, str]] = None,
log_prefix: str,
is_success: SuccessCheck = http_ok,
) -> Tuple[bool, Dict[str, Any]]:
"""Centralized JSON-POST every HTTP-API provider used to inline ~25 lines for.
On transport failure, JSON decode failure, or rejected ``is_success`` check,
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
can decide what to do (typically: mark the payment as ``failed_creation``).
"""
try:
async with session.post(
url,
json=body,
headers=dict(headers) if headers else None,
) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
except json.JSONDecodeError:
logging.error("%s: invalid JSON response: %s", log_prefix, response_text)
return False, {
"status": response.status,
"message": "invalid_json",
"raw": response_text,
}
if not is_success(response.status, response_data):
logging.error(
"%s: API returned error (status=%s, body=%s)",
log_prefix,
response.status,
response_data,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(exc)}
def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]:
"""Return the first non-empty value among ``keys`` (cast to ``str``)."""
if not data:
return None
for key in keys:
value = data.get(key)
if value:
return str(value)
return None
class HttpClientMixin:
"""Shared lazy ``aiohttp.ClientSession`` lifecycle for provider services.
Each subclass calls ``self._init_http_client(total_timeout=...)`` from
``__init__`` and inherits ``_get_session`` / ``close``. The session is
created on first use and recreated transparently if it was closed.
"""
_timeout: ClientTimeout
_session: Optional[ClientSession]
def _init_http_client(self, *, total_timeout: float = 20.0) -> None:
self._timeout = ClientTimeout(total=total_timeout)
self._session = None
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
return self._session
async def close(self) -> None:
if self._session and not self._session.closed:
await self._session.close()
@@ -0,0 +1,383 @@
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
from bot.services.notification_service import NotificationService
from bot.utils.config_link import prepare_config_links
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
from db.dal import payment_dal, user_dal
from db.models import Payment, User
from .common import Translator, format_human_units, make_translator, sale_mode_base
_TRAFFIC_MODES = {"traffic", "traffic_package", "topup", "premium_topup"}
def is_traffic_sale_base(sale_base: str) -> bool:
return sale_base in _TRAFFIC_MODES
async def resolve_user_language(
session: AsyncSession,
*,
user_id: int,
db_user: Optional[User],
settings: Any,
) -> tuple[Optional[User], str]:
"""Return the loaded user and the language to use for messaging."""
if db_user is None:
db_user = await user_dal.get_user_by_id(session, user_id)
language = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
return db_user, language
async def resolve_inviter_name(
session: AsyncSession,
translator: Translator,
db_user: Optional[User],
) -> str:
"""Return a display name for the user's inviter, or the localized placeholder."""
placeholder = translator("friend_placeholder")
if not db_user or not db_user.referred_by_id:
return placeholder
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
if not inviter:
return placeholder
if inviter.first_name:
safe_name = sanitize_display_name(inviter.first_name)
if safe_name:
return safe_name
if inviter.username:
return username_for_display(inviter.username, with_at=False)
return placeholder
@dataclass
class SuccessMessage:
"""Inputs for ``build_success_message``."""
translator: Translator
sale_mode: str
months: Any
base_end_date: Optional[datetime]
final_end_date: Optional[datetime]
config_link_text: str
applied_referee_bonus_days: int = 0
applied_promo_bonus_days: int = 0
inviter_name: Optional[str] = None
fallback_date_text: str = ""
def _fmt_date(dt: Optional[datetime], fallback: str) -> str:
return dt.strftime("%Y-%m-%d") if dt else fallback
def build_success_message(payload: SuccessMessage) -> str:
"""Render the post-payment user-facing text.
Picks one of: ``payment_successful_traffic_full`` /
``payment_successful_with_referral_bonus_full`` /
``payment_successful_with_promo_full`` / ``payment_successful_full``.
"""
base = sale_mode_base(payload.sale_mode)
_ = payload.translator
end_text = _fmt_date(payload.final_end_date, payload.fallback_date_text)
if is_traffic_sale_base(base):
return _(
"payment_successful_traffic_full",
traffic_gb=format_human_units(payload.months),
end_date=end_text,
config_link=payload.config_link_text,
)
if payload.applied_referee_bonus_days and payload.final_end_date:
base_end_text = _fmt_date(payload.base_end_date or payload.final_end_date, end_text)
return _(
"payment_successful_with_referral_bonus_full",
months=payload.months,
base_end_date=base_end_text,
bonus_days=payload.applied_referee_bonus_days,
final_end_date=end_text,
inviter_name=payload.inviter_name or _("friend_placeholder"),
config_link=payload.config_link_text,
)
if payload.applied_promo_bonus_days and payload.final_end_date:
return _(
"payment_successful_with_promo_full",
months=payload.months,
bonus_days=payload.applied_promo_bonus_days,
end_date=end_text,
config_link=payload.config_link_text,
)
return _(
"payment_successful_full",
months=payload.months,
end_date=end_text,
config_link=payload.config_link_text,
)
async def send_success_message_to_user(
*,
bot: Bot,
user_id: int,
text: str,
language: str,
i18n: Any,
settings: Any,
config_link_display: Optional[str],
connect_button_url: Optional[str],
include_keyboard: bool = True,
log_prefix: str = "payment_providers",
) -> None:
"""Send the rendered success text with the standard connect keyboard."""
markup = None
if include_keyboard:
markup = get_connect_and_main_keyboard(
language,
i18n,
settings,
config_link_display,
connect_button_url=connect_button_url,
preserve_message=True,
)
try:
await bot.send_message(
user_id,
text,
reply_markup=markup,
parse_mode="HTML",
disable_web_page_preview=True,
)
except Exception:
logging.exception("%s: failed to notify user %s.", log_prefix, user_id)
async def notify_admins_payment_received(
*,
bot: Bot,
settings: Any,
i18n: Any,
user_id: int,
amount: float,
currency: str,
months_for_admin: int,
traffic_gb_for_admin: Optional[float],
payment_provider: str,
username: Optional[str],
traffic_is_premium: bool,
tariff_key: Optional[str],
log_prefix: str = "payment_providers",
) -> None:
"""Push the standard ``notify_payment_received`` to the admin log channel."""
try:
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_payment_received(
user_id=user_id,
amount=amount,
currency=currency,
months=months_for_admin,
traffic_gb=traffic_gb_for_admin,
payment_provider=payment_provider,
username=username,
traffic_is_premium=traffic_is_premium,
tariff_key=tariff_key,
)
except Exception:
logging.exception("%s: failed to notify admins.", log_prefix)
@dataclass
class PaymentSuccessRequest:
"""All the inputs ``finalize_successful_payment`` needs."""
bot: Bot
settings: Any
i18n: Any
session: AsyncSession
subscription_service: Any
referral_service: Any
payment: Payment
user_id: int
amount: float
currency: str
sale_mode: str
months: Any
traffic_amount: Optional[float]
provider_subscription: str
provider_notification: str
db_user: Optional[User] = None
log_prefix: str = "payment_providers"
activation_extra_kwargs: dict = field(default_factory=dict)
skip_keyboard: bool = False
text_prefix: Optional[str] = None
@dataclass
class PaymentSuccessOutcome:
activation: Optional[dict]
referral_bonus: Optional[dict]
final_end_date: Optional[datetime]
applied_referee_bonus_days: int
applied_promo_bonus_days: int
db_user: Optional[User]
language: str
async def finalize_successful_payment(
req: PaymentSuccessRequest,
) -> Optional[PaymentSuccessOutcome]:
"""Activate the subscription, apply referral bonus, notify user + admins.
Returns ``None`` if the activation pipeline failed mid-way (errors are
logged and the session is rolled back). On success returns an outcome
object so callers can drive extra side-effects (e.g. yookassa LKNPD
receipts) using the same activation result.
"""
base = sale_mode_base(req.sale_mode)
is_subscription = base == "subscription"
is_traffic = is_traffic_sale_base(base)
activation_months = (
int(float(req.months)) if is_subscription else int(float(req.traffic_amount or req.months))
)
traffic_gb_for_activation = (
float(req.traffic_amount or req.months) if is_traffic else None
)
try:
activation = await req.subscription_service.activate_subscription(
req.session,
req.user_id,
activation_months,
req.amount,
req.payment.payment_id,
provider=req.provider_subscription,
sale_mode=req.sale_mode,
traffic_gb=traffic_gb_for_activation,
**req.activation_extra_kwargs,
)
referral_bonus = None
if is_subscription:
referral_bonus = await req.referral_service.apply_referral_bonuses_for_payment(
req.session,
req.user_id,
activation_months or 1,
current_payment_db_id=req.payment.payment_id,
skip_if_active_before_payment=False,
)
await req.session.commit()
except Exception:
await req.session.rollback()
logging.exception(
"%s: failed to activate subscription for payment %s.",
req.log_prefix,
req.payment.payment_id,
)
return None
db_user, language = await resolve_user_language(
req.session,
user_id=req.user_id,
db_user=req.db_user,
settings=req.settings,
)
translator = make_translator(req.i18n, language)
raw_config_link = activation.get("subscription_url") if activation else None
config_link_display, connect_button_url = await prepare_config_links(
req.settings, raw_config_link
)
config_link_text = config_link_display or translator("config_link_not_available")
base_end_date = activation.get("end_date") if activation else None
final_end_date = base_end_date
applied_referee_bonus_days = 0
applied_promo_bonus_days = (
activation.get("applied_promo_bonus_days", 0) if activation else 0
)
inviter_name: Optional[str] = None
if referral_bonus and referral_bonus.get("referee_new_end_date"):
final_end_date = referral_bonus["referee_new_end_date"]
applied_referee_bonus_days = referral_bonus.get("referee_bonus_applied_days", 0) or 0
inviter_name = await resolve_inviter_name(req.session, translator, db_user)
success_text = build_success_message(
SuccessMessage(
translator=translator,
sale_mode=req.sale_mode,
months=(
activation_months
if is_subscription
else format_human_units(req.traffic_amount or req.months)
),
base_end_date=base_end_date,
final_end_date=final_end_date,
config_link_text=config_link_text,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
inviter_name=inviter_name,
)
)
if req.text_prefix:
success_text = f"{req.text_prefix}\n{success_text}"
await send_success_message_to_user(
bot=req.bot,
user_id=req.user_id,
text=success_text,
language=language,
i18n=req.i18n,
settings=req.settings,
config_link_display=config_link_display,
connect_button_url=connect_button_url,
include_keyboard=not req.skip_keyboard,
log_prefix=req.log_prefix,
)
refreshed_payment = await payment_dal.get_payment_by_db_id(
req.session, req.payment.payment_id
)
tariff_key = getattr(refreshed_payment or req.payment, "tariff_key", None)
await notify_admins_payment_received(
bot=req.bot,
settings=req.settings,
i18n=req.i18n,
user_id=req.user_id,
amount=req.amount,
currency=req.currency,
months_for_admin=activation_months if is_subscription else 0,
traffic_gb_for_admin=traffic_gb_for_activation,
payment_provider=req.provider_notification,
username=db_user.username if db_user else None,
traffic_is_premium=base == "premium_topup",
tariff_key=tariff_key,
log_prefix=req.log_prefix,
)
return PaymentSuccessOutcome(
activation=activation,
referral_bonus=referral_bonus,
final_end_date=final_end_date,
applied_referee_bonus_days=applied_referee_bonus_days,
applied_promo_bonus_days=applied_promo_bonus_days,
db_user=db_user,
language=language,
)
@@ -0,0 +1,71 @@
from __future__ import annotations
import logging
from typing import Optional
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import payment_dal
from db.models import Payment
from .common import mark_payment_failed_creation, payment_failed, payment_link_response
async def finalize_webapp_link_payment(
*,
session: AsyncSession,
payment: Payment,
api_success: bool,
payment_url: Optional[str],
provider_payment_id: Optional[str] = None,
new_status: Optional[str] = None,
log_prefix: str,
) -> web.Response:
"""The trailing "persist id → return link or fail" used by every link-style webapp creator.
Mirrors :func:`render_link_or_fail` but for the webapp HTTP context: instead
of editing a Telegram message it returns either ``payment_link_response``
or ``payment_failed``. Provider modules just call:
payment = await create_webapp_payment_record(ctx, ...)
success, data = await service.create_xxx(...)
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(data, "url", "payment_url"),
provider_payment_id=first_value(data, "id"),
log_prefix="Wata",
)
"""
if api_success and provider_payment_id:
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
str(provider_payment_id),
new_status or payment.status,
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"%s: failed to persist provider payment id for payment %s.",
log_prefix,
payment.payment_id,
)
if not payment_url:
try:
await mark_payment_failed_creation(session, payment.payment_id)
except Exception:
await session.rollback()
logging.exception(
"%s: failed to mark payment %s as failed_creation.",
log_prefix,
payment.payment_id,
)
return payment_failed()
return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id)
@@ -0,0 +1,69 @@
from __future__ import annotations
import logging
from typing import Any, Optional
from aiogram import Bot
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import payment_dal, user_dal
from db.models import Payment
from .common import make_translator
def coerce_payment_db_id(order_id_raw: Any) -> Optional[int]:
"""Pull a numeric DB id out of a webhook's ``orderId``/``order_id`` field."""
if isinstance(order_id_raw, int):
return order_id_raw
if isinstance(order_id_raw, str) and order_id_raw.isdigit():
return int(order_id_raw)
return None
async def lookup_payment_by_order_or_provider_id(
session: AsyncSession,
*,
order_id_raw: Any = None,
provider_payment_id: Optional[str] = None,
) -> Optional[Payment]:
"""Find a payment by DB id first, fall back to provider id.
Returns ``None`` so callers stay in charge of the not-found response.
"""
payment_db_id = coerce_payment_db_id(order_id_raw)
payment: Optional[Payment] = None
if payment_db_id is not None:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment and provider_payment_id:
payment = await payment_dal.get_payment_by_provider_payment_id(
session, provider_payment_id
)
return payment
async def notify_user_payment_failed(
*,
bot: Bot,
settings: Any,
i18n: Any,
session: AsyncSession,
payment: Payment,
message_key: str = "payment_failed",
) -> None:
"""Send the localized ``payment_failed`` text to the user; never raises."""
db_user = payment.user or await user_dal.get_user_by_id(session, payment.user_id)
language = (
db_user.language_code
if db_user and db_user.language_code
else settings.DEFAULT_LANGUAGE
)
translator = make_translator(i18n, language)
try:
await bot.send_message(payment.user_id, translator(message_key))
except Exception:
logging.exception(
"Webhook helper: failed to notify user %s about %s.",
payment.user_id,
message_key,
)
+369
View File
@@ -0,0 +1,369 @@
import logging
from typing import Optional
from aiogram import Bot, F, Router, types
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, LabeledPrice
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from config.settings import Settings
from db.dal import payment_dal
from .base import (
PaymentProviderSpec,
ServiceFactoryContext,
WebAppPaymentContext,
)
from .shared import (
PaymentSuccessRequest,
create_webapp_payment_record,
describe_payment,
finalize_successful_payment,
format_number_for_payload,
make_translator,
notify_callback_parse_error,
notify_service_unavailable,
parse_payment_callback,
payment_failed,
payment_record_amounts,
payment_unavailable,
safe_callback_answer,
sale_mode_base,
sale_mode_tariff_key,
)
class StarsService:
def __init__(
self,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
subscription_service: SubscriptionService,
referral_service: ReferralService,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.subscription_service = subscription_service
self.referral_service = referral_service
async def create_invoice(
self,
session: AsyncSession,
user_id: int,
months: int,
stars_price: int,
description: str,
sale_mode: str = "subscription",
) -> Optional[int]:
sale_base = sale_mode_base(sale_mode)
is_traffic = sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"currency": "XTR",
"status": "pending_stars",
"description": description,
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
"provider": "telegram_stars",
"sale_mode": sale_mode,
"tariff_key": sale_mode_tariff_key(sale_mode),
"purchased_gb": float(months) if is_traffic else None,
}
try:
db_payment_record = await payment_dal.create_payment_record(
session, payment_record_data
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to create stars payment record")
return None
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
prices = [LabeledPrice(label=description, amount=stars_price)]
try:
await self.bot.send_invoice(
chat_id=user_id,
title=description,
description=description,
payload=payload,
# Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
provider_token="",
currency="XTR",
prices=prices,
)
return db_payment_record.payment_id
except Exception:
logging.exception("Failed to send Telegram Stars invoice")
return None
async def process_successful_payment(
self,
session: AsyncSession,
message: types.Message,
payment_db_id: int,
months: int,
stars_amount: int,
i18n_data: dict,
sale_mode: str = "subscription",
) -> None:
try:
payment_record = await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
message.successful_payment.provider_payment_charge_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception("Failed to update stars payment record %s", payment_db_id)
return
target_user_id = (
int(payment_record.user_id)
if payment_record and payment_record.user_id is not None
else int(message.from_user.id)
)
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("Stars: payment %s vanished after status update.", payment_db_id)
return
await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=i18n_data.get("i18n_instance") or self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=target_user_id,
amount=float(stars_amount),
currency="XTR",
sale_mode=sale_mode,
months=months,
traffic_amount=float(months),
provider_subscription="telegram_stars",
provider_notification="stars",
log_prefix="Stars",
)
)
router = Router(name="user_subscription_payments_stars_router")
@router.callback_query(F.data.startswith("pay_stars:"))
async def pay_stars_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not settings.STARS_ENABLED:
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
await notify_callback_parse_error(callback, translator)
return
# ``parts.price`` for the Stars callback is the integer Stars price.
stars_price = int(parts.price)
payment_description = describe_payment(translator, parts)
payment_db_id = await stars_service.create_invoice(
session=session,
user_id=callback.from_user.id,
months=parts.months,
stars_price=stars_price,
description=payment_description,
sale_mode=parts.sale_mode,
)
if payment_db_id:
sale_base = parts.sale_base
text_key = (
"payment_invoice_sent_message_traffic"
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
else "payment_invoice_sent_message"
)
markup = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=translator("back_to_payment_methods_button"),
callback_data=f"subscribe_period:{parts.human_value}",
)
]
]
)
try:
await callback.message.edit_text(
translator(
text_key,
months=int(parts.months),
traffic_gb=parts.human_value,
),
reply_markup=markup,
)
except Exception:
logging.warning("Stars payment: failed to show invoice info message")
await safe_callback_answer(callback)
return
await safe_callback_answer(callback, translator("error_payment_gateway"), show_alert=True)
@router.pre_checkout_query()
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
try:
await query.answer(ok=True)
except Exception:
# Nothing else to do here; Telegram will show an error if not answered
pass
@router.message(F.successful_payment)
async def handle_successful_stars_payment(
message: types.Message,
settings: Settings,
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
):
payload = (
message.successful_payment.invoice_payload if message and message.successful_payment else ""
)
try:
parts = (payload or "").split(":")
payment_db_id = int(parts[0])
months = float(parts[1]) if len(parts) > 1 else 0
sale_mode = parts[2] if len(parts) > 2 else "subscription"
except Exception:
return
stars_amount = int(message.successful_payment.total_amount) if message.successful_payment else 0
await stars_service.process_successful_payment(
session=session,
message=message,
payment_db_id=payment_db_id,
months=months,
stars_amount=stars_amount,
i18n_data=i18n_data,
sale_mode=sale_mode,
)
def create_service(ctx: ServiceFactoryContext) -> StarsService:
return StarsService(
ctx.bot,
ctx.settings,
ctx.i18n,
ctx.subscription_service,
ctx.referral_service,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
if ctx.stars_price is None:
return payment_unavailable()
bot = ctx.request.app["bot"]
try:
amounts = payment_record_amounts(
months=ctx.months,
sale_mode=ctx.sale_mode,
traffic_gb=ctx.traffic_gb,
)
payment = await create_webapp_payment_record(
ctx,
amount=float(ctx.stars_price),
currency="XTR",
status="pending_stars",
provider="telegram_stars",
)
payload_units = amounts.purchased_gb if amounts.traffic_sale else ctx.months
payload = (
f"{payment.payment_id}:{format_number_for_payload(payload_units)}:{ctx.sale_mode}"
)
prices = [LabeledPrice(label=ctx.description, amount=ctx.stars_price)]
create_invoice_link = getattr(bot, "create_invoice_link", None)
if callable(create_invoice_link):
invoice_url = await create_invoice_link(
title=ctx.description,
description=ctx.description,
payload=payload,
# Required to be empty for Telegram Stars (XTR) per Telegram Bot API.
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "open_invoice",
"payment_url": invoice_url,
"payment_id": payment.payment_id,
}
)
await bot.send_invoice(
chat_id=ctx.user_id,
title=ctx.description,
description=ctx.description,
payload=payload,
provider_token="",
currency="XTR",
prices=prices,
)
return web.json_response(
{
"ok": True,
"action": "invoice_sent",
"payment_id": payment.payment_id,
}
)
except Exception:
await ctx.session.rollback()
logging.exception("Stars WebApp payment failed")
return payment_failed("Failed to create invoice")
SPEC = PaymentProviderSpec(
id="stars",
provider_key="telegram_stars",
label="Telegram Stars",
webapp_label="Telegram Stars",
webapp_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"},
webapp_icon="Sparkles",
telegram_labels={"ru": "Звёзды Telegram", "en": "Telegram Stars"},
pending_status="pending_stars",
enabled=lambda settings: settings.STARS_ENABLED,
service_key="stars_service",
button_text_key="pay_with_stars_button",
callback_prefix="pay_stars",
router=router,
create_service=create_service,
create_webapp_payment=create_webapp_payment,
requires_configured_service=False,
price_source="stars",
emoji="",
telegram_emoji="",
)
+450
View File
@@ -0,0 +1,450 @@
import base64
import json
import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, Optional, Tuple
from aiogram import Bot, F, Router, types
from aiohttp import web
from cryptography.exceptions import InvalidSignature
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import padding
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import sessionmaker
from bot.middlewares.i18n import JsonI18n
from bot.services.referral_service import ReferralService
from bot.services.subscription_service import SubscriptionService
from bot.utils.request_security import ip_in_allowlist, request_client_ip
from config.settings import Settings
from db.dal import payment_dal
from .base import PaymentProviderSpec, ServiceFactoryContext, WebAppPaymentContext
from .shared import (
HttpClientMixin,
PaymentSuccessRequest,
build_payment_record_payload,
create_webapp_payment_record,
decimal_amounts_equal,
describe_payment,
finalize_successful_payment,
finalize_webapp_link_payment,
first_value,
format_decimal_amount,
lookup_payment_by_order_or_provider_id,
make_translator,
notify_callback_parse_error,
notify_payment_record_failure,
notify_service_unavailable,
notify_user_payment_failed,
parse_payment_callback,
payment_failed,
payment_unavailable,
post_json_request,
render_link_or_fail,
)
router = Router(name="user_subscription_payments_wata_router")
_LOG = "wata"
class WataService(HttpClientMixin):
def __init__(
self,
*,
bot: Bot,
settings: Settings,
i18n: JsonI18n,
async_session_factory: sessionmaker,
subscription_service: SubscriptionService,
referral_service: ReferralService,
default_return_url: str,
):
self.bot = bot
self.settings = settings
self.i18n = i18n
self.async_session_factory = async_session_factory
self.subscription_service = subscription_service
self.referral_service = referral_service
self.base_url = (settings.WATA_BASE_URL or "https://api.wata.pro/api/h2h").rstrip("/")
self.api_token = settings.WATA_API_TOKEN or ""
self.return_url = settings.WATA_RETURN_URL or f"https://t.me/{default_return_url}"
self.failed_url = settings.WATA_FAILED_URL or self.return_url
self.payment_link_ttl_days = settings.WATA_PAYMENT_LINK_TTL_DAYS
self.verify_webhook_signature = settings.WATA_WEBHOOK_VERIFY_SIGNATURE
self._public_key_pem = settings.WATA_PUBLIC_KEY
self._init_http_client(total_timeout=20)
self.configured: bool = bool(settings.WATA_ENABLED and self.api_token)
if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.")
def _auth_headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self.api_token}",
"Content-Type": "application/json",
}
async def create_payment_link(
self,
*,
payment_db_id: int,
amount: float,
currency: Optional[str],
description: str,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("WataService is not configured. Cannot create payment link.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
expires_at = datetime.now(timezone.utc) + timedelta(days=self.payment_link_ttl_days)
body: Dict[str, Any] = {
"amount": float(format_decimal_amount(amount)),
"currency": (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper(),
"description": description,
"orderId": str(payment_db_id),
"successRedirectUrl": self.return_url,
"failRedirectUrl": self.failed_url,
"expirationDateTime": expires_at.isoformat().replace("+00:00", "Z"),
}
return await post_json_request(
session,
f"{self.base_url}/links",
body=body,
headers=self._auth_headers(),
log_prefix="Wata create_payment_link",
)
async def _get_public_key_pem(self) -> Optional[str]:
if self._public_key_pem:
return self._public_key_pem.replace("\\n", "\n")
session = await self._get_session()
try:
async with session.get(f"{self.base_url}/public-key") as response:
if response.status != 200:
logging.error("Wata public key request failed with status %s", response.status)
return None
data = await response.json()
value = data.get("value") if isinstance(data, dict) else None
if isinstance(value, str) and value.strip():
self._public_key_pem = value
return value.replace("\\n", "\n")
except Exception:
logging.exception("Wata public key request failed.")
return None
async def _verify_signature(self, raw_body: bytes, signature_header: str) -> bool:
if not signature_header:
return False
public_key_pem = await self._get_public_key_pem()
if not public_key_pem:
return False
try:
public_key = serialization.load_pem_public_key(public_key_pem.encode("utf-8"))
signature = base64.b64decode(signature_header)
public_key.verify(signature, raw_body, padding.PKCS1v15(), hashes.SHA512())
return True
except (InvalidSignature, ValueError, TypeError):
logging.warning("Wata webhook: invalid signature.")
return False
except Exception:
logging.exception("Wata webhook: signature verification failed.")
return False
async def webhook_route(self, request: web.Request) -> web.Response:
if not self.configured:
return web.Response(status=503, text="wata_disabled")
client_ip = request_client_ip(request, trusted_proxies=self.settings.trusted_proxies)
if self.settings.wata_trusted_ips and not ip_in_allowlist(
client_ip, self.settings.wata_trusted_ips
):
logging.warning("Wata webhook denied from unauthorized IP source.")
return web.Response(status=403, text="forbidden")
raw_body = await request.read()
if self.verify_webhook_signature:
signature = request.headers.get("X-Signature", "")
if not await self._verify_signature(raw_body, signature):
return web.Response(status=403, text="invalid_signature")
try:
payload = json.loads(raw_body.decode("utf-8"))
except Exception:
logging.exception("Wata webhook: failed to parse JSON.")
return web.Response(status=400, text="bad_request")
transaction_id = str(payload.get("transactionId") or "").strip()
status = str(payload.get("transactionStatus") or "").strip().lower()
order_id_raw = payload.get("orderId")
amount_raw = payload.get("amount")
currency = payload.get("currency") or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
if not status or not (transaction_id or order_id_raw):
logging.error("Wata webhook: missing transaction status or ids: %s", payload)
return web.Response(status=400, text="missing_fields")
async with self.async_session_factory() as session:
payment = await lookup_payment_by_order_or_provider_id(
session,
order_id_raw=order_id_raw,
provider_payment_id=transaction_id or None,
)
if not payment:
logging.error(
"Wata webhook: payment not found (order_id=%s, transaction_id=%s)",
order_id_raw,
transaction_id,
)
return web.Response(status=404, text="payment_not_found")
if payment.status == "succeeded" and status == "paid":
return web.Response(text="ok")
resolved_transaction_id = transaction_id or str(payment.payment_id)
if status == "paid":
if amount_raw is not None:
try:
if not decimal_amounts_equal(amount_raw, payment.amount):
logging.warning(
"Wata webhook: amount mismatch for payment %s "
"(expected %s, got %s)",
payment.payment_id,
format_decimal_amount(payment.amount),
format_decimal_amount(amount_raw),
)
except Exception as exc:
logging.warning(
"Wata webhook: failed to compare amounts for %s: %s",
payment.payment_id,
exc,
)
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_transaction_id,
"succeeded",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to mark payment %s as succeeded.",
resolved_transaction_id,
)
return web.Response(status=500, text="processing_error")
payment_units = payment.purchased_gb or payment.subscription_duration_months or 1
sale_mode = payment.sale_mode or (
"traffic" if self.settings.traffic_sale_mode else "subscription"
)
outcome = await finalize_successful_payment(
PaymentSuccessRequest(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
subscription_service=self.subscription_service,
referral_service=self.referral_service,
payment=payment,
user_id=payment.user_id,
amount=float(payment.amount),
currency=str(currency),
sale_mode=sale_mode,
months=payment_units,
traffic_amount=float(payment_units),
provider_subscription="wata",
provider_notification="wata",
db_user=payment.user,
log_prefix="Wata webhook",
)
)
if outcome is None:
return web.Response(status=500, text="processing_error")
return web.Response(text="ok")
if status == "declined":
try:
await payment_dal.update_provider_payment_and_status(
session,
payment.payment_id,
resolved_transaction_id,
"failed",
)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata webhook: failed to mark payment %s as failed.",
resolved_transaction_id,
)
return web.Response(status=500, text="processing_error")
await notify_user_payment_failed(
bot=self.bot,
settings=self.settings,
i18n=self.i18n,
session=session,
payment=payment,
)
return web.Response(text="ok")
logging.warning(
"Wata webhook: unhandled status '%s' for transaction %s",
status,
transaction_id,
)
return web.Response(status=202, text="status_ignored")
@router.callback_query(F.data.startswith("pay_wata:"))
async def pay_wata_callback_handler(
callback: types.CallbackQuery,
settings: Settings,
i18n_data: dict,
wata_service: WataService,
session: AsyncSession,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
translator = make_translator(i18n, current_lang)
if not i18n or not callback.message:
await notify_callback_parse_error(callback, translator)
return
if not wata_service or not wata_service.configured:
logging.error("Wata service is not configured or unavailable.")
await notify_service_unavailable(callback, translator)
return
parts = parse_payment_callback(callback.data or "")
if not parts:
logging.error("Invalid pay_wata data in callback: %s", callback.data)
await notify_callback_parse_error(callback, translator)
return
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
payment_description = describe_payment(translator, parts)
record_payload = build_payment_record_payload(
user_id=callback.from_user.id,
amount=parts.price,
currency=currency_code,
status="pending_wata",
description=payment_description,
months=parts.months,
provider="wata",
sale_mode=parts.sale_mode,
)
try:
payment_record = await payment_dal.create_payment_record(session, record_payload)
await session.commit()
except Exception:
await session.rollback()
logging.exception(
"Wata: failed to create payment record for user %s.", callback.from_user.id
)
await notify_payment_record_failure(callback, translator)
return
success, response_data = await wata_service.create_payment_link(
payment_db_id=payment_record.payment_id,
amount=parts.price,
currency=currency_code,
description=payment_description,
)
await render_link_or_fail(
callback,
translator=translator,
current_lang=current_lang,
i18n=i18n,
parts=parts,
session=session,
payment=payment_record,
api_success=success,
payment_url=first_value(response_data, "url"),
provider_payment_id=first_value(response_data, "id"),
log_prefix=_LOG,
)
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
settings: Settings = ctx.request.app["settings"]
service: WataService = ctx.request.app["wata_service"]
if not service or not service.configured:
return payment_unavailable()
currency = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
try:
payment = await create_webapp_payment_record(
ctx,
amount=ctx.price,
currency=currency,
status="pending_wata",
provider="wata",
)
success, response_data = await service.create_payment_link(
payment_db_id=payment.payment_id,
amount=ctx.price,
currency=currency,
description=ctx.description,
)
except Exception:
await ctx.session.rollback()
logging.exception("Wata WebApp payment failed")
return payment_failed()
return await finalize_webapp_link_payment(
session=ctx.session,
payment=payment,
api_success=success,
payment_url=first_value(response_data, "url") if success else None,
provider_payment_id=first_value(response_data, "id"),
log_prefix="Wata",
)
async def wata_webhook_route(request: web.Request) -> web.Response:
service: WataService = request.app["wata_service"]
return await service.webhook_route(request)
def create_service(ctx: ServiceFactoryContext) -> WataService:
return WataService(
bot=ctx.bot,
settings=ctx.settings,
i18n=ctx.i18n,
async_session_factory=ctx.async_session_factory,
subscription_service=ctx.subscription_service,
referral_service=ctx.referral_service,
default_return_url=ctx.bot_username_for_default_return,
)
SPEC = PaymentProviderSpec(
id="wata",
provider_key="wata",
label="Wata",
webapp_label="Wata",
webapp_labels={"ru": "Wata", "en": "Wata"},
webapp_icon="WalletCards",
telegram_labels={"ru": "Wata", "en": "Wata"},
telegram_emoji="💳",
pending_status="pending_wata",
enabled=lambda settings: settings.WATA_ENABLED,
service_key="wata_service",
button_text_key="pay_with_wata_button",
callback_prefix="pay_wata",
router=router,
create_service=create_service,
webhook_path=lambda settings: settings.wata_webhook_path,
webhook_route=wata_webhook_route,
create_webapp_payment=create_webapp_payment,
)
File diff suppressed because it is too large Load Diff