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, hwid_device_count=ctx.hwid_device_count, ) 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, supported_currencies=("XTR",), currency_support_note="Telegram Stars use Telegram's XTR currency and separate Stars prices.", )