diff --git a/app/bot.py b/app/bot.py
index c1df99e8..14d961e5 100644
--- a/app/bot.py
+++ b/app/bot.py
@@ -60,6 +60,7 @@ from app.handlers.admin import (
welcome_text as admin_welcome_text,
)
from app.handlers.channel_member import register_handlers as register_channel_member_handlers
+from app.handlers.gift_activation import register_handlers as register_gift_activation_handlers
from app.handlers.stars_payments import register_stars_handlers
from app.middlewares.auth import AuthMiddleware
from app.middlewares.blacklist import BlacklistMiddleware
@@ -199,6 +200,7 @@ async def setup_bot() -> tuple[Bot, Dispatcher]:
admin_blocked_users.register_handlers(dp)
admin_required_channels.register_handlers(dp)
register_channel_member_handlers(dp)
+ register_gift_activation_handlers(dp)
common.register_handlers(dp)
register_stars_handlers(dp)
user_contests.register_handlers(dp)
diff --git a/app/cabinet/routes/auth.py b/app/cabinet/routes/auth.py
index fc40830d..8c8c4e53 100644
--- a/app/cabinet/routes/auth.py
+++ b/app/cabinet/routes/auth.py
@@ -188,7 +188,12 @@ async def _process_campaign_bonus(
user.referred_by_id = campaign.partner_user_id
await db.flush()
try:
- await process_referral_registration(db, user.id, campaign.partner_user_id, bot=None)
+ from aiogram import Bot
+ from aiogram.client.default import DefaultBotProperties
+ from aiogram.enums import ParseMode
+
+ bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
+ await process_referral_registration(db, user.id, campaign.partner_user_id, bot=bot)
logger.info(
'Referral set from campaign partner',
user_id=user.id,
@@ -242,7 +247,12 @@ async def _process_referral_code(
return
user.referred_by_id = referrer.id
await db.flush()
- await process_referral_registration(db, user.id, referrer.id, bot=None)
+ from aiogram import Bot
+ from aiogram.client.default import DefaultBotProperties
+ from aiogram.enums import ParseMode
+
+ bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
+ await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info('Referral applied from code', user_id=user.id, referrer_id=referrer.id, referral_code=referral_code)
except Exception as e:
logger.error('Failed to process referral code', error=e, referral_code=referral_code)
@@ -917,7 +927,12 @@ async def register_email_standalone(
# Обработать реферальную регистрацию (если есть реферер)
if referrer:
try:
- await process_referral_registration(db, user.id, referrer.id, bot=None)
+ from aiogram import Bot
+ from aiogram.client.default import DefaultBotProperties
+ from aiogram.enums import ParseMode
+
+ bot = Bot(token=settings.BOT_TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.HTML))
+ await process_referral_registration(db, user.id, referrer.id, bot=bot)
logger.info(
'Processed referral registration: user_id=, referrer_id', user_id=user.id, referrer_id=referrer.id
)
diff --git a/app/cabinet/routes/landing.py b/app/cabinet/routes/landing.py
index 71bb5201..85316129 100644
--- a/app/cabinet/routes/landing.py
+++ b/app/cabinet/routes/landing.py
@@ -102,7 +102,7 @@ class LandingConfigResponse(BaseModel):
_EMAIL_RE = re.compile(r'^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$')
-_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{3,31}$')
+_TELEGRAM_RE = re.compile(r'^@?[a-zA-Z][a-zA-Z0-9_]{4,31}$')
def _validate_contact(contact_type: str, contact_value: str) -> None:
@@ -153,6 +153,8 @@ class PurchaseStatusResponse(BaseModel):
cabinet_email: str | None = None
cabinet_password: str | None = None
auto_login_token: str | None = None
+ recipient_in_bot: bool | None = None
+ bot_link: str | None = None
# ============ Helpers ============
@@ -180,7 +182,7 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe
within_ttl = False
subscription_url = None
subscription_crypto_link = None
- if purchase.delivered_at and purchase.subscription_url:
+ if purchase.delivered_at and purchase.subscription_url and not purchase.is_gift:
age = datetime.now(UTC) - purchase.delivered_at
if age < timedelta(hours=_SUBSCRIPTION_URL_EXPIRY_HOURS):
within_ttl = True
@@ -221,6 +223,16 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe
cabinet_password = purchase.cabinet_password
auto_login_token = purchase.auto_login_token
+ # For telegram gifts: indicate whether recipient is known to the bot
+ recipient_in_bot: bool | None = None
+ bot_link: str | None = None
+ if purchase.is_gift and effective_contact_type == 'telegram':
+ recipient_in_bot = purchase.user is not None and purchase.user.telegram_id is not None
+ if not recipient_in_bot:
+ bot_username = settings.get_bot_username()
+ if bot_username:
+ bot_link = f'https://t.me/{bot_username}'
+
return PurchaseStatusResponse(
status=purchase.status,
subscription_url=subscription_url,
@@ -235,6 +247,8 @@ def _build_purchase_status_response(purchase: GuestPurchase) -> PurchaseStatusRe
cabinet_email=cabinet_email,
cabinet_password=cabinet_password,
auto_login_token=auto_login_token,
+ recipient_in_bot=recipient_in_bot,
+ bot_link=bot_link,
)
diff --git a/app/cabinet/routes/subscription.py b/app/cabinet/routes/subscription.py
index 83e326a5..b3d16228 100644
--- a/app/cabinet/routes/subscription.py
+++ b/app/cabinet/routes/subscription.py
@@ -510,12 +510,16 @@ async def renew_subscription(
'source': 'cabinet',
}
- # Add tariff parameters for tariffs mode
+ # Add subscription parameters for auto-purchase
if tariff_id:
cart_data['traffic_limit_gb'] = tariff_traffic_limit_gb
# Сохраняем актуальный device_limit подписки (включая докупленные устройства)
cart_data['device_limit'] = user.subscription.device_limit
cart_data['allowed_squads'] = tariff_allowed_squads
+ else:
+ # Classic mode: сохраняем текущие параметры подписки для корректной автопокупки
+ cart_data['device_limit'] = user.subscription.device_limit
+ cart_data['traffic_limit_gb'] = user.subscription.traffic_limit_gb
try:
await user_cart_service.save_user_cart(user.id, cart_data)
diff --git a/app/config.py b/app/config.py
index 8ee4281a..dfaff11a 100644
--- a/app/config.py
+++ b/app/config.py
@@ -1738,7 +1738,7 @@ class Settings(BaseSettings):
return info.get('title') or info.get('name') or f'Platega {method_code}'
def is_wata_enabled(self) -> bool:
- return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None and self.WATA_TERMINAL_PUBLIC_ID is not None
+ return self.WATA_ENABLED and self.WATA_ACCESS_TOKEN is not None
def get_wata_display_name(self) -> str:
name = (self.WATA_DISPLAY_NAME or '').strip()
diff --git a/app/database/crud/user.py b/app/database/crud/user.py
index 9c8fd0fb..4f4be174 100644
--- a/app/database/crud/user.py
+++ b/app/database/crud/user.py
@@ -122,6 +122,30 @@ async def get_user_by_telegram_id(db: AsyncSession, telegram_id: int) -> User |
return user
+async def find_phantom_user_by_username(db: AsyncSession, username: str) -> User | None:
+ """Find a phantom user created by guest purchase (no telegram_id, auth_type=telegram).
+
+ Used during /start to reconcile phantom users with real Telegram accounts.
+ """
+ if not username:
+ return None
+
+ normalized = username.lower()
+ result = await db.execute(
+ select(User)
+ .options(
+ selectinload(User.subscription).selectinload(Subscription.tariff),
+ )
+ .where(
+ User.telegram_id.is_(None),
+ User.auth_type == 'telegram',
+ func.lower(User.username) == normalized,
+ )
+ .with_for_update()
+ )
+ return result.scalars().first()
+
+
async def get_user_by_username(db: AsyncSession, username: str) -> User | None:
if not username:
return None
diff --git a/app/handlers/admin/users.py b/app/handlers/admin/users.py
index 1ab69c26..fe4aa812 100644
--- a/app/handlers/admin/users.py
+++ b/app/handlers/admin/users.py
@@ -4023,7 +4023,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
from app.database.crud.subscription import (
deactivate_subscription,
get_subscription_by_user_id,
- is_active_paid_subscription,
)
from app.services.subscription_service import SubscriptionService
@@ -4032,13 +4031,6 @@ async def _deactivate_user_subscription(db: AsyncSession, user_id: int, admin_id
logger.error('Подписка не найдена для пользователя', user_id=user_id)
return False
- if is_active_paid_subscription(subscription):
- logger.info(
- '⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
- user_id=user_id,
- )
- return False
-
await deactivate_subscription(db, subscription)
user = await get_user_by_id(db, user_id)
diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py
index 606914dd..9c641d26 100644
--- a/app/handlers/balance/main.py
+++ b/app/handlers/balance/main.py
@@ -145,6 +145,8 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
"""
Generate quick amount buttons with user-specific pricing and discounts.
+ Includes full subscription cost: base period price + devices + servers + traffic.
+
Args:
language: User's language for formatting
user: User object to calculate personalized discounts
@@ -156,25 +158,63 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
return []
from app.config import PERIOD_PRICES
- from app.localization.texts import get_texts
+ from app.database.crud.subscription import get_subscription_by_user_id
+ from app.database.database import AsyncSessionLocal
+ from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
texts = get_texts(language)
- # В режиме тарифов получаем цены из тарифа пользователя
+ tariff = None
tariff_prices = None
tariff_periods = None
- if settings.is_tariffs_mode():
- from app.database.crud.subscription import get_subscription_by_user_id
- from app.database.crud.tariff import get_tariff_by_id
- from app.database.database import AsyncSessionLocal
+ devices_price_per_month = 0
+ servers_per_month_prices: list[int] = []
+ traffic_price_per_month = 0
- async with AsyncSessionLocal() as db:
- subscription = await get_subscription_by_user_id(db, user.id)
- if subscription and subscription.tariff_id:
- tariff = await get_tariff_by_id(db, subscription.tariff_id)
- if tariff and tariff.period_prices:
- tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
- tariff_periods = sorted(tariff_prices.keys())
+ async with AsyncSessionLocal() as db:
+ subscription = await get_subscription_by_user_id(db, user.id)
+
+ # В режиме тарифов получаем цены из тарифа пользователя
+ if settings.is_tariffs_mode() and subscription and subscription.tariff_id:
+ from app.database.crud.tariff import get_tariff_by_id
+
+ tariff = await get_tariff_by_id(db, subscription.tariff_id)
+ if tariff and tariff.period_prices:
+ tariff_prices = {int(k): v for k, v in tariff.period_prices.items()}
+ tariff_periods = sorted(tariff_prices.keys())
+
+ # Получаем стоимость устройств, серверов и трафика из подписки
+ if subscription and not subscription.is_trial:
+ # Устройства: в режиме тарифов используем цену и базовый лимит из тарифа
+ if settings.is_tariffs_mode() and tariff and tariff_prices:
+ tariff_device_price = getattr(tariff, 'device_price_kopeks', None)
+ if tariff_device_price and tariff_device_price > 0:
+ device_unit_price = tariff_device_price
+ base_device_limit = tariff.device_limit or 0
+ else:
+ device_unit_price = settings.PRICE_PER_DEVICE
+ base_device_limit = settings.DEFAULT_DEVICE_LIMIT
+ else:
+ device_unit_price = settings.PRICE_PER_DEVICE
+ base_device_limit = settings.DEFAULT_DEVICE_LIMIT
+
+ device_limit = subscription.device_limit or base_device_limit
+ additional_devices = max(0, device_limit - base_device_limit)
+ if additional_devices > 0:
+ devices_price_per_month = additional_devices * device_unit_price
+
+ # Серверы
+ connected_squads = subscription.connected_squads or []
+ if connected_squads:
+ from app.services.subscription_service import SubscriptionService
+
+ subscription_service = SubscriptionService()
+ _, servers_per_month_prices = await subscription_service.get_countries_price_by_uuids(
+ connected_squads, db, promo_group_id=user.promo_group_id
+ )
+
+ # Трафик
+ traffic_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
buttons = []
@@ -192,23 +232,58 @@ async def get_quick_amount_buttons(language: str, user: User) -> list:
base_price_kopeks = PERIOD_PRICES.get(period, 0)
if base_price_kopeks > 0:
- # Calculate price with user's promo group discount using unified system
+ # Базовая цена периода с промо-скидками
price_info = calculate_user_price(user, base_price_kopeks, period, 'period')
- callback_data = f'quick_amount_{price_info.final_price}'
+ months = calculate_months_from_days(period)
+
+ # Стоимость устройств со скидкой
+ devices_addon = 0
+ if devices_price_per_month > 0:
+ devices_discount = user.get_promo_discount('devices', period)
+ devices_discounted, _ = apply_percentage_discount(devices_price_per_month, devices_discount)
+ devices_addon = devices_discounted * months
+
+ # Стоимость серверов со скидкой
+ servers_addon = 0
+ if servers_per_month_prices:
+ servers_discount = user.get_promo_discount('servers', period)
+ for server_price in servers_per_month_prices:
+ discounted, _ = apply_percentage_discount(server_price, servers_discount)
+ servers_addon += discounted
+ servers_addon *= months
+
+ # Стоимость трафика со скидкой
+ traffic_addon = 0
+ if traffic_price_per_month > 0:
+ traffic_discount = user.get_promo_discount('traffic', period)
+ traffic_discounted, _ = apply_percentage_discount(traffic_price_per_month, traffic_discount)
+ traffic_addon = traffic_discounted * months
+
+ total_price = price_info.final_price + devices_addon + servers_addon + traffic_addon
+ callback_data = f'quick_amount_{total_price}'
- # Format button text with discount display
period_label = f'{period} дней'
- # For balance buttons, use simpler format without emoji and period label prefix
- if price_info.has_discount:
- button_text = (
- f'{texts.format_price(price_info.base_price)} ➜ '
- f'{texts.format_price(price_info.final_price)} '
- f'(-{price_info.discount_percent}%) • {period_label}'
- )
+ # Скидка считается от полной базовой стоимости (период + аддоны без скидок)
+ total_base = (
+ base_price_kopeks
+ + (devices_price_per_month + sum(servers_per_month_prices) + traffic_price_per_month) * months
+ )
+ has_discount = total_base > total_price and total_base > 0
+
+ if has_discount:
+ discount_pct = round((total_base - total_price) * 100 / total_base)
+ if discount_pct > 0:
+ button_text = (
+ f'{texts.format_price(total_base)} ➜ '
+ f'{texts.format_price(total_price)} '
+ f'(-{discount_pct}%) • {period_label}'
+ )
+ else:
+ button_text = f'{texts.format_price(total_price)} • {period_label}'
else:
- button_text = f'{texts.format_price(price_info.final_price)} • {period_label}'
+ button_text = f'{texts.format_price(total_price)} • {period_label}'
buttons.append(types.InlineKeyboardButton(text=button_text, callback_data=callback_data))
diff --git a/app/handlers/gift_activation.py b/app/handlers/gift_activation.py
new file mode 100644
index 00000000..0216a510
--- /dev/null
+++ b/app/handlers/gift_activation.py
@@ -0,0 +1,95 @@
+"""Handler for gift subscription activation via inline callback button."""
+
+import html as html_mod
+
+import structlog
+from aiogram import Dispatcher, F, types
+from aiogram.types import InaccessibleMessage
+from sqlalchemy import select
+
+from app.database.database import AsyncSessionLocal
+from app.database.models import GuestPurchase
+from app.services.guest_purchase_service import GuestPurchaseError, activate_purchase
+
+
+logger = structlog.get_logger(__name__)
+
+_GIFT_NOT_FOUND = 'Подарок не найден или недоступен.'
+
+
+async def handle_gift_activate(callback: types.CallbackQuery) -> None:
+ """Handle gift_activate:{purchase_id} callback from Telegram notification."""
+ if isinstance(callback.message, InaccessibleMessage):
+ await callback.answer('Сообщение устарело. Попробуйте /start.', show_alert=True)
+ return
+
+ if not callback.data:
+ return
+
+ parts = callback.data.split(':', 1)
+ if len(parts) != 2:
+ await callback.answer(_GIFT_NOT_FOUND, show_alert=True)
+ return
+
+ try:
+ purchase_id = int(parts[1])
+ except ValueError:
+ await callback.answer(_GIFT_NOT_FOUND, show_alert=True)
+ return
+
+ await callback.answer()
+ await callback.message.edit_text('⏳ Активируем подарок...', parse_mode=None)
+
+ async with AsyncSessionLocal() as db:
+ result = await db.execute(select(GuestPurchase).where(GuestPurchase.id == purchase_id))
+ purchase = result.scalars().first()
+
+ if not purchase or purchase.user_id is None or purchase.user is None:
+ await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None)
+ return
+
+ # Verify the callback sender is the actual recipient
+ if purchase.user.telegram_id != callback.from_user.id:
+ await callback.message.edit_text(_GIFT_NOT_FOUND, parse_mode=None)
+ return
+
+ # Resolve tariff info inside session (selectin-loaded relationships)
+ tariff_name = html_mod.escape(purchase.tariff.name) if purchase.tariff and purchase.tariff.name else ''
+ period_days = purchase.period_days
+
+ try:
+ await activate_purchase(db, purchase.token, skip_notification=True)
+ except GuestPurchaseError as exc:
+ logger.warning(
+ 'Gift activation via callback failed',
+ purchase_id=purchase_id,
+ telegram_id=callback.from_user.id,
+ error=exc.message,
+ )
+ if exc.status_code >= 500:
+ await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None)
+ else:
+ await callback.message.edit_text(
+ f'Не удалось активировать подарок: {html_mod.escape(exc.message)}',
+ parse_mode=None,
+ )
+ return
+ except Exception:
+ logger.exception(
+ 'Unexpected error during gift activation via callback',
+ purchase_id=purchase_id,
+ telegram_id=callback.from_user.id,
+ )
+ await callback.message.edit_text('Произошла ошибка при активации. Попробуйте позже.', parse_mode=None)
+ return
+
+ period_text = f'{period_days} дн.' if period_days else ''
+ tariff_text = f'{tariff_name} — {period_text}' if tariff_name else period_text
+
+ await callback.message.edit_text(
+ f'✅ Подарок активирован!\n{tariff_text}\n\nВаша подписка обновлена.',
+ )
+
+
+def register_handlers(dp: Dispatcher) -> None:
+ dp.callback_query.register(handle_gift_activate, F.data.startswith('gift_activate:'))
diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py
index 3055a4f2..ce2d4621 100644
--- a/app/handlers/promocode.py
+++ b/app/handlers/promocode.py
@@ -1,5 +1,6 @@
import structlog
from aiogram import Bot, Dispatcher, F, types
+from aiogram.exceptions import TelegramBadRequest
from aiogram.fsm.context import FSMContext
from aiogram.types import InaccessibleMessage
from sqlalchemy.ext.asyncio import AsyncSession
@@ -24,7 +25,14 @@ async def show_promocode_menu(callback: types.CallbackQuery, db_user: User, stat
if isinstance(callback.message, InaccessibleMessage):
await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
else:
- await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
+ try:
+ await callback.message.edit_text(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
+ except TelegramBadRequest as error:
+ error_message = str(error).lower()
+ if 'there is no text in the message to edit' in error_message:
+ await callback.message.answer(texts.PROMOCODE_ENTER, reply_markup=get_back_keyboard(db_user.language))
+ else:
+ raise
await state.set_state(PromoCodeStates.waiting_for_code)
await callback.answer()
diff --git a/app/handlers/start.py b/app/handlers/start.py
index fb00bef1..6576e7f1 100644
--- a/app/handlers/start.py
+++ b/app/handlers/start.py
@@ -5,6 +5,7 @@ from aiogram import Bot, Dispatcher, F, types
from aiogram.exceptions import TelegramBadRequest, TelegramForbiddenError
from aiogram.filters import Command, StateFilter
from aiogram.fsm.context import FSMContext
+from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
@@ -15,6 +16,7 @@ from app.database.crud.campaign import (
from app.database.crud.subscription import decrement_subscription_server_counts
from app.database.crud.user import (
create_user,
+ find_phantom_user_by_username,
get_user_by_referral_code,
get_user_by_telegram_id,
)
@@ -58,6 +60,73 @@ from app.utils.user_utils import generate_unique_referral_code
logger = structlog.get_logger(__name__)
+async def _claim_phantom_user(
+ db: AsyncSession,
+ phantom: 'User',
+ *,
+ telegram_id: int,
+ username: str | None,
+ first_name: str | None,
+ last_name: str | None,
+ language: str,
+ referrer_id: int | None,
+) -> tuple[bool, 'User | None']:
+ """Claim a phantom user by backfilling Telegram profile data.
+
+ Returns (success, user). On IntegrityError falls back to existing user lookup.
+
+ Note: Phantom users created when Bot.get_chat() fails at purchase time are matched
+ by username only. Since Telegram usernames are changeable and reassignable, this is
+ inherently vulnerable to username change attacks. When Bot.get_chat() succeeds at
+ purchase time, telegram_id is stored on the user and the phantom path is not used.
+ """
+ from app.utils.validators import sanitize_telegram_name
+
+ phantom.telegram_id = telegram_id
+ phantom.username = username
+ phantom.first_name = sanitize_telegram_name(first_name)
+ phantom.last_name = sanitize_telegram_name(last_name)
+ phantom.language = language
+ phantom.status = UserStatus.ACTIVE.value
+ if referrer_id and referrer_id != phantom.id:
+ phantom.referred_by_id = referrer_id
+ if not phantom.referral_code:
+ phantom.referral_code = await generate_unique_referral_code(db, telegram_id)
+ phantom.updated_at = datetime.now(UTC)
+ phantom.last_activity = datetime.now(UTC)
+ try:
+ await db.commit()
+ except IntegrityError:
+ await db.rollback()
+ logger.warning(
+ 'IntegrityError claiming phantom user, falling back to existing user lookup',
+ phantom_user_id=phantom.id,
+ telegram_id=telegram_id,
+ )
+ existing = await get_user_by_telegram_id(db, telegram_id)
+ return False, existing
+ await db.refresh(phantom, ['subscription'])
+ logger.info(
+ 'Claimed phantom user from guest purchase',
+ phantom_user_id=phantom.id,
+ telegram_id=telegram_id,
+ )
+
+ # Sync Remnawave panel with updated user data (telegram_id, username, etc.)
+ if phantom.subscription:
+ try:
+ subscription_service = SubscriptionService()
+ await subscription_service.update_remnawave_user(db, phantom.subscription)
+ except Exception as exc:
+ logger.warning(
+ 'Failed to update Remnawave panel after phantom claim',
+ phantom_user_id=phantom.id,
+ error=str(exc),
+ )
+
+ return True, phantom
+
+
def _calculate_subscription_flags(subscription):
if not subscription:
return False, False
@@ -1136,6 +1205,7 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
subscription_is_active=subscription_is_active,
)
+ pinned_message = await get_active_pinned_message(db)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
@@ -1150,8 +1220,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
- await _send_pinned_message(callback.bot, db, existing_user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(callback.bot, db, existing_user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
await callback.message.answer(
@@ -1199,21 +1272,50 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
logger.info('✅ Пользователь восстановлен', from_user_id=callback.from_user.id)
elif not existing_user:
- logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id)
-
- referral_code = await generate_unique_referral_code(db, callback.from_user.id)
-
- user = await create_user(
- db=db,
- telegram_id=callback.from_user.id,
- username=callback.from_user.username,
- first_name=callback.from_user.first_name,
- last_name=callback.from_user.last_name,
- language=language,
- referred_by_id=referrer_id,
- referral_code=referral_code,
+ # Check for phantom user created by guest purchase (gift by @username)
+ phantom = (
+ await find_phantom_user_by_username(db, callback.from_user.username)
+ if callback.from_user.username
+ else None
)
- await db.refresh(user, ['subscription'])
+ if phantom:
+ claimed, user = await _claim_phantom_user(
+ db,
+ phantom,
+ telegram_id=callback.from_user.id,
+ username=callback.from_user.username,
+ first_name=callback.from_user.first_name,
+ last_name=callback.from_user.last_name,
+ language=language,
+ referrer_id=referrer_id,
+ )
+ if not claimed and user:
+ # IntegrityError fallback — use existing user
+ await db.refresh(user, ['subscription'])
+ elif not claimed:
+ logger.critical(
+ 'Phantom claim failed with no fallback user, proceeding to normal registration',
+ telegram_id=callback.from_user.id,
+ phantom_user_id=phantom.id,
+ )
+ phantom = None
+
+ if not phantom:
+ logger.info('🆕 Создаем нового пользователя', from_user_id=callback.from_user.id)
+
+ referral_code = await generate_unique_referral_code(db, callback.from_user.id)
+
+ user = await create_user(
+ db=db,
+ telegram_id=callback.from_user.id,
+ username=callback.from_user.username,
+ first_name=callback.from_user.first_name,
+ last_name=callback.from_user.last_name,
+ language=language,
+ referred_by_id=referrer_id,
+ referral_code=referral_code,
+ )
+ await db.refresh(user, ['subscription'])
else:
logger.info('🔄 Обновляем существующего пользователя', from_user_id=callback.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
@@ -1273,15 +1375,19 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, callback.from_user)
+ pinned_message = await get_active_pinned_message(db)
if offer_text:
try:
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(callback.bot, db, user, pinned_message)
await callback.message.answer(
offer_text,
reply_markup=get_post_registration_keyboard(user.language),
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
- await _send_pinned_message(callback.bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(callback.bot, db, user, pinned_message)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
@@ -1291,7 +1397,8 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
reply_markup=get_post_registration_keyboard(user.language),
parse_mode=None,
)
- await _send_pinned_message(callback.bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(callback.bot, db, user, pinned_message)
except Exception as fallback_err:
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
else:
@@ -1336,8 +1443,11 @@ async def complete_registration_from_callback(callback: types.CallbackQuery, sta
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(callback.bot, db, user, pinned_message)
await callback.message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
- await _send_pinned_message(callback.bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(callback.bot, db, user, pinned_message)
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
except Exception as e:
logger.error('Ошибка при показе главного меню', error=e)
@@ -1387,6 +1497,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
subscription_is_active=subscription_is_active,
)
+ pinned_message = await get_active_pinned_message(db)
try:
keyboard = await get_main_menu_keyboard_async(
db=db,
@@ -1401,8 +1512,11 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(message.bot, db, existing_user, pinned_message)
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
- await _send_pinned_message(message.bot, db, existing_user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(message.bot, db, existing_user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню существующему пользователю', error=e)
await message.answer(
@@ -1450,21 +1564,47 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
logger.info('✅ Пользователь восстановлен', from_user_id=message.from_user.id)
elif not existing_user:
- logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id)
-
- referral_code = await generate_unique_referral_code(db, message.from_user.id)
-
- user = await create_user(
- db=db,
- telegram_id=message.from_user.id,
- username=message.from_user.username,
- first_name=message.from_user.first_name,
- last_name=message.from_user.last_name,
- language=language,
- referred_by_id=referrer_id,
- referral_code=referral_code,
+ # Check for phantom user created by guest purchase (gift by @username)
+ phantom = (
+ await find_phantom_user_by_username(db, message.from_user.username) if message.from_user.username else None
)
- await db.refresh(user, ['subscription'])
+ if phantom:
+ claimed, user = await _claim_phantom_user(
+ db,
+ phantom,
+ telegram_id=message.from_user.id,
+ username=message.from_user.username,
+ first_name=message.from_user.first_name,
+ last_name=message.from_user.last_name,
+ language=language,
+ referrer_id=referrer_id,
+ )
+ if not claimed and user:
+ await db.refresh(user, ['subscription'])
+ elif not claimed:
+ logger.critical(
+ 'Phantom claim failed with no fallback user, proceeding to normal registration',
+ telegram_id=message.from_user.id,
+ phantom_user_id=phantom.id,
+ )
+ phantom = None
+
+ if not phantom:
+ logger.info('🆕 Создаем нового пользователя', from_user_id=message.from_user.id)
+
+ referral_code = await generate_unique_referral_code(db, message.from_user.id)
+
+ user = await create_user(
+ db=db,
+ telegram_id=message.from_user.id,
+ username=message.from_user.username,
+ first_name=message.from_user.first_name,
+ last_name=message.from_user.last_name,
+ language=language,
+ referred_by_id=referrer_id,
+ referral_code=referral_code,
+ )
+ await db.refresh(user, ['subscription'])
else:
logger.info('🔄 Обновляем существующего пользователя', from_user_id=message.from_user.id)
existing_user.status = UserStatus.ACTIVE.value
@@ -1553,6 +1693,7 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
from app.database.crud.welcome_text import get_welcome_text_for_user
offer_text = await get_welcome_text_for_user(db, message.from_user)
+ pinned_message = await get_active_pinned_message(db)
if offer_text:
try:
@@ -1563,12 +1704,15 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
else:
keyboard = get_post_registration_keyboard(user.language)
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(message.bot, db, user, pinned_message)
await message.answer(
offer_text,
reply_markup=keyboard,
)
logger.info('✅ Приветственное сообщение отправлено пользователю', telegram_id=user.telegram_id)
- await _send_pinned_message(message.bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(message.bot, db, user, pinned_message)
except TelegramBadRequest as e:
if 'parse entities' in str(e).lower() or "can't parse" in str(e).lower():
logger.warning('HTML parse error в приветственном сообщении, повтор без parse_mode', error=e)
@@ -1578,7 +1722,8 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
reply_markup=keyboard,
parse_mode=None,
)
- await _send_pinned_message(message.bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(message.bot, db, user, pinned_message)
except Exception as fallback_err:
logger.error('Ошибка при повторной отправке приветственного сообщения', fallback_err=fallback_err)
else:
@@ -1623,9 +1768,12 @@ async def complete_registration(message: types.Message, state: FSMContext, db: A
is_moderator=is_moderator,
custom_buttons=custom_buttons,
)
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(message.bot, db, user, pinned_message)
await message.answer(menu_text, reply_markup=keyboard, parse_mode='HTML')
logger.info('✅ Главное меню показано пользователю', telegram_id=user.telegram_id)
- await _send_pinned_message(message.bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(message.bot, db, user, pinned_message)
except Exception as e:
logger.error('Ошибка при показе главного меню', error=e)
await message.answer(
@@ -1977,6 +2125,10 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
+ pinned_message = await get_active_pinned_message(db)
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(bot, db, user, pinned_message)
+
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
@@ -1993,7 +2145,8 @@ async def required_sub_channel_check(
reply_markup=keyboard,
parse_mode='HTML',
)
- await _send_pinned_message(bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(bot, db, user, pinned_message)
else:
from app.keyboards.inline import get_rules_keyboard
@@ -2014,19 +2167,47 @@ async def required_sub_channel_check(
referrer_id = referrer.id
logger.info('✅ CHANNEL CHECK: Реферер найден из ссылки', referrer_id=referrer.id)
- referral_code = await generate_unique_referral_code(db, query.from_user.id)
-
- user = await create_user(
- db=db,
- telegram_id=query.from_user.id,
- username=query.from_user.username,
- first_name=query.from_user.first_name,
- last_name=query.from_user.last_name,
- language=language,
- referral_code=referral_code,
- referred_by_id=referrer_id,
+ # Check for phantom user created by guest purchase (gift by @username)
+ phantom = (
+ await find_phantom_user_by_username(db, query.from_user.username)
+ if query.from_user.username
+ else None
)
- await db.refresh(user, ['subscription'])
+ if phantom:
+ claimed, user = await _claim_phantom_user(
+ db,
+ phantom,
+ telegram_id=query.from_user.id,
+ username=query.from_user.username,
+ first_name=query.from_user.first_name,
+ last_name=query.from_user.last_name,
+ language=language,
+ referrer_id=referrer_id,
+ )
+ if not claimed and user:
+ await db.refresh(user, ['subscription'])
+ elif not claimed:
+ logger.critical(
+ 'Phantom claim failed with no fallback user, proceeding to normal registration',
+ telegram_id=query.from_user.id,
+ phantom_user_id=phantom.id,
+ )
+ phantom = None
+
+ if not phantom:
+ referral_code = await generate_unique_referral_code(db, query.from_user.id)
+
+ user = await create_user(
+ db=db,
+ telegram_id=query.from_user.id,
+ username=query.from_user.username,
+ first_name=query.from_user.first_name,
+ last_name=query.from_user.last_name,
+ language=language,
+ referral_code=referral_code,
+ referred_by_id=referrer_id,
+ )
+ await db.refresh(user, ['subscription'])
# ИСПРАВЛЕНИЕ БАГА: Очищаем pending_start_payload из state после создания пользователя
state_data.pop('pending_start_payload', None)
@@ -2070,6 +2251,10 @@ async def required_sub_channel_check(
custom_buttons=custom_buttons,
)
+ pinned_message = await get_active_pinned_message(db)
+ if pinned_message and pinned_message.send_before_menu:
+ await _send_pinned_message(bot, db, user, pinned_message)
+
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
_result = await bot.send_photo(
chat_id=query.from_user.id,
@@ -2086,7 +2271,8 @@ async def required_sub_channel_check(
reply_markup=keyboard,
parse_mode='HTML',
)
- await _send_pinned_message(bot, db, user)
+ if pinned_message and not pinned_message.send_before_menu:
+ await _send_pinned_message(bot, db, user, pinned_message)
else:
await bot.send_message(
chat_id=query.from_user.id,
diff --git a/app/handlers/subscription/devices.py b/app/handlers/subscription/devices.py
index 9ac70761..2732409b 100644
--- a/app/handlers/subscription/devices.py
+++ b/app/handlers/subscription/devices.py
@@ -27,7 +27,6 @@ from app.services.user_cart_service import user_cart_service
from app.utils.pagination import paginate_list
from app.utils.pricing_utils import (
apply_percentage_discount,
- calculate_prorated_price,
get_remaining_months,
)
from app.utils.subscription_utils import (
@@ -339,9 +338,10 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
- # Для обычных тарифов - по месяцам
- months_hint = get_remaining_months(subscription.end_date)
- period_hint_days = months_hint * 30 if months_hint > 0 else None
+ # Для обычных тарифов - по дням (как в кабинете)
+ now = datetime.now(UTC)
+ days_left = max(1, (subscription.end_date - now).days)
+ period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
@@ -352,12 +352,11 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
devices_price_per_month,
devices_discount_percent,
)
- price, charged_months = calculate_prorated_price(
- discounted_per_month,
- subscription.end_date,
- )
- total_discount = discount_per_month * charged_months
- period_label = f'{charged_months} мес'
+ # Цена = месячная_цена * days_left / 30
+ price = int(discounted_per_month * days_left / 30)
+ price = max(100, price) # Минимум 1 рубль
+ total_discount = int(discount_per_month * days_left / 30)
+ period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
if price > 0 and db_user.balance_kopeks < price:
missing_kopeks = price - db_user.balance_kopeks
@@ -1161,9 +1160,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
total_discount = int(discount_per_month * days_left / 30)
period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
else:
- # Для обычных тарифов - по месяцам
- months_hint = get_remaining_months(subscription.end_date)
- period_hint_days = months_hint * 30 if months_hint > 0 else None
+ # Для обычных тарифов - по дням (как в кабинете)
+ now = datetime.now(UTC)
+ days_left = max(1, (subscription.end_date - now).days)
+ period_hint_days = days_left
devices_discount_percent = _get_addon_discount_percent_for_user(
db_user,
@@ -1174,12 +1174,11 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
devices_price_per_month,
devices_discount_percent,
)
- price, charged_months = calculate_prorated_price(
- discounted_per_month,
- subscription.end_date,
- )
- total_discount = discount_per_month * charged_months
- period_label = f'{charged_months} мес'
+ # Цена = месячная_цена * days_left / 30
+ price = int(discounted_per_month * days_left / 30)
+ price = max(100, price) # Минимум 1 рубль
+ total_discount = int(discount_per_month * days_left / 30)
+ period_label = f'{days_left} дн.' if days_left > 1 else '1 день'
logger.info(
'Добавление устройств: ₽/мес × = ₽ (скидка ₽)',
diff --git a/app/handlers/subscription/purchase.py b/app/handlers/subscription/purchase.py
index 508ffce1..88aa0565 100644
--- a/app/handlers/subscription/purchase.py
+++ b/app/handlers/subscription/purchase.py
@@ -1952,6 +1952,8 @@ async def confirm_extend_subscription(callback: types.CallbackQuery, db_user: Us
'return_to_cart': True,
'description': f'Продление подписки на {days} дней',
'consume_promo_offer': bool(promo_component['discount'] > 0),
+ 'device_limit': device_limit,
+ 'traffic_limit_gb': renewal_traffic_gb,
}
await user_cart_service.save_user_cart(db_user.id, cart_data)
diff --git a/app/services/guest_purchase_service.py b/app/services/guest_purchase_service.py
index 879a3391..4aaf3984 100644
--- a/app/services/guest_purchase_service.py
+++ b/app/services/guest_purchase_service.py
@@ -1,12 +1,13 @@
"""Service for guest (unauthenticated) purchases via landing pages."""
import asyncio
+import re
import secrets
from datetime import UTC, datetime
from typing import Literal
import structlog
-from sqlalchemy import select
+from sqlalchemy import func, select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
@@ -22,6 +23,8 @@ from app.services.subscription_service import SubscriptionService
logger = structlog.get_logger(__name__)
+_TELEGRAM_USERNAME_RE = re.compile(r'^[a-zA-Z][a-zA-Z0-9_]{4,31}$')
+
async def _send_admin_notification(
purchase: GuestPurchase,
@@ -216,14 +219,14 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha
# Check if user already has a subscription
existing_subscription = await get_subscription_by_user_id(db, user.id)
- if existing_subscription is not None and existing_subscription.is_active:
- # Active subscription — hold for manual activation
+ if existing_subscription is not None and (existing_subscription.is_active or purchase.is_gift):
+ # Active subscription or gift with any existing subscription — hold for manual activation
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
purchase.user_id = user.id
if recipient_type == 'email' and not purchase.is_gift:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
- await db.refresh(purchase, attribute_names=['landing'])
+ await db.refresh(purchase, attribute_names=['landing', 'user'])
try:
await send_guest_notification(
@@ -292,7 +295,7 @@ async def fulfill_purchase(db: AsyncSession, purchase_token: str) -> GuestPurcha
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
- await db.refresh(purchase, attribute_names=['landing'])
+ await db.refresh(purchase, attribute_names=['landing', 'user'])
try:
await send_guest_notification(
@@ -429,30 +432,86 @@ async def _find_or_create_user(
if contact_type != 'telegram':
raise GuestPurchaseError(f'Unsupported contact type: {contact_type}', status_code=500)
- username = contact_value.lstrip('@').lower()
- result = await db.execute(
- select(User).where(User.username == username),
- )
- user = result.scalars().first()
+ username = contact_value.lstrip('@')
+ if not _TELEGRAM_USERNAME_RE.match(username):
+ raise GuestPurchaseError('Invalid Telegram username format', status_code=400)
+ normalized = username.lower()
+
+ # Try to resolve telegram_id via Bot API (works if user has interacted with the bot)
+ resolved_telegram_id: int | None = None
+ try:
+ from aiogram import Bot
+
+ async with Bot(token=settings.BOT_TOKEN) as bot:
+ chat = await asyncio.wait_for(
+ bot.get_chat(chat_id=f'@{username}'),
+ timeout=5.0,
+ )
+ resolved_telegram_id = chat.id
+ # Use the canonical username from Telegram if available
+ if chat.username:
+ username = chat.username
+ normalized = username.lower()
+ except Exception as exc:
+ logger.debug('Could not resolve telegram_id for username', username=username, error=str(exc))
+
+ # Search by telegram_id first (most reliable), then by username (case-insensitive)
+ user = None
+ if resolved_telegram_id:
+ result = await db.execute(
+ select(User).where(User.telegram_id == resolved_telegram_id),
+ )
+ user = result.scalars().first()
+
+ if not user:
+ result = await db.execute(
+ select(User).where(func.lower(User.username) == normalized),
+ )
+ user = result.scalars().first()
+
if user:
+ # Backfill telegram_id if we resolved it and user doesn't have it yet
+ if resolved_telegram_id and not user.telegram_id:
+ try:
+ async with db.begin_nested():
+ user.telegram_id = resolved_telegram_id
+ await db.flush()
+ except IntegrityError:
+ logger.warning(
+ 'Could not backfill telegram_id (unique constraint)',
+ user_id=user.id,
+ resolved_telegram_id=resolved_telegram_id,
+ )
+ await db.refresh(user)
return user, False
- # Create new telegram user (without telegram_id — will be linked later)
+ # Create new telegram user
user = User(
auth_type='telegram',
username=username,
+ telegram_id=resolved_telegram_id,
)
try:
async with db.begin_nested():
db.add(user)
await db.flush()
except IntegrityError:
- result = await db.execute(select(User).where(User.username == username))
+ if resolved_telegram_id:
+ result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
+ user = result.scalars().first()
+ if user:
+ return user, False
+ result = await db.execute(select(User).where(func.lower(User.username) == normalized))
user = result.scalars().first()
if user:
return user, False
raise
- logger.info('Created new telegram user for guest purchase', user_id=user.id, username=username)
+ logger.info(
+ 'Created new telegram user for guest purchase',
+ user_id=user.id,
+ username=username,
+ has_telegram_id=resolved_telegram_id is not None,
+ )
return user, False
@@ -463,6 +522,82 @@ def _get_recipient_contact(purchase: GuestPurchase) -> tuple[str, str]:
return purchase.contact_type, purchase.contact_value
+async def _send_telegram_gift_notification(
+ purchase: GuestPurchase,
+ *,
+ is_pending_activation: bool = False,
+ tariff_name: str = '',
+) -> None:
+ """Send Telegram bot message to gift recipient if they have a telegram_id."""
+ if not settings.BOT_TOKEN:
+ return
+ user = purchase.user
+ if not user or not user.telegram_id:
+ return
+
+ try:
+ import html as html_mod
+
+ from aiogram import Bot
+ from aiogram.client.default import DefaultBotProperties
+ from aiogram.enums import ParseMode
+ from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
+
+ gift_from = ''
+ if purchase.contact_value:
+ safe_name = html_mod.escape(purchase.contact_value)
+ gift_from = f'\nОт: {safe_name}'
+
+ gift_msg = ''
+ if purchase.gift_message:
+ safe_msg = html_mod.escape(purchase.gift_message)
+ gift_msg = f'\n\n"{safe_msg}"'
+
+ safe_tariff = html_mod.escape(tariff_name) if tariff_name else ''
+ period_text = f'{purchase.period_days} дн.' if purchase.period_days else ''
+ tariff_text = f'{safe_tariff} — {period_text}' if safe_tariff else period_text
+
+ text = f'🎁 Вам подарили VPN подписку!\n{tariff_text}{gift_from}{gift_msg}'
+
+ keyboard = None
+ if is_pending_activation:
+ text += '\n\nУ вас уже есть активная подписка. Нажмите кнопку ниже, чтобы активировать подарок (текущая подписка будет заменена).'
+ keyboard = InlineKeyboardMarkup(
+ inline_keyboard=[
+ [
+ InlineKeyboardButton(
+ text='Активировать подарок',
+ callback_data=f'gift_activate:{purchase.id}',
+ )
+ ]
+ ]
+ )
+
+ async with Bot(
+ token=settings.BOT_TOKEN,
+ default=DefaultBotProperties(parse_mode=ParseMode.HTML),
+ ) as bot:
+ await bot.send_message(
+ chat_id=user.telegram_id,
+ text=text,
+ reply_markup=keyboard,
+ )
+
+ logger.info(
+ 'Telegram gift notification sent',
+ purchase_id=purchase.id,
+ recipient_telegram_id=user.telegram_id,
+ is_pending_activation=is_pending_activation,
+ )
+ except Exception:
+ logger.warning(
+ 'Failed to send Telegram gift notification',
+ purchase_id=purchase.id,
+ recipient_telegram_id=user.telegram_id if user else None,
+ exc_info=True,
+ )
+
+
async def send_guest_notification(
purchase: GuestPurchase,
*,
@@ -471,9 +606,11 @@ async def send_guest_notification(
language: str = 'ru',
is_new_account: bool = False,
) -> None:
- """Send email notification for guest purchase delivery or activation requirement.
+ """Send notification for guest purchase delivery or activation requirement.
- For telegram contacts, no notification is sent (success page only).
+ For telegram gift contacts, sends a Telegram bot message to the recipient.
+ For telegram non-gift contacts, no notification is sent (success page only).
+ For email contacts, sends an email notification.
For gifts, notification goes to the recipient, not the buyer.
Args:
@@ -488,8 +625,16 @@ async def send_guest_notification(
from app.cabinet.services.email_templates import EmailNotificationTemplates
from app.services.notification_delivery_service import NotificationType
- recipient_type, recipient_email = _get_recipient_contact(purchase)
+ recipient_type, recipient_value = _get_recipient_contact(purchase)
+ if recipient_type == 'telegram':
+ if purchase.is_gift:
+ await _send_telegram_gift_notification(
+ purchase, is_pending_activation=is_pending_activation, tariff_name=tariff_name
+ )
+ return
+
+ recipient_email = recipient_value
if recipient_type != 'email':
return
@@ -600,7 +745,7 @@ async def send_guest_notification(
logger.warning('Failed to send cabinet credentials email', purchase_id=purchase.id)
-async def activate_purchase(db: AsyncSession, purchase_token: str) -> GuestPurchase:
+async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notification: bool = False) -> GuestPurchase:
"""Activate a PENDING_ACTIVATION purchase by replacing or creating a subscription.
Uses SELECT ... FOR UPDATE to prevent concurrent activation.
@@ -687,18 +832,19 @@ async def activate_purchase(db: AsyncSession, purchase_token: str) -> GuestPurch
if user.auth_type == 'email' and not purchase.is_gift:
purchase.auto_login_token = create_auto_login_token(user.id)
await db.commit()
- await db.refresh(purchase, attribute_names=['landing'])
+ await db.refresh(purchase, attribute_names=['landing', 'user'])
- try:
- await send_guest_notification(
- purchase,
- is_pending_activation=False,
- tariff_name=notification_tariff_name,
- language=notification_language,
- is_new_account=is_new_account,
- )
- except Exception:
- logger.exception('Failed to send delivery notification after activation', purchase_id=purchase.id)
+ if not skip_notification:
+ try:
+ await send_guest_notification(
+ purchase,
+ is_pending_activation=False,
+ tariff_name=notification_tariff_name,
+ language=notification_language,
+ is_new_account=is_new_account,
+ )
+ except Exception:
+ logger.exception('Failed to send delivery notification after activation', purchase_id=purchase.id)
await _send_admin_notification(purchase, notification_tariff_name, is_pending_activation=False)
diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py
index 06c119b3..02642d4f 100644
--- a/app/services/payment/cloudpayments.py
+++ b/app/services/payment/cloudpayments.py
@@ -258,6 +258,20 @@ class CloudPaymentsPaymentMixin:
logger.error('Пользователь не найден: id', user_id=payment.user_id)
return False
+ # Загружаем промогруппы и данные для уведомлений
+ await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
+ for user_promo_group in getattr(user, 'user_promo_groups', []):
+ await db.refresh(user_promo_group, attribute_names=['promo_group'])
+
+ from app.utils.user_utils import format_referrer_info
+
+ promo_group = user.get_primary_promo_group()
+ subscription = getattr(user, 'subscription', None)
+ referrer_info = format_referrer_info(user)
+
+ old_balance = user.balance_kopeks
+ was_first_topup = not user.has_made_first_topup
+
# Credit balance directly (not via add_user_balance which commits)
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
@@ -313,6 +327,44 @@ class CloudPaymentsPaymentMixin:
except Exception as error:
logger.exception('Ошибка отправки уведомления CloudPayments', error=error)
+ # Начисляем реферальную комиссию
+ try:
+ from app.services.referral_service import process_referral_topup
+
+ await process_referral_topup(
+ db,
+ user.id,
+ amount_kopeks,
+ getattr(self, 'bot', None),
+ )
+ except Exception as error:
+ logger.error('Ошибка обработки реферального пополнения CloudPayments', error=error)
+
+ if was_first_topup and not user.has_made_first_topup:
+ user.has_made_first_topup = True
+ await db.commit()
+ await db.refresh(user)
+
+ topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
+
+ if getattr(self, 'bot', None):
+ try:
+ from app.services.admin_notification_service import AdminNotificationService
+
+ notification_service = AdminNotificationService(self.bot)
+ await notification_service.send_balance_topup_notification(
+ user,
+ transaction,
+ old_balance,
+ topup_status=topup_status,
+ referrer_info=referrer_info,
+ subscription=subscription,
+ promo_group=promo_group,
+ db=db,
+ )
+ except Exception as error:
+ logger.error('Ошибка отправки админ уведомления CloudPayments', error=error)
+
# Автопокупка из сохранённой корзины и уведомление о корзине
try:
from app.services.payment.common import send_cart_notification_after_topup
diff --git a/app/services/payment/wata.py b/app/services/payment/wata.py
index d49d8198..af303430 100644
--- a/app/services/payment/wata.py
+++ b/app/services/payment/wata.py
@@ -194,7 +194,7 @@ class WataPaymentMixin:
return False
order_id_raw = payload.get('orderId')
- payment_link_raw = payload.get('paymentLinkId') or payload.get('id')
+ payment_link_raw = payload.get('paymentLinkId') or payload.get('id') or payload.get('transactionId')
transaction_status_raw = payload.get('transactionStatus')
order_id = str(order_id_raw) if order_id_raw else None
diff --git a/app/services/subscription_auto_purchase_service.py b/app/services/subscription_auto_purchase_service.py
index 6524ad59..56a9e748 100644
--- a/app/services/subscription_auto_purchase_service.py
+++ b/app/services/subscription_auto_purchase_service.py
@@ -346,15 +346,20 @@ def _apply_extension_updates(context: AutoExtendContext) -> None:
# subscription.is_trial = False # УДАЛЕНО: преждевременное удаление триала
if context.traffic_limit_gb is not None:
subscription.traffic_limit_gb = context.traffic_limit_gb
+ # При конвертации триала device_limit должен быть не ниже DEFAULT_DEVICE_LIMIT
if context.device_limit is not None:
- subscription.device_limit = max(subscription.device_limit, context.device_limit)
+ subscription.device_limit = max(
+ subscription.device_limit or 0, context.device_limit, settings.DEFAULT_DEVICE_LIMIT
+ )
+ else:
+ subscription.device_limit = max(subscription.device_limit or 0, settings.DEFAULT_DEVICE_LIMIT)
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
else:
# Обновляем лимиты для платной подписки
if context.traffic_limit_gb not in (None, 0):
subscription.traffic_limit_gb = context.traffic_limit_gb
- if context.device_limit is not None and context.device_limit > subscription.device_limit:
+ if context.device_limit is not None and context.device_limit > (subscription.device_limit or 0):
subscription.device_limit = context.device_limit
if context.squad_uuid and context.squad_uuid not in (subscription.connected_squads or []):
subscription.connected_squads = (subscription.connected_squads or []) + [context.squad_uuid]
diff --git a/app/services/subscription_service.py b/app/services/subscription_service.py
index e7062399..e2ba9011 100644
--- a/app/services/subscription_service.py
+++ b/app/services/subscription_service.py
@@ -248,7 +248,8 @@ class SubscriptionService:
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
- email=user.email, # Обновляем email в панели RemnaWave
+ telegram_id=user.telegram_id,
+ email=user.email,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
@@ -403,7 +404,8 @@ class SubscriptionService:
expire_at=subscription.end_date,
traffic_limit_bytes=self._gb_to_bytes(subscription.traffic_limit_gb),
traffic_limit_strategy=get_traffic_reset_strategy(subscription.tariff),
- email=user.email, # Обновляем email в панели RemnaWave
+ telegram_id=user.telegram_id,
+ email=user.email,
description=settings.format_remnawave_user_description(
full_name=user.full_name,
username=user.username,
diff --git a/app/services/wata_service.py b/app/services/wata_service.py
index be1e60a2..bde1c3f5 100644
--- a/app/services/wata_service.py
+++ b/app/services/wata_service.py
@@ -180,7 +180,9 @@ class WataService:
'orderId': order_id,
}
- payload['type'] = link_type or settings.WATA_PAYMENT_TYPE or 'OneTime'
+ payment_type = link_type or settings.WATA_PAYMENT_TYPE
+ if payment_type:
+ payload['type'] = payment_type
if success_url or settings.WATA_SUCCESS_REDIRECT_URL:
payload['successRedirectUrl'] = success_url or settings.WATA_SUCCESS_REDIRECT_URL
diff --git a/app/utils/message_patch.py b/app/utils/message_patch.py
index 19a7c846..8fd6049e 100644
--- a/app/utils/message_patch.py
+++ b/app/utils/message_patch.py
@@ -189,6 +189,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
# Уважаем флаг в рантайме: если логотип выключен — не подменяем редактирование
if not settings.ENABLE_LOGO_MODE:
kwargs.setdefault('disable_web_page_preview', True)
+ # Медиа-сообщения (фото/видео из рассылки и т.д.) не имеют text — edit_text упадёт.
+ # Удаляем старое сообщение и отправляем новое.
+ if self.text is None:
+ try:
+ await self.delete()
+ except TelegramBadRequest:
+ pass
+ try:
+ return await _original_answer(self, text, **kwargs)
+ except TelegramBadRequest as error:
+ if is_topic_required_error(error):
+ return None
+ raise
return await _original_edit_text(self, text, **kwargs)
if self.photo:
language = _get_language(self)
@@ -242,6 +255,19 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
if is_topic_required_error(inner_error):
return None
raise
+ # Не-фото медиа (видео, анимация и т.д.) с включённым логотипом — удаляем и отправляем с фото
+ if self.text is None:
+ try:
+ await self.delete()
+ except TelegramBadRequest:
+ pass
+ try:
+ return await _answer_with_photo(self, text, **kwargs)
+ except TelegramBadRequest as error:
+ if is_topic_required_error(error):
+ return None
+ raise
+
# Обработка ошибок MESSAGE_ID_INVALID для сообщений без фото
try:
return await _text_edit(self, text, **kwargs)
diff --git a/app/webapi/routes/subscriptions.py b/app/webapi/routes/subscriptions.py
index 87c89bdd..e13f65a2 100644
--- a/app/webapi/routes/subscriptions.py
+++ b/app/webapi/routes/subscriptions.py
@@ -329,16 +329,6 @@ async def delete_subscription(
"""
subscription = await _get_subscription(db, subscription_id)
- from app.database.crud.subscription import is_active_paid_subscription
-
- if is_active_paid_subscription(subscription):
- logger.info(
- '⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
- subscription_id=subscription_id,
- )
- subscription = await _get_subscription(db, subscription.id)
- return _serialize_subscription(subscription)
-
await deactivate_subscription(db, subscription)
# Деактивируем пользователя в RemnaWave, если есть UUID
diff --git a/app/webapi/routes/users.py b/app/webapi/routes/users.py
index 1a760daa..e5c7c28e 100644
--- a/app/webapi/routes/users.py
+++ b/app/webapi/routes/users.py
@@ -453,20 +453,10 @@ async def delete_user_subscription(
"""
user = await _get_user_by_id_or_telegram_id(db, user_id)
- from app.database.crud.subscription import is_active_paid_subscription
-
subscription = await get_subscription_by_user_id(db, user.id)
if not subscription:
raise HTTPException(status.HTTP_404_NOT_FOUND, 'User has no subscription')
- if is_active_paid_subscription(subscription):
- logger.info(
- '⏭️ Пропуск деактивации: у пользователя активная оплаченная подписка',
- user_id=user.id,
- )
- user = await get_user_by_id(db, user.id)
- return _serialize_user(user)
-
await deactivate_subscription(db, subscription)
# Деактивируем пользователя в RemnaWave, если есть UUID