diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py new file mode 100644 index 00000000..eaa789ae --- /dev/null +++ b/app/database/crud/discount_offer.py @@ -0,0 +1,90 @@ +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database.models import DiscountOffer + + +async def upsert_discount_offer( + db: AsyncSession, + *, + user_id: int, + subscription_id: Optional[int], + notification_type: str, + discount_percent: int, + bonus_amount_kopeks: int, + valid_hours: int, +) -> DiscountOffer: + """Create or refresh a discount offer for a user.""" + + expires_at = datetime.utcnow() + timedelta(hours=valid_hours) + + result = await db.execute( + select(DiscountOffer) + .where( + DiscountOffer.user_id == user_id, + DiscountOffer.notification_type == notification_type, + DiscountOffer.is_active == True, # noqa: E712 + ) + .order_by(DiscountOffer.created_at.desc()) + ) + offer = result.scalars().first() + + if offer and offer.claimed_at is None: + offer.discount_percent = discount_percent + offer.bonus_amount_kopeks = bonus_amount_kopeks + offer.expires_at = expires_at + offer.subscription_id = subscription_id + else: + offer = DiscountOffer( + user_id=user_id, + subscription_id=subscription_id, + notification_type=notification_type, + discount_percent=discount_percent, + bonus_amount_kopeks=bonus_amount_kopeks, + expires_at=expires_at, + is_active=True, + ) + db.add(offer) + + await db.commit() + await db.refresh(offer) + return offer + + +async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountOffer]: + result = await db.execute( + select(DiscountOffer).where(DiscountOffer.id == offer_id) + ) + return result.scalar_one_or_none() + + +async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: + offer.claimed_at = datetime.utcnow() + offer.is_active = False + await db.commit() + await db.refresh(offer) + return offer + + +async def deactivate_expired_offers(db: AsyncSession) -> int: + now = datetime.utcnow() + result = await db.execute( + select(DiscountOffer).where( + DiscountOffer.is_active == True, # noqa: E712 + DiscountOffer.expires_at < now, + ) + ) + offers = result.scalars().all() + if not offers: + return 0 + + count = 0 + for offer in offers: + offer.is_active = False + count += 1 + + await db.commit() + return count diff --git a/app/database/models.py b/app/database/models.py index f9b6d8ab..91a7a360 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -14,6 +14,7 @@ from sqlalchemy import ( JSON, BigInteger, UniqueConstraint, + Index, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import relationship, Mapped, mapped_column @@ -358,6 +359,7 @@ class User(Base): subscription = relationship("Subscription", back_populates="user", uselist=False) transactions = relationship("Transaction", back_populates="user") referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user") + discount_offers = relationship("DiscountOffer", back_populates="user") lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) last_remnawave_sync = Column(DateTime, nullable=True) @@ -420,8 +422,9 @@ class Subscription(Base): updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) remnawave_short_uuid = Column(String(255), nullable=True) - + user = relationship("User", back_populates="subscription") + discount_offers = relationship("DiscountOffer", back_populates="subscription") @property def is_active(self) -> bool: @@ -765,6 +768,28 @@ class SentNotification(Base): user = relationship("User", backref="sent_notifications") subscription = relationship("Subscription", backref="sent_notifications") + +class DiscountOffer(Base): + __tablename__ = "discount_offers" + __table_args__ = ( + Index("ix_discount_offers_user_type", "user_id", "notification_type"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + subscription_id = Column(Integer, ForeignKey("subscriptions.id", ondelete="SET NULL"), nullable=True) + notification_type = Column(String(50), nullable=False) + discount_percent = Column(Integer, nullable=False, default=0) + bonus_amount_kopeks = Column(Integer, nullable=False, default=0) + expires_at = Column(DateTime, nullable=False) + claimed_at = Column(DateTime, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=func.now()) + updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) + + user = relationship("User", back_populates="discount_offers") + subscription = relationship("Subscription", back_populates="discount_offers") + class BroadcastHistory(Base): __tablename__ = "broadcast_history" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 40273ff4..522747f0 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -520,6 +520,94 @@ async def create_pal24_payments_table(): logger.error(f"Ошибка создания таблицы pal24_payments: {e}") return False + +async def create_discount_offers_table(): + table_exists = await check_table_exists('discount_offers') + if table_exists: + logger.info("Таблица discount_offers уже существует") + return True + + try: + async with engine.begin() as conn: + db_type = await get_database_type() + + if db_type == 'sqlite': + await conn.execute(text(""" + CREATE TABLE discount_offers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT 1, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'postgresql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + subscription_id INTEGER NULL REFERENCES subscriptions(id) ON DELETE SET NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMP NOT NULL, + claimed_at TIMESTAMP NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """)) + await conn.execute(text(""" + CREATE INDEX IF NOT EXISTS ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + elif db_type == 'mysql': + await conn.execute(text(""" + CREATE TABLE IF NOT EXISTS discount_offers ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + user_id INTEGER NOT NULL, + subscription_id INTEGER NULL, + notification_type VARCHAR(50) NOT NULL, + discount_percent INTEGER NOT NULL DEFAULT 0, + bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, + expires_at DATETIME NOT NULL, + claimed_at DATETIME NULL, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_discount_offers_user FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + CONSTRAINT fk_discount_offers_subscription FOREIGN KEY(subscription_id) REFERENCES subscriptions(id) ON DELETE SET NULL + ) + """)) + await conn.execute(text(""" + CREATE INDEX ix_discount_offers_user_type + ON discount_offers (user_id, notification_type) + """)) + + else: + raise ValueError(f"Unsupported database type: {db_type}") + + logger.info("✅ Таблица discount_offers успешно создана") + return True + + except Exception as e: + logger.error(f"Ошибка создания таблицы discount_offers: {e}") + return False + async def create_user_messages_table(): table_exists = await check_table_exists('user_messages') if table_exists: @@ -1467,6 +1555,13 @@ async def run_universal_migration(): else: logger.warning("⚠️ Проблемы с таблицей Pal24 payments") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===") + discount_created = await create_discount_offers_table() + if discount_created: + logger.info("✅ Таблица discount_offers готова") + else: + logger.warning("⚠️ Проблемы с таблицей discount_offers") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ USER_MESSAGES ===") user_messages_created = await create_user_messages_table() if user_messages_created: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index be876876..29d097d2 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -4,6 +4,7 @@ from datetime import datetime, timedelta from aiogram import Router, F from aiogram.types import Message, CallbackQuery from aiogram.filters import Command +from aiogram.fsm.context import FSMContext from app.config import settings from app.database.database import get_db @@ -12,11 +13,77 @@ from app.utils.decorators import admin_required from app.utils.pagination import paginate_list from app.keyboards.admin import get_monitoring_keyboard, get_admin_main_keyboard from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService +from app.states import AdminStates logger = logging.getLogger(__name__) router = Router() +def _format_toggle(enabled: bool) -> str: + return "🟢 Вкл" if enabled else "🔴 Выкл" + + +def _build_notification_settings_view(language: str): + texts = get_texts(language) + config = NotificationSettingsService.get_config() + + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + second_hours = NotificationSettingsService.get_second_wave_valid_hours() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_hours = NotificationSettingsService.get_third_wave_valid_hours() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + trial_1h_status = _format_toggle(config["trial_inactive_1h"].get("enabled", True)) + trial_24h_status = _format_toggle(config["trial_inactive_24h"].get("enabled", True)) + expired_1d_status = _format_toggle(config["expired_1d"].get("enabled", True)) + second_wave_status = _format_toggle(config["expired_second_wave"].get("enabled", True)) + third_wave_status = _format_toggle(config["expired_third_wave"].get("enabled", True)) + + summary_text = ( + "🔔 Уведомления пользователям\n\n" + f"• 1 час после триала: {trial_1h_status}\n" + f"• 24 часа после триала: {trial_24h_status}\n" + f"• 1 день после истечения: {expired_1d_status}\n" + f"• 2-3 дня (скидка {second_percent}% / {second_hours} ч): {second_wave_status}\n" + f"• {third_days} дней (скидка {third_percent}% / {third_hours} ч): {third_wave_status}" + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=f"{trial_1h_status} • 1 час после триала", callback_data="admin_mon_notify_toggle_trial_1h")], + [InlineKeyboardButton(text=f"{trial_24h_status} • 24 часа после триала", callback_data="admin_mon_notify_toggle_trial_24h")], + [InlineKeyboardButton(text=f"{expired_1d_status} • 1 день после истечения", callback_data="admin_mon_notify_toggle_expired_1d")], + [InlineKeyboardButton(text=f"{second_wave_status} • 2-3 дня со скидкой", callback_data="admin_mon_notify_toggle_expired_2d")], + [InlineKeyboardButton(text=f"✏️ Скидка 2-3 дня: {second_percent}%", callback_data="admin_mon_notify_edit_2d_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки 2-3 дня: {second_hours} ч", callback_data="admin_mon_notify_edit_2d_hours")], + [InlineKeyboardButton(text=f"{third_wave_status} • {third_days} дней со скидкой", callback_data="admin_mon_notify_toggle_expired_nd")], + [InlineKeyboardButton(text=f"✏️ Скидка {third_days} дней: {third_percent}%", callback_data="admin_mon_notify_edit_nd_percent")], + [InlineKeyboardButton(text=f"⏱️ Срок скидки {third_days} дней: {third_hours} ч", callback_data="admin_mon_notify_edit_nd_hours")], + [InlineKeyboardButton(text=f"📆 Порог уведомления: {third_days} дн.", callback_data="admin_mon_notify_edit_nd_threshold")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_mon_settings")], + ]) + + return summary_text, keyboard + + +async def _render_notification_settings(callback: CallbackQuery) -> None: + language = (callback.from_user.language_code or settings.DEFAULT_LANGUAGE) + text, keyboard = _build_notification_settings_view(language) + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + +async def _render_notification_settings_for_state(bot, chat_id: int, message_id: int, language: str) -> None: + text, keyboard = _build_notification_settings_view(language) + await bot.edit_message_text( + text, + chat_id, + message_id, + parse_mode="HTML", + reply_markup=keyboard, + ) + @router.callback_query(F.data == "admin_monitoring") @admin_required async def admin_monitoring_menu(callback: CallbackQuery): @@ -52,6 +119,180 @@ async def admin_monitoring_menu(callback: CallbackQuery): await callback.answer("❌ Ошибка получения данных", show_alert=True) +@router.callback_query(F.data == "admin_mon_settings") +@admin_required +async def admin_monitoring_settings(callback: CallbackQuery): + try: + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + global_status = "🟢 Включены" if NotificationSettingsService.are_notifications_globally_enabled() else "🔴 Отключены" + second_percent = NotificationSettingsService.get_second_wave_discount_percent() + third_percent = NotificationSettingsService.get_third_wave_discount_percent() + third_days = NotificationSettingsService.get_third_wave_trigger_days() + + text = ( + "⚙️ Настройки мониторинга\n\n" + f"🔔 Уведомления пользователям: {global_status}\n" + f"• Скидка 2-3 дня: {second_percent}%\n" + f"• Скидка после {third_days} дней: {third_percent}%\n\n" + "Выберите раздел для настройки." + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🔔 Уведомления пользователям", callback_data="admin_mon_notify_settings")], + [InlineKeyboardButton(text="⬅️ Назад", callback_data="admin_submenu_settings")], + ]) + + await callback.message.edit_text(text, parse_mode="HTML", reply_markup=keyboard) + + except Exception as e: + logger.error(f"Ошибка отображения настроек мониторинга: {e}") + await callback.answer("❌ Не удалось открыть настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_settings") +@admin_required +async def admin_notify_settings(callback: CallbackQuery): + try: + await _render_notification_settings(callback) + except Exception as e: + logger.error(f"Ошибка отображения настроек уведомлений: {e}") + await callback.answer("❌ Не удалось загрузить настройки", show_alert=True) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_1h") +@admin_required +async def toggle_trial_1h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_1h_enabled() + NotificationSettingsService.set_trial_inactive_1h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_trial_24h") +@admin_required +async def toggle_trial_24h_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_trial_inactive_24h_enabled() + NotificationSettingsService.set_trial_inactive_24h_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_1d") +@admin_required +async def toggle_expired_1d_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_expired_1d_enabled() + NotificationSettingsService.set_expired_1d_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_2d") +@admin_required +async def toggle_second_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_second_wave_enabled() + NotificationSettingsService.set_second_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +@router.callback_query(F.data == "admin_mon_notify_toggle_expired_nd") +@admin_required +async def toggle_third_wave_notification(callback: CallbackQuery): + enabled = NotificationSettingsService.is_third_wave_enabled() + NotificationSettingsService.set_third_wave_enabled(not enabled) + await callback.answer("✅ Включено" if not enabled else "⏸️ Отключено") + await _render_notification_settings(callback) + + +async def _start_notification_value_edit( + callback: CallbackQuery, + state: FSMContext, + setting_key: str, + field: str, + prompt_key: str, + default_prompt: str, +): + language = callback.from_user.language_code or settings.DEFAULT_LANGUAGE + await state.set_state(AdminStates.editing_notification_value) + await state.update_data( + notification_setting_key=setting_key, + notification_setting_field=field, + settings_message_chat=callback.message.chat.id, + settings_message_id=callback.message.message_id, + settings_language=language, + ) + texts = get_texts(language) + await callback.answer() + await callback.message.answer(texts.get(prompt_key, default_prompt)) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_percent") +@admin_required +async def edit_second_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "percent", + "NOTIFY_PROMPT_SECOND_PERCENT", + "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_2d_hours") +@admin_required +async def edit_second_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_second_wave", + "hours", + "NOTIFY_PROMPT_SECOND_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_percent") +@admin_required +async def edit_third_wave_percent(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "percent", + "NOTIFY_PROMPT_THIRD_PERCENT", + "Введите новый процент скидки для позднего предложения (0-100):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_hours") +@admin_required +async def edit_third_wave_hours(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "hours", + "NOTIFY_PROMPT_THIRD_HOURS", + "Введите количество часов действия скидки (1-168):", + ) + + +@router.callback_query(F.data == "admin_mon_notify_edit_nd_threshold") +@admin_required +async def edit_third_wave_threshold(callback: CallbackQuery, state: FSMContext): + await _start_notification_value_edit( + callback, + state, + "expired_third_wave", + "trigger", + "NOTIFY_PROMPT_THIRD_DAYS", + "Через сколько дней после истечения отправлять предложение? (минимум 2):", + ) + + @router.callback_query(F.data == "admin_mon_start") @admin_required async def start_monitoring_callback(callback: CallbackQuery): @@ -366,5 +607,53 @@ async def monitoring_command(message: Message): await message.answer(f"❌ Ошибка: {str(e)}") +@router.message(AdminStates.editing_notification_value) +async def process_notification_value_input(message: Message, state: FSMContext): + data = await state.get_data() + if not data: + await state.clear() + await message.answer("ℹ️ Контекст утерян, попробуйте снова из меню настроек.") + return + + raw_value = (message.text or "").strip() + try: + value = int(raw_value) + except (TypeError, ValueError): + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Введите целое число.")) + return + + key = data.get("notification_setting_key") + field = data.get("notification_setting_field") + language = data.get("settings_language") or message.from_user.language_code or settings.DEFAULT_LANGUAGE + texts = get_texts(language) + + success = False + if key == "expired_second_wave" and field == "percent": + success = NotificationSettingsService.set_second_wave_discount_percent(value) + elif key == "expired_second_wave" and field == "hours": + success = NotificationSettingsService.set_second_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "percent": + success = NotificationSettingsService.set_third_wave_discount_percent(value) + elif key == "expired_third_wave" and field == "hours": + success = NotificationSettingsService.set_third_wave_valid_hours(value) + elif key == "expired_third_wave" and field == "trigger": + success = NotificationSettingsService.set_third_wave_trigger_days(value) + + if not success: + await message.answer(texts.get("NOTIFICATION_VALUE_INVALID", "❌ Некорректное значение, попробуйте снова.")) + return + + await message.answer(texts.get("NOTIFICATION_VALUE_UPDATED", "✅ Настройки обновлены.")) + + chat_id = data.get("settings_message_chat") + message_id = data.get("settings_message_id") + if chat_id and message_id: + await _render_notification_settings_for_state(message.bot, chat_id, message_id, language) + + await state.clear() + + def register_handlers(dp): dp.include_router(router) \ No newline at end of file diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index 3f0c182a..3eeee497 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -17,12 +17,13 @@ from app.database.crud.subscription import ( add_subscription_squad, update_subscription_autopay, add_subscription_servers ) -from app.database.crud.user import subtract_user_balance +from app.database.crud.user import subtract_user_balance, add_user_balance from app.database.crud.transaction import create_transaction, get_user_transactions from app.database.models import ( - User, TransactionType, SubscriptionStatus, - SubscriptionServer, Subscription + User, TransactionType, SubscriptionStatus, + SubscriptionServer, Subscription ) +from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, get_subscription_period_keyboard, get_traffic_packages_keyboard, @@ -4068,6 +4069,76 @@ async def handle_connect_subscription( await callback.answer() +async def claim_discount_offer( + callback: types.CallbackQuery, + db_user: User, + db: AsyncSession, +): + texts = get_texts(db_user.language) + + try: + offer_id = int(callback.data.split("_")[-1]) + except (ValueError, AttributeError): + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + offer = await get_offer_by_id(db, offer_id) + if not offer or offer.user_id != db_user.id: + await callback.answer( + texts.get("DISCOUNT_CLAIM_NOT_FOUND", "❌ Предложение не найдено"), + show_alert=True, + ) + return + + now = datetime.utcnow() + if offer.claimed_at is not None: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ALREADY", "ℹ️ Скидка уже была активирована"), + show_alert=True, + ) + return + + if not offer.is_active or offer.expires_at <= now: + offer.is_active = False + await db.commit() + await callback.answer( + texts.get("DISCOUNT_CLAIM_EXPIRED", "⚠️ Время действия предложения истекло"), + show_alert=True, + ) + return + + bonus_amount = offer.bonus_amount_kopeks or 0 + if bonus_amount > 0: + success = await add_user_balance( + db, + db_user, + bonus_amount, + texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), + ) + if not success: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), + show_alert=True, + ) + return + + await mark_offer_claimed(db, offer) + + success_message = texts.get( + "DISCOUNT_CLAIM_SUCCESS", + "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + ).format( + percent=offer.discount_percent, + amount=settings.format_price(bonus_amount), + ) + + await callback.answer("✅ Скидка активирована!", show_alert=True) + await callback.message.answer(success_message) + + async def handle_device_guide( callback: types.CallbackQuery, db_user: User, @@ -4963,6 +5034,11 @@ def register_handlers(dp: Dispatcher): F.data == "countries_apply" ) + dp.callback_query.register( + claim_discount_offer, + F.data.startswith("claim_discount_") + ) + dp.callback_query.register( handle_connect_subscription, F.data == "subscription_connect" diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py index 8219147b..13c37204 100644 --- a/app/keyboards/admin.py +++ b/app/keyboards/admin.py @@ -99,6 +99,12 @@ def get_admin_settings_submenu_keyboard(language: str = "ru") -> InlineKeyboardM InlineKeyboardButton(text=texts.ADMIN_REMNAWAVE, callback_data="admin_remnawave"), InlineKeyboardButton(text=texts.ADMIN_MONITORING, callback_data="admin_monitoring") ], + [ + InlineKeyboardButton( + text=texts.t("ADMIN_MONITORING_SETTINGS", "⚙️ Настройки мониторинга"), + callback_data="admin_mon_settings" + ) + ], [ InlineKeyboardButton(text=texts.ADMIN_RULES, callback_data="admin_rules"), InlineKeyboardButton(text="🔧 Техработы", callback_data="maintenance_panel") diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index a190aec4..337e18f8 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -21,10 +21,15 @@ from app.database.crud.notification import ( notification_sent, record_notification, ) +from app.database.crud.discount_offer import ( + upsert_discount_offer, + deactivate_expired_offers, +) from app.database.models import MonitoringLog, SubscriptionStatus, Subscription, User, Ticket, TicketStatus from app.services.subscription_service import SubscriptionService from app.services.payment_service import PaymentService from app.localization.texts import get_texts +from app.services.notification_settings_service import NotificationSettingsService from app.external.remnawave_api import ( RemnaWaveUser, UserStatus, TrafficLimitStrategy, RemnaWaveAPIError @@ -80,10 +85,16 @@ class MonitoringService: async for db in get_db(): try: await self._cleanup_notification_cache() - + + expired_offers = await deactivate_expired_offers(db) + if expired_offers: + logger.info(f"🧹 Деактивировано {expired_offers} просроченных скидочных предложений") + await self._check_expired_subscriptions(db) await self._check_expiring_subscriptions(db) - await self._check_trial_expiring_soon(db) + await self._check_trial_expiring_soon(db) + await self._check_trial_inactivity_notifications(db) + await self._check_expired_subscription_followups(db) await self._process_autopayments(db) await self._cleanup_inactive_users(db) await self._sync_with_remnawave(db) @@ -250,7 +261,7 @@ class MonitoringService: async def _check_trial_expiring_soon(self, db: AsyncSession): try: threshold_time = datetime.utcnow() + timedelta(hours=2) - + result = await db.execute( select(Subscription) .options(selectinload(Subscription.user)) @@ -288,7 +299,202 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка проверки истекающих тестовых подписок: {e}") - + + async def _check_trial_inactivity_notifications(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + one_hour_ago = now - timedelta(hours=1) + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.status == SubscriptionStatus.ACTIVE.value, + Subscription.is_trial == True, + Subscription.start_date.isnot(None), + Subscription.start_date <= one_hour_ago, + Subscription.end_date > now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_1h = 0 + sent_24h = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if (subscription.traffic_used_gb or 0) > 0: + continue + + start_date = subscription.start_date + if not start_date: + continue + + time_since_start = now - start_date + + if (NotificationSettingsService.is_trial_inactive_1h_enabled() + and timedelta(hours=1) <= time_since_start < timedelta(hours=24)): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_1h"): + success = await self._send_trial_inactive_notification(user, subscription, 1) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_1h") + sent_1h += 1 + + if NotificationSettingsService.is_trial_inactive_24h_enabled() and time_since_start >= timedelta(hours=24): + if not await notification_sent(db, user.id, subscription.id, "trial_inactive_24h"): + success = await self._send_trial_inactive_notification(user, subscription, 24) + if success: + await record_notification(db, user.id, subscription.id, "trial_inactive_24h") + sent_24h += 1 + + if sent_1h or sent_24h: + await self._log_monitoring_event( + db, + "trial_inactivity_notifications", + f"Отправлено {sent_1h} уведомлений спустя 1 час и {sent_24h} спустя 24 часа", + {"sent_1h": sent_1h, "sent_24h": sent_24h}, + ) + + except Exception as e: + logger.error(f"Ошибка проверки неактивных тестовых подписок: {e}") + + async def _check_expired_subscription_followups(self, db: AsyncSession): + if not NotificationSettingsService.are_notifications_globally_enabled(): + return + if not self.bot: + return + + try: + now = datetime.utcnow() + + result = await db.execute( + select(Subscription) + .options(selectinload(Subscription.user)) + .where( + and_( + Subscription.is_trial == False, + Subscription.end_date <= now, + ) + ) + ) + + subscriptions = result.scalars().all() + sent_day1 = 0 + sent_wave2 = 0 + sent_wave3 = 0 + + for subscription in subscriptions: + user = subscription.user + if not user: + continue + + if subscription.end_date is None: + continue + + time_since_end = now - subscription.end_date + if time_since_end.total_seconds() < 0: + continue + + days_since = time_since_end.total_seconds() / 86400 + + # Day 1 reminder + if NotificationSettingsService.is_expired_1d_enabled() and 1 <= days_since < 2: + if not await notification_sent(db, user.id, subscription.id, "expired_1d"): + success = await self._send_expired_day1_notification(user, subscription) + if success: + await record_notification(db, user.id, subscription.id, "expired_1d") + sent_day1 += 1 + + # Second wave (2-3 days) discount + if NotificationSettingsService.is_second_wave_enabled() and 2 <= days_since < 4: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"): + percent = NotificationSettingsService.get_second_wave_discount_percent() + valid_hours = NotificationSettingsService.get_second_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave2", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "second", + bonus_amount, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave2") + sent_wave2 += 1 + + # Third wave (N days) discount + if NotificationSettingsService.is_third_wave_enabled(): + trigger_days = NotificationSettingsService.get_third_wave_trigger_days() + if trigger_days <= days_since < trigger_days + 1: + if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"): + percent = NotificationSettingsService.get_third_wave_discount_percent() + valid_hours = NotificationSettingsService.get_third_wave_valid_hours() + bonus_amount = settings.PRICE_30_DAYS * percent // 100 + offer = await upsert_discount_offer( + db, + user_id=user.id, + subscription_id=subscription.id, + notification_type="expired_discount_wave3", + discount_percent=percent, + bonus_amount_kopeks=bonus_amount, + valid_hours=valid_hours, + ) + success = await self._send_expired_discount_notification( + user, + subscription, + percent, + offer.expires_at, + offer.id, + "third", + bonus_amount, + trigger_days=trigger_days, + ) + if success: + await record_notification(db, user.id, subscription.id, "expired_discount_wave3") + sent_wave3 += 1 + + if sent_day1 or sent_wave2 or sent_wave3: + await self._log_monitoring_event( + db, + "expired_followups_sent", + ( + "Follow-ups: 1д={0}, скидка 2-3д={1}, скидка N={2}".format( + sent_day1, + sent_wave2, + sent_wave3, + ) + ), + { + "day1": sent_day1, + "wave2": sent_wave2, + "wave3": sent_wave3, + }, + ) + + except Exception as e: + logger.error(f"Ошибка проверки напоминаний об истекшей подписке: {e}") + async def _get_expiring_paid_subscriptions(self, db: AsyncSession, days_before: int) -> List[Subscription]: current_time = datetime.utcnow() threshold_date = current_time + timedelta(days=days_before) @@ -465,7 +671,7 @@ class MonitoringService: async def _send_trial_ending_notification(self, user: User, subscription: Subscription) -> bool: try: texts = get_texts(user.language) - + message = f""" 🎁 Тестовая подписка скоро закончится! @@ -501,7 +707,149 @@ class MonitoringService: except Exception as e: logger.error(f"Ошибка отправки уведомления об окончании тестовой подписки пользователю {user.telegram_id}: {e}") return False - + + async def _send_trial_inactive_notification(self, user: User, subscription: Subscription, hours: int) -> bool: + try: + texts = get_texts(user.language) + if hours >= 24: + template = texts.get( + "TRIAL_INACTIVE_24H", + ( + "⏳ Вы ещё не подключились к VPN\n\n" + "Прошли сутки с активации тестового периода, но трафик не зафиксирован." + "\n\nНажмите кнопку ниже, чтобы подключиться." + ), + ) + else: + template = texts.get( + "TRIAL_INACTIVE_1H", + ( + "⏳ Прошёл час, а подключения нет\n\n" + "Если возникли сложности с запуском — воспользуйтесь инструкциями." + ), + ) + + message = template.format( + price=settings.format_price(settings.PRICE_30_DAYS), + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("CONNECT_BUTTON", "🔗 Подключиться"), callback_data="subscription_connect")], + [InlineKeyboardButton(text=texts.t("MY_SUBSCRIPTION_BUTTON", "📱 Моя подписка"), callback_data="menu_subscription")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки уведомления об отсутствии подключения пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_day1_notification(self, user: User, subscription: Subscription) -> bool: + try: + texts = get_texts(user.language) + template = texts.get( + "SUBSCRIPTION_EXPIRED_1D", + ( + "⛔ Подписка закончилась\n\n" + "Доступ был отключён {end_date}. Продлите подписку, чтобы вернуться в сервис." + ), + ) + message = template.format( + end_date=subscription.end_date.strftime("%d.%m.%Y %H:%M"), + price=settings.format_price(settings.PRICE_30_DAYS), + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки напоминания об истекшей подписке пользователю {user.telegram_id}: {e}") + return False + + async def _send_expired_discount_notification( + self, + user: User, + subscription: Subscription, + percent: int, + expires_at: datetime, + offer_id: int, + wave: str, + bonus_amount: int, + trigger_days: int = None, + ) -> bool: + try: + texts = get_texts(user.language) + + if wave == "second": + template = texts.get( + "SUBSCRIPTION_EXPIRED_SECOND_WAVE", + ( + "🔥 Скидка {percent}% на продление\n\n" + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." + ), + ) + else: + template = texts.get( + "SUBSCRIPTION_EXPIRED_THIRD_WAVE", + ( + "🎁 Индивидуальная скидка {percent}%\n\n" + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {expires_at}." + ), + ) + + message = template.format( + percent=percent, + bonus=settings.format_price(bonus_amount), + expires_at=expires_at.strftime("%d.%m.%Y %H:%M"), + trigger_days=trigger_days or "", + ) + + from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton + + keyboard = InlineKeyboardMarkup(inline_keyboard=[ + [InlineKeyboardButton(text="🎁 Получить скидку", callback_data=f"claim_discount_{offer_id}")], + [InlineKeyboardButton(text=texts.t("SUBSCRIPTION_EXTEND", "💎 Продлить подписку"), callback_data="subscription_extend")], + [InlineKeyboardButton(text=texts.t("BALANCE_TOPUP", "💳 Пополнить баланс"), callback_data="balance_topup")], + [InlineKeyboardButton(text=texts.t("SUPPORT_BUTTON", "🆘 Поддержка"), callback_data="menu_support")], + ]) + + await self.bot.send_message( + user.telegram_id, + message, + parse_mode="HTML", + reply_markup=keyboard, + ) + return True + + except Exception as e: + logger.error(f"Ошибка отправки скидочного уведомления пользователю {user.telegram_id}: {e}") + return False + async def _send_autopay_success_notification(self, user: User, amount: int, days: int): try: texts = get_texts(user.language) diff --git a/app/services/notification_settings_service.py b/app/services/notification_settings_service.py new file mode 100644 index 00000000..a19edffd --- /dev/null +++ b/app/services/notification_settings_service.py @@ -0,0 +1,249 @@ +import json +import json +import logging +from copy import deepcopy +from pathlib import Path +from typing import Any, Dict + +from app.config import settings + + +logger = logging.getLogger(__name__) + + +class NotificationSettingsService: + """Runtime-editable notification settings stored on disk.""" + + _storage_path: Path = Path("data/notification_settings.json") + _data: Dict[str, Dict[str, Any]] = {} + _loaded: bool = False + + _DEFAULTS: Dict[str, Dict[str, Any]] = { + "trial_inactive_1h": {"enabled": True}, + "trial_inactive_24h": {"enabled": True}, + "expired_1d": {"enabled": True}, + "expired_second_wave": { + "enabled": True, + "discount_percent": 10, + "valid_hours": 24, + }, + "expired_third_wave": { + "enabled": True, + "discount_percent": 20, + "valid_hours": 24, + "trigger_days": 5, + }, + } + + @classmethod + def _ensure_dir(cls) -> None: + try: + cls._storage_path.parent.mkdir(parents=True, exist_ok=True) + except Exception as exc: # pragma: no cover - filesystem guard + logger.error("Failed to create notification settings dir: %s", exc) + + @classmethod + def _load(cls) -> None: + if cls._loaded: + return + + cls._ensure_dir() + try: + if cls._storage_path.exists(): + raw = cls._storage_path.read_text(encoding="utf-8") + cls._data = json.loads(raw) if raw.strip() else {} + else: + cls._data = {} + except Exception as exc: + logger.error("Failed to load notification settings: %s", exc) + cls._data = {} + + changed = cls._apply_defaults() + if changed: + cls._save() + cls._loaded = True + + @classmethod + def _apply_defaults(cls) -> bool: + changed = False + for key, defaults in cls._DEFAULTS.items(): + current = cls._data.get(key) + if not isinstance(current, dict): + cls._data[key] = deepcopy(defaults) + changed = True + continue + + for def_key, def_value in defaults.items(): + if def_key not in current: + current[def_key] = def_value + changed = True + return changed + + @classmethod + def _save(cls) -> bool: + cls._ensure_dir() + try: + cls._storage_path.write_text( + json.dumps(cls._data, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + return True + except Exception as exc: + logger.error("Failed to save notification settings: %s", exc) + return False + + @classmethod + def _get(cls, key: str) -> Dict[str, Any]: + cls._load() + value = cls._data.get(key) + if not isinstance(value, dict): + value = deepcopy(cls._DEFAULTS.get(key, {})) + cls._data[key] = value + return value + + @classmethod + def get_config(cls) -> Dict[str, Dict[str, Any]]: + cls._load() + return deepcopy(cls._data) + + @classmethod + def _set_field(cls, key: str, field: str, value: Any) -> bool: + cls._load() + section = cls._get(key) + section[field] = value + cls._data[key] = section + return cls._save() + + @classmethod + def set_enabled(cls, key: str, enabled: bool) -> bool: + return cls._set_field(key, "enabled", bool(enabled)) + + @classmethod + def is_enabled(cls, key: str) -> bool: + return bool(cls._get(key).get("enabled", True)) + + # Trial inactivity helpers + @classmethod + def is_trial_inactive_1h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_1h") + + @classmethod + def set_trial_inactive_1h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_1h", enabled) + + @classmethod + def is_trial_inactive_24h_enabled(cls) -> bool: + return cls.is_enabled("trial_inactive_24h") + + @classmethod + def set_trial_inactive_24h_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("trial_inactive_24h", enabled) + + # Expired subscription notifications + @classmethod + def is_expired_1d_enabled(cls) -> bool: + return cls.is_enabled("expired_1d") + + @classmethod + def set_expired_1d_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_1d", enabled) + + @classmethod + def is_second_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_second_wave") + + @classmethod + def set_second_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_second_wave", enabled) + + @classmethod + def get_second_wave_discount_percent(cls) -> int: + value = cls._get("expired_second_wave").get("discount_percent", 10) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 10 + + @classmethod + def set_second_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "discount_percent", percent_int) + + @classmethod + def get_second_wave_valid_hours(cls) -> int: + value = cls._get("expired_second_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_second_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_second_wave", "valid_hours", hours_int) + + @classmethod + def is_third_wave_enabled(cls) -> bool: + return cls.is_enabled("expired_third_wave") + + @classmethod + def set_third_wave_enabled(cls, enabled: bool) -> bool: + return cls.set_enabled("expired_third_wave", enabled) + + @classmethod + def get_third_wave_discount_percent(cls) -> int: + value = cls._get("expired_third_wave").get("discount_percent", 20) + try: + return max(0, min(100, int(value))) + except (TypeError, ValueError): + return 20 + + @classmethod + def set_third_wave_discount_percent(cls, percent: int) -> bool: + try: + percent_int = max(0, min(100, int(percent))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "discount_percent", percent_int) + + @classmethod + def get_third_wave_valid_hours(cls) -> int: + value = cls._get("expired_third_wave").get("valid_hours", 24) + try: + return max(1, min(168, int(value))) + except (TypeError, ValueError): + return 24 + + @classmethod + def set_third_wave_valid_hours(cls, hours: int) -> bool: + try: + hours_int = max(1, min(168, int(hours))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "valid_hours", hours_int) + + @classmethod + def get_third_wave_trigger_days(cls) -> int: + value = cls._get("expired_third_wave").get("trigger_days", 5) + try: + return max(2, min(60, int(value))) + except (TypeError, ValueError): + return 5 + + @classmethod + def set_third_wave_trigger_days(cls, days: int) -> bool: + try: + days_int = max(2, min(60, int(days))) + except (TypeError, ValueError): + return False + return cls._set_field("expired_third_wave", "trigger_days", days_int) + + @classmethod + def are_notifications_globally_enabled(cls) -> bool: + return bool(getattr(settings, "ENABLE_NOTIFICATIONS", True)) diff --git a/app/states.py b/app/states.py index 45e87e21..782fae7d 100644 --- a/app/states.py +++ b/app/states.py @@ -84,9 +84,10 @@ class AdminStates(StatesGroup): editing_device_price = State() editing_user_devices = State() editing_user_traffic = State() - + editing_rules_page = State() - + editing_notification_value = State() + confirming_sync = State() editing_server_name = State() diff --git a/locales/en.json b/locales/en.json index 1b416564..2f370239 100644 --- a/locales/en.json +++ b/locales/en.json @@ -129,6 +129,7 @@ "ACCESS_DENIED": "❌ Access denied", "ADMIN_MESSAGES": "📨 Broadcasts", "ADMIN_MONITORING": "🔍 Monitoring", + "ADMIN_MONITORING_SETTINGS": "⚙️ Monitoring settings", "ADMIN_PANEL": "\n⚙️ Administration panel\n\nSelect a section to manage:\n", "ADMIN_PROMOCODES": "🎫 Promo codes", "ADMIN_REFERRALS": "🤝 Referral program", @@ -484,5 +485,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "via CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Support team", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "other options", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ Automated payment methods are temporarily unavailable. Contact support to top up your balance.", + "TRIAL_INACTIVE_1H": "⏳ An hour has passed and we haven't seen any traffic yet\n\nOpen the connection guide and follow the steps. We're always ready to help!", + "TRIAL_INACTIVE_24H": "⏳ A full day passed without activity\n\nWe still don't see traffic from your test subscription. Use the guide or message support and we'll help you connect!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Your subscription expired\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back — tap “Get discount” and {bonus} will be credited. Offer valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.", + "NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Enter the number of hours the discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Enter a new discount percentage for the late offer (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Enter the number of hours the late discount is active (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "After how many days without a subscription should we send the offer? (minimum 2):" } diff --git a/locales/ru.json b/locales/ru.json index 736d38e1..5a9218f1 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -5,6 +5,7 @@ "ADMIN_CAMPAIGNS": "📣 Рекламные кампании", "ADMIN_MESSAGES": "📨 Рассылки", "ADMIN_MONITORING": "🔍 Мониторинг", + "ADMIN_MONITORING_SETTINGS": "⚙️ Настройки мониторинга", "ADMIN_REPORTS": "📊 Отчеты", "ADMIN_PANEL": "\n⚙️ Административная панель\n\nВыберите раздел для управления:\n", "ADMIN_PROMOCODES": "🎫 Промокоды", @@ -484,5 +485,23 @@ "PAYMENT_METHOD_CRYPTOBOT_DESCRIPTION": "через CryptoBot", "PAYMENT_METHOD_SUPPORT_NAME": "🛠️ Через поддержку", "PAYMENT_METHOD_SUPPORT_DESCRIPTION": "другие способы", - "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку." + "PAYMENT_METHODS_UNAVAILABLE_ALERT": "⚠️ В данный момент автоматические способы оплаты временно недоступны. Для пополнения баланса обратитесь в техподдержку.", + "TRIAL_INACTIVE_1H": "⏳ Прошёл час, а подключение не выполнено\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!", + "TRIAL_INACTIVE_24H": "⏳ Прошли сутки с начала теста\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!", + "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", + "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", + "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", + "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", + "NOTIFY_PROMPT_SECOND_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_PERCENT": "Введите новый процент скидки для позднего предложения (0-100):", + "NOTIFY_PROMPT_THIRD_HOURS": "Введите количество часов действия скидки (1-168):", + "NOTIFY_PROMPT_THIRD_DAYS": "Через сколько дней после истечения отправлять предложение? (минимум 2):" }