Files
remnawave-minishop/bot/services/stars_service.py
T
kavore f1620a784b refactor(promo): streamline discount consumption logic
Simplify the discount consumption process in the PromoCodeService by removing unnecessary checks for active discounts and improving logging for various scenarios. Ensure that successful payments are always accounted for, even if the associated reservation has expired. This enhances clarity and reliability in the discount application workflow.
2026-02-12 15:47:52 +03:00

293 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 and reject unknown packages.
resolved_base_price = self._resolve_base_stars_price(months, sale_mode)
if resolved_base_price is None:
logging.warning(
"Stars invoice rejected: base price not found for sale_mode=%s months=%s.",
sale_mode,
months,
)
return None
original_stars_price = int(resolved_base_price)
# Detect callback tampering (or stale callback payload) and prefer server-side price.
if int(stars_price) != original_stars_price:
logging.warning(
"Stars callback price mismatch for user %s: callback=%s, resolved=%s, sale_mode=%s, months=%s",
user_id,
stars_price,
original_stars_price,
sale_mode,
months,
)
# 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}")