Files
remnawave-minishop/backend/bot/payment_providers/stars.py
T

372 lines
12 KiB
Python

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.keyboards.inline.user_keyboards import payment_methods_back_callback
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=payment_methods_back_callback(
parts.human_value, parts.sale_mode, parts.price
),
)
]
]
)
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",
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="⭐",
)