Add validation for provider payment IDs in YooKassa webhook processing and ensure activation details are checked for null values in CryptoPay, FreeKassa, Platega, and Stars services. This prevents potential runtime errors during payment processing and enhances logging for better debugging.
282 lines
12 KiB
Python
282 lines
12 KiB
Python
import logging
|
|
import math
|
|
from typing import Optional
|
|
|
|
from aiogram import Bot, types
|
|
from aiogram.types import LabeledPrice
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from config.settings import Settings
|
|
from db.dal import payment_dal, user_dal
|
|
from .subscription_service import SubscriptionService
|
|
from .referral_service import ReferralService
|
|
from bot.middlewares.i18n import JsonI18n
|
|
from .notification_service import NotificationService
|
|
from bot.keyboards.inline.user_keyboards import get_connect_and_main_keyboard
|
|
from bot.utils.text_sanitizer import sanitize_display_name, username_for_display
|
|
from bot.utils.config_link import prepare_config_links
|
|
|
|
|
|
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
|
|
|
|
def _resolve_base_stars_price(self, months: float, sale_mode: str) -> Optional[int]:
|
|
stars_price_source = (
|
|
self.settings.stars_traffic_packages
|
|
if sale_mode == "traffic"
|
|
else self.settings.stars_subscription_options
|
|
)
|
|
|
|
if sale_mode != "traffic":
|
|
months_key = int(months) if float(months).is_integer() else months
|
|
base_price = stars_price_source.get(months_key)
|
|
if base_price is not None:
|
|
return base_price
|
|
|
|
if float(months).is_integer():
|
|
return stars_price_source.get(float(months_key))
|
|
|
|
return None
|
|
|
|
base_price = stars_price_source.get(months)
|
|
if base_price is not None:
|
|
return base_price
|
|
|
|
for package_size, package_price in stars_price_source.items():
|
|
if math.isclose(float(package_size), float(months), rel_tol=0.0, abs_tol=1e-9):
|
|
return package_price
|
|
|
|
return None
|
|
|
|
async def create_invoice(self, session: AsyncSession, user_id: int, months: float,
|
|
stars_price: int, description: str, sale_mode: str = "subscription",
|
|
promo_code_service=None) -> Optional[int]:
|
|
# Always resolve base price server-side to avoid trusting callback payload.
|
|
resolved_base_price = self._resolve_base_stars_price(months, sale_mode)
|
|
if resolved_base_price is None:
|
|
logging.warning(
|
|
"Stars base price not found for sale_mode=%s months=%s, fallback to callback price.",
|
|
sale_mode,
|
|
months,
|
|
)
|
|
original_stars_price = stars_price
|
|
else:
|
|
original_stars_price = int(resolved_base_price)
|
|
|
|
# Invoice amount starts from the base price and discount is applied once.
|
|
stars_price = original_stars_price
|
|
discount_amount_stars = None
|
|
promo_code_id = None
|
|
|
|
if promo_code_service:
|
|
# Import here to avoid circular import
|
|
from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment
|
|
|
|
# Apply discount and round up using ceiling
|
|
final_price_float, discount_float, promo_code_id = await apply_discount_to_payment(
|
|
session, user_id, float(original_stars_price), promo_code_service
|
|
)
|
|
if discount_float:
|
|
stars_price = math.ceil(final_price_float)
|
|
discount_amount_stars = original_stars_price - stars_price
|
|
logging.info(
|
|
"Stars discount applied: %s -> %.2f -> %s (ceiling)",
|
|
original_stars_price,
|
|
final_price_float,
|
|
stars_price,
|
|
)
|
|
|
|
payment_record_data = {
|
|
"user_id": user_id,
|
|
"amount": float(stars_price),
|
|
"original_amount": float(original_stars_price) if discount_amount_stars else None,
|
|
"discount_applied": float(discount_amount_stars) if discount_amount_stars else None,
|
|
"currency": "XTR",
|
|
"status": "pending_stars",
|
|
"description": description,
|
|
"subscription_duration_months": int(months),
|
|
"provider": "telegram_stars",
|
|
"promo_code_id": promo_code_id,
|
|
}
|
|
try:
|
|
db_payment_record = await payment_dal.create_payment_record(
|
|
session, payment_record_data)
|
|
await session.commit()
|
|
except Exception as e_db:
|
|
await session.rollback()
|
|
logging.error(f"Failed to create stars payment record: {e_db}",
|
|
exc_info=True)
|
|
return None
|
|
|
|
payload = f"{db_payment_record.payment_id}:{months}:{sale_mode}"
|
|
prices = [LabeledPrice(label=description, amount=stars_price)]
|
|
try:
|
|
await self.bot.send_invoice(
|
|
chat_id=user_id,
|
|
title=description,
|
|
description=description,
|
|
payload=payload,
|
|
provider_token=self.settings.STARS_PROVIDER_TOKEN or "",
|
|
currency="XTR",
|
|
prices=prices,
|
|
)
|
|
return db_payment_record.payment_id
|
|
except Exception as e_inv:
|
|
logging.error(f"Failed to send Telegram Stars invoice: {e_inv}",
|
|
exc_info=True)
|
|
return None
|
|
|
|
async def process_successful_payment(self, session: AsyncSession,
|
|
message: types.Message,
|
|
payment_db_id: int,
|
|
months: int,
|
|
stars_amount: int,
|
|
i18n_data: dict,
|
|
sale_mode: str = "subscription") -> None:
|
|
# Fetch payment record to get promo_code_id
|
|
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
|
promo_code_id_from_payment = payment_record.promo_code_id if payment_record else None
|
|
|
|
activation_details = None
|
|
referral_bonus = None
|
|
try:
|
|
provider_payment_id = str(
|
|
message.successful_payment.provider_payment_charge_id
|
|
or f"stars:{payment_db_id}"
|
|
)
|
|
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
|
session,
|
|
payment_db_id,
|
|
provider_payment_id,
|
|
)
|
|
if not marked:
|
|
logging.info(
|
|
"Stars payment %s already processed atomically",
|
|
payment_db_id,
|
|
)
|
|
return
|
|
|
|
activation_details = await self.subscription_service.activate_subscription(
|
|
session,
|
|
message.from_user.id,
|
|
int(months) if sale_mode != "traffic" else 0,
|
|
float(stars_amount),
|
|
payment_db_id,
|
|
promo_code_id_from_payment=promo_code_id_from_payment,
|
|
provider="telegram_stars",
|
|
sale_mode=sale_mode,
|
|
traffic_gb=months if sale_mode == "traffic" else None,
|
|
)
|
|
if not activation_details or not activation_details.get("end_date"):
|
|
raise RuntimeError(
|
|
f"Failed to activate subscription after stars payment {payment_db_id}"
|
|
)
|
|
|
|
if sale_mode != "traffic":
|
|
referral_bonus = await self.referral_service.apply_referral_bonuses_for_payment(
|
|
session,
|
|
message.from_user.id,
|
|
int(months) or 1,
|
|
current_payment_db_id=payment_db_id,
|
|
skip_if_active_before_payment=False,
|
|
)
|
|
await session.commit()
|
|
except Exception as e_upd:
|
|
await session.rollback()
|
|
logging.error(
|
|
f"Failed to process stars payment record {payment_db_id}: {e_upd}",
|
|
exc_info=True)
|
|
return
|
|
|
|
applied_days = referral_bonus.get("referee_bonus_applied_days") if referral_bonus else None
|
|
final_end = referral_bonus.get("referee_new_end_date") if referral_bonus else None
|
|
if not final_end:
|
|
final_end = activation_details["end_date"]
|
|
|
|
# Always use user's language from DB for user-facing messages
|
|
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
|
current_lang = db_user.language_code if db_user and db_user.language_code else self.settings.DEFAULT_LANGUAGE
|
|
i18n: JsonI18n = i18n_data.get("i18n_instance")
|
|
_ = lambda k, **kw: i18n.gettext(current_lang, k, **kw) if i18n else k
|
|
|
|
raw_config_link = activation_details.get("subscription_url") if activation_details else None
|
|
config_link_display, connect_button_url = await prepare_config_links(self.settings, raw_config_link)
|
|
config_link_text = config_link_display or _("config_link_not_available")
|
|
|
|
if sale_mode == "traffic":
|
|
success_msg = _(
|
|
"payment_successful_traffic_full",
|
|
traffic_gb=str(int(months)) if float(months).is_integer() else f"{months:g}",
|
|
end_date=final_end.strftime('%Y-%m-%d'),
|
|
config_link=config_link_text,
|
|
)
|
|
elif applied_days:
|
|
inviter_name_display = _("friend_placeholder")
|
|
db_user = await user_dal.get_user_by_id(session, message.from_user.id)
|
|
if db_user and db_user.referred_by_id:
|
|
inviter = await user_dal.get_user_by_id(session, db_user.referred_by_id)
|
|
if inviter:
|
|
safe_name = sanitize_display_name(inviter.first_name) if inviter.first_name else None
|
|
if safe_name:
|
|
inviter_name_display = safe_name
|
|
elif inviter.username:
|
|
inviter_name_display = username_for_display(inviter.username, with_at=False)
|
|
success_msg = _(
|
|
"payment_successful_with_referral_bonus_full",
|
|
months=months,
|
|
base_end_date=activation_details["end_date"].strftime('%Y-%m-%d'),
|
|
bonus_days=applied_days,
|
|
final_end_date=final_end.strftime('%Y-%m-%d'),
|
|
inviter_name=inviter_name_display,
|
|
config_link=config_link_text,
|
|
)
|
|
else:
|
|
success_msg = _(
|
|
"payment_successful_full",
|
|
months=months,
|
|
end_date=final_end.strftime('%Y-%m-%d'),
|
|
config_link=config_link_text,
|
|
)
|
|
markup = get_connect_and_main_keyboard(
|
|
current_lang,
|
|
i18n,
|
|
self.settings,
|
|
config_link_display,
|
|
connect_button_url=connect_button_url,
|
|
preserve_message=True,
|
|
)
|
|
try:
|
|
await self.bot.send_message(
|
|
message.from_user.id,
|
|
success_msg,
|
|
reply_markup=markup,
|
|
parse_mode="HTML",
|
|
disable_web_page_preview=True,
|
|
)
|
|
except Exception as e_send:
|
|
logging.error(
|
|
f"Failed to send stars payment success message: {e_send}")
|
|
|
|
# Send notification about payment
|
|
try:
|
|
notification_service = NotificationService(self.bot, self.settings, self.i18n)
|
|
user = await user_dal.get_user_by_id(session, message.from_user.id)
|
|
await notification_service.notify_payment_received(
|
|
user_id=message.from_user.id,
|
|
amount=float(stars_amount),
|
|
currency="XTR",
|
|
months=int(months) if sale_mode != "traffic" else 0,
|
|
payment_provider="stars",
|
|
username=user.username if user else None,
|
|
traffic_gb=months if sale_mode == "traffic" else None,
|
|
)
|
|
except Exception as e:
|
|
logging.error(f"Failed to send stars payment notification: {e}")
|