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

488 lines
16 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 pydantic_settings import SettingsConfigDict
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,
ProviderEnvConfig,
ProviderManifestField,
ServiceFactoryContext,
WebAppPaymentContext,
provider_env_file,
)
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,
quote_hwid_callback_parts,
safe_callback_answer,
sale_mode_base,
sale_mode_tariff_key,
)
class StarsPresentation(ProviderEnvConfig):
model_config = SettingsConfigDict(
env_file=provider_env_file(),
env_file_encoding="utf-8",
env_prefix="PAYMENT_STARS_",
extra="ignore",
)
WEBAPP_LABEL_RU: Optional[str] = None
WEBAPP_LABEL_EN: Optional[str] = None
WEBAPP_ICON: Optional[str] = None
TELEGRAM_LABEL_RU: Optional[str] = None
TELEGRAM_LABEL_EN: Optional[str] = None
TELEGRAM_EMOJI: Optional[str] = None
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",
hwid_quote: Optional[dict] = None,
) -> Optional[int]:
amounts = payment_record_amounts(months=months, sale_mode=sale_mode)
sale_base = sale_mode_base(sale_mode)
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": amounts.purchased_gb,
"purchased_hwid_devices": amounts.purchased_hwid_devices,
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
if hwid_quote
else None,
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote 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:
payment = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment:
logging.error("Stars: payment %s not found.", payment_db_id)
return
if payment.status == "succeeded":
logging.info("Stars: payment %s already succeeded.", payment_db_id)
return
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)
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 SPEC.is_available_to_user(
settings,
user_id=callback.from_user.id,
require_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
parts, hwid_quote = await quote_hwid_callback_parts(
session=session,
user_id=callback.from_user.id,
parts=parts,
subscription_service=stars_service.subscription_service,
currency="stars",
)
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,
hwid_quote=hwid_quote,
)
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")
_PRESENTATION_MANIFEST = tuple(
ProviderManifestField(
key=key,
type=type_,
label=label,
description=description,
placeholder=placeholder,
subsection="Telegram Stars",
target="presentation",
attr=attr,
)
for key, type_, label, description, placeholder, attr in (
(
"PAYMENT_STARS_WEBAPP_LABEL_RU",
"string",
"WebApp button text (RU)",
"Custom Russian text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_RU",
),
(
"PAYMENT_STARS_WEBAPP_LABEL_EN",
"string",
"WebApp button text (EN)",
"Custom English text shown in the Web App payment method button.",
"",
"WEBAPP_LABEL_EN",
),
(
"PAYMENT_STARS_WEBAPP_ICON",
"icon",
"WebApp button icon",
"Lucide icon name rendered inside the Web App payment method button.",
"Sparkles",
"WEBAPP_ICON",
),
(
"PAYMENT_STARS_TELEGRAM_LABEL_RU",
"string",
"Telegram button text (RU)",
"Custom Russian text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_RU",
),
(
"PAYMENT_STARS_TELEGRAM_LABEL_EN",
"string",
"Telegram button text (EN)",
"Custom English text shown in Telegram bot payment buttons.",
"",
"TELEGRAM_LABEL_EN",
),
(
"PAYMENT_STARS_TELEGRAM_EMOJI",
"string",
"Telegram button emoji",
"Emoji prepended to the Telegram bot payment button when customized.",
"🌟",
"TELEGRAM_EMOJI",
),
)
)
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",
# Stars toggles stay on global Settings because stars_subscription_options
# reads them together with STARS_PRICE_* fields.
enabled=lambda settings: bool(getattr(settings, "STARS_ENABLED", False)),
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="⭐",
presentation_class=StarsPresentation,
manifest_fields=_PRESENTATION_MANIFEST,
)