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.
293 lines
12 KiB
Python
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}")
|