From 1cbf3c564381870160991e46c4874ffebe8d937c Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 4 Oct 2025 12:10:04 +0300 Subject: [PATCH] Recalculate promo offer discount during subscription confirmation --- app/database/crud/discount_offer.py | 2 +- app/database/crud/promo_offer_template.py | 10 +- app/database/models.py | 4 +- app/database/universal_migration.py | 111 ++++++++++++- app/handlers/admin/monitoring.py | 12 +- app/handlers/admin/promo_offers.py | 49 ++---- app/handlers/subscription.py | 182 +++++++++++++++++++--- app/localization/locales/en.json | 11 +- app/localization/locales/ru.json | 11 +- app/services/monitoring_service.py | 84 +++++++--- app/states.py | 1 - locales/en.json | 16 +- locales/ru.json | 16 +- 13 files changed, 385 insertions(+), 124 deletions(-) diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index 0ae6a068..c0bc9800 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -16,7 +16,7 @@ async def upsert_discount_offer( discount_percent: int, bonus_amount_kopeks: int, valid_hours: int, - effect_type: str = "balance_bonus", + effect_type: str = "percent_discount", extra_data: Optional[dict] = None, ) -> DiscountOffer: """Create or refresh a discount offer for a user.""" diff --git a/app/database/crud/promo_offer_template.py b/app/database/crud/promo_offer_template.py index cb99770b..9ebdd5fd 100644 --- a/app/database/crud/promo_offer_template.py +++ b/app/database/crud/promo_offer_template.py @@ -31,13 +31,14 @@ DEFAULT_TEMPLATES: tuple[dict, ...] = ( "name": "Скидка на продление", "message_text": ( "💎 Экономия {discount_percent}% при продлении\n\n" - "Мы начислим {bonus_amount} на баланс после активации, чтобы продление обошлось дешевле.\n" + "Активируйте предложение и получите дополнительную скидку на оплату продления. " + "Она суммируется с вашими промогрупповыми скидками и действует один раз.\n" "Срок действия предложения — {valid_hours} ч." ), "button_text": "🎁 Получить скидку", "valid_hours": 24, "discount_percent": 20, - "bonus_amount_kopeks": settings.PRICE_30_DAYS * 20 // 100, + "bonus_amount_kopeks": 0, "test_duration_hours": None, "test_squad_uuids": [], }, @@ -46,13 +47,14 @@ DEFAULT_TEMPLATES: tuple[dict, ...] = ( "name": "Скидка на покупку", "message_text": ( "🎯 Вернитесь со скидкой {discount_percent}%\n\n" - "Начислим {bonus_amount} после активации — используйте бонус при оплате новой подписки.\n" + "После активации мы применим дополнительную скидку к вашей следующей оплате подписки. " + "Скидка суммируется с промогруппой и действует один раз.\n" "Предложение действует {valid_hours} ч." ), "button_text": "🎁 Забрать скидку", "valid_hours": 48, "discount_percent": 25, - "bonus_amount_kopeks": settings.PRICE_30_DAYS * 25 // 100, + "bonus_amount_kopeks": 0, "test_duration_hours": None, "test_squad_uuids": [], }, diff --git a/app/database/models.py b/app/database/models.py index 6de613e4..e6dd34c2 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -389,6 +389,8 @@ class User(Base): lifetime_used_traffic_bytes = Column(BigInteger, default=0) auto_promo_group_assigned = Column(Boolean, nullable=False, default=False) auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0) + promo_offer_discount_percent = Column(Integer, nullable=False, default=0) + promo_offer_discount_source = Column(String(100), nullable=True) last_remnawave_sync = Column(DateTime, nullable=True) trojan_password = Column(String(255), nullable=True) vless_uuid = Column(String(255), nullable=True) @@ -812,7 +814,7 @@ class DiscountOffer(Base): expires_at = Column(DateTime, nullable=False) claimed_at = Column(DateTime, nullable=True) is_active = Column(Boolean, default=True, nullable=False) - effect_type = Column(String(50), nullable=False, default="balance_bonus") + effect_type = Column(String(50), nullable=False, default="percent_discount") extra_data = Column(JSON, nullable=True) created_at = Column(DateTime, default=func.now()) updated_at = Column(DateTime, default=func.now(), onupdate=func.now()) diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index 4d5dd201..fd1db22e 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -728,7 +728,7 @@ async def create_discount_offers_table(): expires_at DATETIME NOT NULL, claimed_at DATETIME NULL, is_active BOOLEAN NOT NULL DEFAULT 1, - effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus', + effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount', extra_data TEXT NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -753,7 +753,7 @@ async def create_discount_offers_table(): expires_at TIMESTAMP NOT NULL, claimed_at TIMESTAMP NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, - effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus', + effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount', extra_data JSON NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP @@ -776,7 +776,7 @@ async def create_discount_offers_table(): expires_at DATETIME NOT NULL, claimed_at DATETIME NULL, is_active BOOLEAN NOT NULL DEFAULT TRUE, - effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus', + effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount', extra_data JSON NULL, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, @@ -814,15 +814,15 @@ async def ensure_discount_offer_columns(): if not effect_exists: if db_type == 'sqlite': await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'" + "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" )) elif db_type == 'postgresql': await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'" + "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" )) elif db_type == 'mysql': await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'" + "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" )) else: raise ValueError(f"Unsupported database type: {db_type}") @@ -851,6 +851,76 @@ async def ensure_discount_offer_columns(): return False +async def ensure_user_promo_offer_discount_columns(): + try: + percent_exists = await check_column_exists('users', 'promo_offer_discount_percent') + source_exists = await check_column_exists('users', 'promo_offer_discount_source') + + if percent_exists and source_exists: + return True + + async with engine.begin() as conn: + db_type = await get_database_type() + + if not percent_exists: + column_def = 'INTEGER NOT NULL DEFAULT 0' + if db_type == 'mysql': + column_def = 'INT NOT NULL DEFAULT 0' + await conn.execute(text( + f"ALTER TABLE users ADD COLUMN promo_offer_discount_percent {column_def}" + )) + + if not source_exists: + if db_type == 'sqlite': + column_def = 'TEXT NULL' + elif db_type == 'postgresql': + column_def = 'VARCHAR(100) NULL' + elif db_type == 'mysql': + column_def = 'VARCHAR(100) NULL' + else: + raise ValueError(f"Unsupported database type: {db_type}") + + await conn.execute(text( + f"ALTER TABLE users ADD COLUMN promo_offer_discount_source {column_def}" + )) + + logger.info("✅ Колонки promo_offer_discount_* для users проверены") + return True + except Exception as e: + logger.error(f"Ошибка обновления колонок promo_offer_discount_*: {e}") + return False + + +async def migrate_discount_offer_effect_types(): + try: + async with engine.begin() as conn: + await conn.execute(text( + "UPDATE discount_offers SET effect_type = 'percent_discount' " + "WHERE effect_type = 'balance_bonus'" + )) + logger.info("✅ Типы эффектов discount_offers обновлены на percent_discount") + return True + except Exception as e: + logger.error(f"Ошибка обновления типов эффектов discount_offers: {e}") + return False + + +async def reset_discount_offer_bonuses(): + try: + async with engine.begin() as conn: + await conn.execute(text( + "UPDATE discount_offers SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0" + )) + await conn.execute(text( + "UPDATE promo_offer_templates SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0" + )) + logger.info("✅ Бонусы промо-предложений сброшены до нуля") + return True + except Exception as e: + logger.error(f"Ошибка обнуления бонусов промо-предложений: {e}") + return False + + async def create_promo_offer_templates_table(): table_exists = await check_table_exists('promo_offer_templates') if table_exists: @@ -2433,6 +2503,24 @@ async def run_universal_migration(): else: logger.warning("⚠️ Не удалось обновить колонки discount_offers") + user_discount_columns_ready = await ensure_user_promo_offer_discount_columns() + if user_discount_columns_ready: + logger.info("✅ Колонки пользовательских промо-скидок готовы") + else: + logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки") + + effect_types_updated = await migrate_discount_offer_effect_types() + if effect_types_updated: + logger.info("✅ Типы эффектов промо-предложений обновлены") + else: + logger.warning("⚠️ Не удалось обновить типы эффектов промо-предложений") + + bonuses_reset = await reset_discount_offer_bonuses() + if bonuses_reset: + logger.info("✅ Бонусные начисления промо-предложений отключены") + else: + logger.warning("⚠️ Не удалось обнулить бонусы промо-предложений") + logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===") promo_templates_created = await create_promo_offer_templates_table() if promo_templates_created: @@ -2647,6 +2735,8 @@ async def check_migration_status(): "promo_groups_addon_discount_column": False, "users_auto_promo_group_assigned_column": False, "users_auto_promo_group_threshold_column": False, + "users_promo_offer_discount_percent_column": False, + "users_promo_offer_discount_source_column": False, "subscription_crypto_link_column": False, "discount_offers_table": False, "discount_offers_effect_column": False, @@ -2677,6 +2767,8 @@ async def check_migration_status(): status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons') status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned') status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks') + status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent') + status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source') status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') media_fields_exist = ( @@ -2716,7 +2808,14 @@ async def check_migration_status(): "promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп", "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей", + "users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей", + "users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", + "discount_offers_table": "Таблица discount_offers", + "discount_offers_effect_column": "Колонка effect_type в discount_offers", + "discount_offers_extra_column": "Колонка extra_data в discount_offers", + "promo_offer_templates_table": "Таблица promo_offer_templates", + "subscription_temporary_access_table": "Таблица subscription_temporary_access", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 528288b4..01511b76 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -226,18 +226,16 @@ def _build_notification_preview_message(language: str, notification_type: str): elif notification_type == "expired_2d": percent = NotificationSettingsService.get_second_wave_discount_percent() valid_hours = NotificationSettingsService.get_second_wave_valid_hours() - bonus_amount = settings.PRICE_30_DAYS * percent // 100 template = texts.get( "SUBSCRIPTION_EXPIRED_SECOND_WAVE", ( "🔥 Скидка {percent}% на продление\n\n" - "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " - "Предложение действует до {expires_at}." + "Активируйте предложение, чтобы получить дополнительную скидку. " + "Она суммируется с вашей промогруппой и действует до {expires_at}." ), ) message = template.format( percent=percent, - bonus=settings.format_price(bonus_amount), expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), trigger_days=3, ) @@ -273,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str): percent = NotificationSettingsService.get_third_wave_discount_percent() valid_hours = NotificationSettingsService.get_third_wave_valid_hours() trigger_days = NotificationSettingsService.get_third_wave_trigger_days() - bonus_amount = settings.PRICE_30_DAYS * percent // 100 template = texts.get( "SUBSCRIPTION_EXPIRED_THIRD_WAVE", ( "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " - "Скидка действует до {expires_at}." + "Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. " + "Она суммируется с промогруппой и действует до {expires_at}." ), ) message = template.format( percent=percent, - bonus=settings.format_price(bonus_amount), trigger_days=trigger_days, expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"), ) diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 249b3969..bac92821 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -10,7 +10,6 @@ from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message from sqlalchemy.ext.asyncio import AsyncSession -from app.config import settings from app.database.crud.discount_offer import upsert_discount_offer from app.database.crud.promo_offer_template import ( ensure_default_templates, @@ -45,7 +44,7 @@ OFFER_TYPE_CONFIG = { "allowed_segments": [ ("paid_active", "🟢 Активные платные"), ], - "effect_type": "balance_bonus", + "effect_type": "percent_discount", }, "purchase_discount": { "icon": "🎯", @@ -55,18 +54,13 @@ OFFER_TYPE_CONFIG = { ("paid_expired", "🔴 Истёкшие платные"), ("trial_expired", "🥶 Истёкшие триалы"), ], - "effect_type": "balance_bonus", + "effect_type": "percent_discount", }, } -def _format_bonus(template: PromoOfferTemplate) -> str: - return settings.format_price(template.bonus_amount_kopeks or 0) - - def _render_template_text(template: PromoOfferTemplate, language: str) -> str: replacements = { "discount_percent": template.discount_percent, - "bonus_amount": _format_bonus(template), "valid_hours": template.valid_hours, "test_duration_hours": template.test_duration_hours or 0, } @@ -109,9 +103,6 @@ def _build_offer_detail_keyboard(template: PromoOfferTemplate, language: str) -> if template.offer_type != "test_access": rows[-1].append(InlineKeyboardButton(text="📉 %", callback_data=f"promo_offer_edit_discount_{template.id}")) - rows.append([ - InlineKeyboardButton(text="💰 Бонус", callback_data=f"promo_offer_edit_bonus_{template.id}"), - ]) else: rows.append([ InlineKeyboardButton(text="⏳ Длительность", callback_data=f"promo_offer_edit_duration_{template.id}"), @@ -155,8 +146,18 @@ def _describe_offer(template: PromoOfferTemplate, language: str) -> str: lines.append(texts.t("ADMIN_PROMO_OFFER_VALID", "Срок действия: {hours} ч").format(hours=template.valid_hours)) if template.offer_type != "test_access": - lines.append(texts.t("ADMIN_PROMO_OFFER_DISCOUNT", "Скидка: {percent}%").format(percent=template.discount_percent)) - lines.append(texts.t("ADMIN_PROMO_OFFER_BONUS", "Бонус: {amount}").format(amount=_format_bonus(template))) + lines.append( + texts.t( + "ADMIN_PROMO_OFFER_DISCOUNT", + "Доп. скидка: {percent}% (суммируется с промогруппой)", + ).format(percent=template.discount_percent) + ) + stack_note = texts.t( + "ADMIN_PROMO_OFFER_STACKABLE_NOTE", + "Скидка применяется один раз и добавляется к промогруппе.", + ) + if stack_note: + lines.append(stack_note) else: duration = template.test_duration_hours or 0 lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_DURATION", "Доступ: {hours} ч").format(hours=duration)) @@ -266,15 +267,6 @@ async def prompt_edit_discount(callback: CallbackQuery, db_user: User, db: Async await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_discount) -@admin_required -@error_handler -async def prompt_edit_bonus(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): - template_id = int(callback.data.split("_")[-1]) - texts = get_texts(db_user.language) - prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_BONUS", "Введите размер бонуса в копейках:") - await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_bonus) - - @admin_required @error_handler async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext): @@ -328,9 +320,6 @@ async def _handle_edit_field( elif field == "discount_percent": percent = max(0, min(100, int(value))) await update_promo_offer_template(db, template, discount_percent=percent) - elif field == "bonus_amount_kopeks": - bonus = max(0, int(value)) - await update_promo_offer_template(db, template, bonus_amount_kopeks=bonus) elif field == "test_duration_hours": hours = max(1, int(value)) await update_promo_offer_template(db, template, test_duration_hours=hours) @@ -424,7 +413,7 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn sent = 0 failed = 0 - effect_type = config.get("effect_type", "balance_bonus") + effect_type = config.get("effect_type", "percent_discount") for user in users: try: @@ -434,7 +423,7 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn subscription_id=user.subscription.id if user.subscription else None, notification_type=f"promo_template_{template.id}", discount_percent=template.discount_percent, - bonus_amount_kopeks=template.bonus_amount_kopeks, + bonus_amount_kopeks=0, valid_hours=template.valid_hours, effect_type=effect_type, extra_data={ @@ -495,10 +484,6 @@ async def process_edit_discount_percent(message: Message, state: FSMContext, db: await _handle_edit_field(message, state, db, db_user, "discount_percent") -async def process_edit_bonus_amount(message: Message, state: FSMContext, db: AsyncSession, db_user: User): - await _handle_edit_field(message, state, db, db_user, "bonus_amount_kopeks") - - async def process_edit_test_duration(message: Message, state: FSMContext, db: AsyncSession, db_user: User): await _handle_edit_field(message, state, db, db_user, "test_duration_hours") @@ -513,7 +498,6 @@ def register_handlers(dp: Dispatcher): dp.callback_query.register(prompt_edit_button, F.data.startswith("promo_offer_edit_button_")) dp.callback_query.register(prompt_edit_valid, F.data.startswith("promo_offer_edit_valid_")) dp.callback_query.register(prompt_edit_discount, F.data.startswith("promo_offer_edit_discount_")) - dp.callback_query.register(prompt_edit_bonus, F.data.startswith("promo_offer_edit_bonus_")) dp.callback_query.register(prompt_edit_duration, F.data.startswith("promo_offer_edit_duration_")) dp.callback_query.register(prompt_edit_squads, F.data.startswith("promo_offer_edit_squads_")) dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_")) @@ -524,6 +508,5 @@ def register_handlers(dp: Dispatcher): dp.message.register(process_edit_button_text, AdminStates.editing_promo_offer_button) dp.message.register(process_edit_valid_hours, AdminStates.editing_promo_offer_valid_hours) dp.message.register(process_edit_discount_percent, AdminStates.editing_promo_offer_discount) - dp.message.register(process_edit_bonus_amount, AdminStates.editing_promo_offer_bonus) dp.message.register(process_edit_test_duration, AdminStates.editing_promo_offer_test_duration) dp.message.register(process_edit_test_squads, AdminStates.editing_promo_offer_squads) diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index e8181f78..ec511aac 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -16,7 +16,7 @@ from app.database.crud.subscription import ( update_subscription_autopay ) from app.database.crud.transaction import create_transaction -from app.database.crud.user import subtract_user_balance, add_user_balance +from app.database.crud.user import subtract_user_balance from app.database.models import ( User, TransactionType, SubscriptionStatus, Subscription @@ -111,6 +111,40 @@ def _apply_addon_discount( } +def _get_promo_offer_discount_percent(user: Optional[User]) -> int: + if not user: + return 0 + + try: + percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + except (TypeError, ValueError): + return 0 + + return max(0, min(100, percent)) + + +def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]: + percent = _get_promo_offer_discount_percent(user) + + if amount <= 0 or percent <= 0: + return {"discounted": amount, "discount": 0, "percent": 0} + + discounted, discount_value = apply_percentage_discount(amount, percent) + return {"discounted": discounted, "discount": discount_value, "percent": percent} + + +async def _consume_promo_offer_discount(db: AsyncSession, user: User) -> None: + if _get_promo_offer_discount_percent(user) <= 0: + return + + user.promo_offer_discount_percent = 0 + user.promo_offer_discount_source = None + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + + def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]: if not subscription: return None @@ -246,7 +280,20 @@ async def _prepare_subscription_summary( if not is_valid: raise ValueError("Subscription price calculation validation failed") + original_total_price = total_price + promo_offer_component = _apply_promo_offer_discount(db_user, total_price) + if promo_offer_component["discount"] > 0: + total_price = promo_offer_component["discounted"] + summary_data['total_price'] = total_price + if promo_offer_component["discount"] > 0: + summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"] + summary_data['promo_offer_discount_value'] = promo_offer_component["discount"] + summary_data['total_price_before_promo_offer'] = original_total_price + else: + summary_data.pop('promo_offer_discount_percent', None) + summary_data.pop('promo_offer_discount_value', None) + summary_data.pop('total_price_before_promo_offer', None) summary_data['server_prices_for_period'] = selected_server_prices summary_data['months_in_period'] = months_in_period summary_data['base_price'] = base_price @@ -330,6 +377,17 @@ async def _prepare_subscription_summary( ) details_lines.append(devices_line) + if promo_offer_component["discount"] > 0: + details_lines.append( + texts.t( + "SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT", + "- Промо-предложение: -{amount} ({percent}% дополнительно)", + ).format( + amount=texts.format_price(promo_offer_component["discount"]), + percent=promo_offer_component["percent"], + ) + ) + details_text = "\n".join(details_lines) summary_text = ( @@ -2374,6 +2432,7 @@ async def handle_extend_subscription( available_periods = settings.get_available_renewal_periods() renewal_prices = {} + promo_offer_percent = _get_promo_offer_discount_percent(db_user) for days in available_periods: try: @@ -2419,7 +2478,8 @@ async def handle_extend_subscription( total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period price = base_price + total_servers_price + total_devices_price + total_traffic_price - renewal_prices[days] = price + promo_component = _apply_promo_offer_discount(db_user, price) + renewal_prices[days] = promo_component["discounted"] except Exception as e: logger.error(f"Ошибка расчета цены для периода {days}: {e}") @@ -2456,6 +2516,15 @@ async def handle_extend_subscription( if promo_discounts_text: message_text += f"{promo_discounts_text}\n\n" + if promo_offer_percent > 0: + message_text += ( + texts.t( + "SUBSCRIPTION_PROMO_DISCOUNT_HINT", + "⚡ Доп. скидка {percent}% активирована и будет применена автоматически. Суммируется с промогруппой.", + ).format(percent=promo_offer_percent) + + "\n\n" + ) + message_text += "💡 Цена включает все ваши текущие серверы и настройки" await callback.message.edit_text( @@ -2720,13 +2789,17 @@ async def confirm_extend_subscription( total_traffic_price = discounted_traffic_price_per_month * months_in_period price = base_price + total_servers_price + total_devices_price + total_traffic_price + original_price = price + promo_component = _apply_promo_offer_discount(db_user, price) + if promo_component["discount"] > 0: + price = promo_component["discounted"] monthly_additions = ( discounted_servers_price_per_month + discounted_devices_price_per_month + discounted_traffic_price_per_month ) - is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price) + is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price) if not is_valid: logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}") @@ -2774,6 +2847,12 @@ async def confirm_extend_subscription( else "" ) ) + if promo_component["discount"] > 0: + logger.info( + " 🎯 Промо-предложение: -%s₽ (%s%%)", + promo_component["discount"] / 100, + promo_component["percent"], + ) logger.info(f" 💎 ИТОГО: {price / 100}₽") except Exception as e: @@ -2904,6 +2983,15 @@ async def confirm_extend_subscription( f"💰 Списано: {texts.format_price(price)}" ) + if promo_component["discount"] > 0: + success_message += ( + f" (включая доп. скидку {promo_component['percent']}%:" + f" -{texts.format_price(promo_component['discount'])})" + ) + + if promo_component["discount"] > 0: + await _consume_promo_offer_discount(db, db_user) + await callback.message.edit_text( success_message, reply_markup=get_back_keyboard(db_user.language) @@ -3550,7 +3638,26 @@ async def confirm_purchase( total_servers_price = data.get('total_servers_price', total_countries_price) - final_price = data['total_price'] + cached_total_price = data['total_price'] + cached_promo_discount_value = data.get('promo_offer_discount_value', 0) + + validation_total_price = data.get('total_price_before_promo_offer') + if validation_total_price is None and cached_promo_discount_value > 0: + validation_total_price = cached_total_price + cached_promo_discount_value + if validation_total_price is None: + validation_total_price = cached_total_price + + current_promo_offer_percent = _get_promo_offer_discount_percent(db_user) + if current_promo_offer_percent > 0: + final_price, promo_offer_discount_value = apply_percentage_discount( + validation_total_price, + current_promo_offer_percent, + ) + promo_offer_discount_percent = current_promo_offer_percent + else: + final_price = validation_total_price + promo_offer_discount_value = 0 + promo_offer_discount_percent = 0 discounted_monthly_additions = data.get( 'discounted_monthly_additions', @@ -3563,7 +3670,7 @@ async def confirm_purchase( base_price, discounted_monthly_additions, months_in_period, - final_price, + validation_total_price, ) if not is_valid: @@ -3612,6 +3719,12 @@ async def confirm_purchase( f" -{devices_discount_total / 100}₽)" ) logger.info(message) + if promo_offer_discount_value > 0: + logger.info( + " 🎯 Промо-предложение: -%s₽ (%s%%)", + promo_offer_discount_value / 100, + promo_offer_discount_percent, + ) logger.info(f" ИТОГО: {final_price / 100}₽") if db_user.balance_kopeks < final_price: @@ -3811,6 +3924,16 @@ async def confirm_purchase( subscription_link = get_display_subscription_link(subscription) hide_subscription_link = settings.should_hide_subscription_link() + discount_note = "" + if promo_offer_discount_value > 0: + discount_note = texts.t( + "SUBSCRIPTION_PROMO_DISCOUNT_NOTE", + "⚡ Доп. скидка {percent}%: -{amount}", + ).format( + percent=promo_offer_discount_percent, + amount=texts.format_price(promo_offer_discount_value), + ) + if remnawave_user and subscription_link: if settings.is_happ_cryptolink_mode(): success_text = ( @@ -3850,6 +3973,9 @@ async def confirm_purchase( f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" ) + if discount_note: + success_text = f"{success_text}\n\n{discount_note}" + connect_mode = settings.CONNECT_BUTTON_MODE if connect_mode == "miniapp_subscription": @@ -3917,17 +4043,26 @@ async def confirm_purchase( callback_data="back_to_menu")], ]) + if promo_offer_discount_value > 0: + await _consume_promo_offer_discount(db, db_user) + await callback.message.edit_text( success_text, reply_markup=connect_keyboard, parse_mode="HTML" ) else: + purchase_text = texts.SUBSCRIPTION_PURCHASED + if discount_note: + purchase_text = f"{purchase_text}\n\n{discount_note}" + if promo_offer_discount_value > 0: + await _consume_promo_offer_discount(db, db_user) + await callback.message.edit_text( texts.t( "SUBSCRIPTION_LINK_GENERATING_NOTICE", "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", - ).format(purchase_text=texts.SUBSCRIPTION_PURCHASED), + ).format(purchase_text=purchase_text), reply_markup=get_back_keyboard(db_user.language) ) @@ -5053,7 +5188,9 @@ async def claim_discount_offer( ) return - effect_type = (offer.effect_type or "balance_bonus").lower() + effect_type = (offer.effect_type or "percent_discount").lower() + if effect_type == "balance_bonus": + effect_type = "percent_discount" if effect_type == "test_access": success, added_squads, expires_at, error_code = await promo_offer_service.grant_test_access( @@ -5094,30 +5231,25 @@ async def claim_discount_offer( await callback.message.answer(success_message) 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", "Скидка за продление подписки"), + discount_percent = int(offer.discount_percent or 0) + if discount_percent <= 0: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось активировать скидку. Попробуйте позже."), + show_alert=True, ) - if not success: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), - show_alert=True, - ) - return + return + + db_user.promo_offer_discount_percent = discount_percent + db_user.promo_offer_discount_source = offer.notification_type + db_user.updated_at = now await mark_offer_claimed(db, offer) + await db.refresh(db_user) success_message = texts.get( "DISCOUNT_CLAIM_SUCCESS", - "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", - ).format( - percent=offer.discount_percent, - amount=settings.format_price(bonus_amount), - ) + "🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.", + ).format(percent=discount_percent) await callback.answer("✅ Скидка активирована!", show_alert=True) await callback.message.answer(success_message) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 84750376..4f3644dd 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -548,11 +548,14 @@ "CREATE_TICKET_BUTTON": "🎫 Create ticket", "DELETE_MESSAGE": "🗑 Delete", "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)", + "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", + "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with your promo group.", "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", - "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.", "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment.", "ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):", "LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.", "MARK_AS_ANSWERED": "✅ Mark as answered", @@ -592,8 +595,8 @@ "REPORT_CLOSE_ERROR": "❌ Failed to close the report.", "SENDING_ATTACHMENTS": "📎 Sending attachments...", "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}.", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.", "SUBSCRIPTION_EXTEND": "💎 Extend subscription", "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 4ca51d14..1c7bf566 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -548,11 +548,14 @@ "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "DELETE_MESSAGE": "🗑 Удалить", "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)", + "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", + "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Доп. скидка {percent}% активирована и будет применена автоматически. Суммируется с промогруппой.", "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", - "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.", "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.", "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", "LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.", "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", @@ -592,8 +595,8 @@ "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", "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}.", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.", "SUBSCRIPTION_EXTEND": "💎 Продлить подписку", "SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "
{crypto_link}
", "SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.", diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index 584e03fc..e0cc2968 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -41,6 +41,7 @@ from app.services.notification_settings_service import NotificationSettingsServi from app.services.payment_service import PaymentService from app.services.subscription_service import SubscriptionService from app.services.promo_offer_service import promo_offer_service +from app.utils.pricing_utils import apply_percentage_discount from app.external.remnawave_api import ( RemnaWaveAPIError, @@ -681,15 +682,15 @@ class MonitoringService: 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, + bonus_amount_kopeks=0, valid_hours=valid_hours, + effect_type="percent_discount", ) success = await self._send_expired_discount_notification( user, @@ -698,7 +699,6 @@ class MonitoringService: offer.expires_at, offer.id, "second", - bonus_amount, ) if success: await record_notification(db, user.id, subscription.id, "expired_discount_wave2") @@ -711,15 +711,15 @@ class MonitoringService: 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, + bonus_amount_kopeks=0, valid_hours=valid_hours, + effect_type="percent_discount", ) success = await self._send_expired_discount_notification( user, @@ -728,7 +728,6 @@ class MonitoringService: offer.expires_at, offer.id, "third", - bonus_amount, trigger_days=trigger_days, ) if success: @@ -782,6 +781,30 @@ class MonitoringService: return subscriptions + @staticmethod + def _get_user_promo_offer_discount_percent(user: Optional[User]) -> int: + if not user: + return 0 + + try: + percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0) + except (TypeError, ValueError): + return 0 + + return max(0, min(100, percent)) + + @staticmethod + async def _consume_user_promo_offer_discount(db: AsyncSession, user: User) -> None: + if MonitoringService._get_user_promo_offer_discount_percent(user) <= 0: + return + + user.promo_offer_discount_percent = 0 + user.promo_offer_discount_source = None + user.updated_at = datetime.utcnow() + + await db.commit() + await db.refresh(user) + async def _process_autopayments(self, db: AsyncSession): try: current_time = datetime.utcnow() @@ -814,17 +837,26 @@ class MonitoringService: continue renewal_cost = settings.PRICE_30_DAYS - + promo_discount_percent = self._get_user_promo_offer_discount_percent(user) + charge_amount = renewal_cost + promo_discount_value = 0 + + if renewal_cost > 0 and promo_discount_percent > 0: + charge_amount, promo_discount_value = apply_percentage_discount( + renewal_cost, + promo_discount_percent, + ) + autopay_key = f"autopay_{user.telegram_id}_{subscription.id}" if autopay_key in self._notified_users: continue - - if user.balance_kopeks >= renewal_cost: + + if user.balance_kopeks >= charge_amount: success = await subtract_user_balance( - db, user, renewal_cost, + db, user, charge_amount, "Автопродление подписки" ) - + if success: await extend_subscription(db, subscription, 30) await self.subscription_service.update_remnawave_user( @@ -833,22 +865,30 @@ class MonitoringService: reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, reset_reason="автопродление подписки", ) - + + if promo_discount_value > 0: + await self._consume_user_promo_offer_discount(db, user) + if self.bot: - await self._send_autopay_success_notification(user, renewal_cost, 30) - + await self._send_autopay_success_notification(user, charge_amount, 30) + processed_count += 1 self._notified_users.add(autopay_key) - logger.info(f"💳 Автопродление подписки пользователя {user.telegram_id} успешно") + logger.info( + "💳 Автопродление подписки пользователя %s успешно (списано %s, скидка %s%%)", + user.telegram_id, + charge_amount, + promo_discount_percent, + ) else: failed_count += 1 if self.bot: - await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost) + await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount) logger.warning(f"💳 Ошибка списания средств для автопродления пользователя {user.telegram_id}") else: failed_count += 1 if self.bot: - await self._send_autopay_failed_notification(user, user.balance_kopeks, renewal_cost) + await self._send_autopay_failed_notification(user, user.balance_kopeks, charge_amount) logger.warning(f"💳 Недостаточно средств для автопродления у пользователя {user.telegram_id}") if processed_count > 0 or failed_count > 0: @@ -1191,7 +1231,6 @@ class MonitoringService: expires_at: datetime, offer_id: int, wave: str, - bonus_amount: int, trigger_days: int = None, ) -> bool: try: @@ -1202,8 +1241,8 @@ class MonitoringService: "SUBSCRIPTION_EXPIRED_SECOND_WAVE", ( "🔥 Скидка {percent}% на продление\n\n" - "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " - "Предложение действует до {expires_at}." + "Активируйте предложение, чтобы получить дополнительную скидку. " + "Она суммируется с вашей промогруппой и действует до {expires_at}." ), ) else: @@ -1211,14 +1250,13 @@ class MonitoringService: "SUBSCRIPTION_EXPIRED_THIRD_WAVE", ( "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " - "Скидка действует до {expires_at}." + "Прошло {trigger_days} дней без подписки — возвращайтесь и активируйте дополнительную скидку. " + "Она суммируется с промогруппой и действует до {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 "", ) diff --git a/app/states.py b/app/states.py index 767d20f6..4bee07ca 100644 --- a/app/states.py +++ b/app/states.py @@ -110,7 +110,6 @@ class AdminStates(StatesGroup): editing_promo_offer_button = State() editing_promo_offer_valid_hours = State() editing_promo_offer_discount = State() - editing_promo_offer_bonus = State() editing_promo_offer_test_duration = State() editing_promo_offer_squads = State() diff --git a/locales/en.json b/locales/en.json index f024ad95..817b8091 100644 --- a/locales/en.json +++ b/locales/en.json @@ -527,19 +527,22 @@ "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.", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 {percent}% discount on renewal\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Personal {percent}% discount\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It stacks with your promo group and will apply automatically to your next payment.", "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_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.", "TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.", "TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.", "TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.", "TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.", "TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!", "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", + "SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)", + "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}", + "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with your promo group.", "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):", @@ -569,8 +572,8 @@ "ADMIN_PROMO_OFFER_PURCHASE": "Purchase discount", "ADMIN_PROMO_OFFER_TYPE": "Type: {label}", "ADMIN_PROMO_OFFER_VALID": "Validity: {hours} h", - "ADMIN_PROMO_OFFER_DISCOUNT": "Discount: {percent}%", - "ADMIN_PROMO_OFFER_BONUS": "Bonus: {amount}", + "ADMIN_PROMO_OFFER_DISCOUNT": "Extra discount: {percent}% (stacks with promo group)", + "ADMIN_PROMO_OFFER_STACKABLE_NOTE": "The discount applies once and stacks with the promo group.", "ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h", "ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}", "ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified", @@ -580,7 +583,6 @@ "ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Enter the button label:", "ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):", "ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:", - "ADMIN_PROMO_OFFER_PROMPT_BONUS": "Enter bonus amount in kopeks:", "ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):", "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "List squad UUIDs separated by commas or spaces. Send 'clear' to reset:", "ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...", diff --git a/locales/ru.json b/locales/ru.json index f4d63055..be4b4c7b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -527,19 +527,22 @@ "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}.", + "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.", + "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она суммируется с промогруппой и автоматически применится при следующей оплате.", "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.", "TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.", "TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.", "TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.", "TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.", "TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!", "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", + "SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)", + "SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}", + "SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Доп. скидка {percent}% активирована и будет применена автоматически. Суммируется с промогруппой.", "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", @@ -569,8 +572,8 @@ "ADMIN_PROMO_OFFER_PURCHASE": "Скидка на покупку", "ADMIN_PROMO_OFFER_TYPE": "Тип: {label}", "ADMIN_PROMO_OFFER_VALID": "Срок действия: {hours} ч", - "ADMIN_PROMO_OFFER_DISCOUNT": "Скидка: {percent}%", - "ADMIN_PROMO_OFFER_BONUS": "Бонус: {amount}", + "ADMIN_PROMO_OFFER_DISCOUNT": "Доп. скидка: {percent}% (суммируется с промогруппой)", + "ADMIN_PROMO_OFFER_STACKABLE_NOTE": "Скидка применяется один раз и добавляется к промогруппе.", "ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч", "ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}", "ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны", @@ -580,7 +583,6 @@ "ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Введите новый текст кнопки:", "ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):", "ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:", - "ADMIN_PROMO_OFFER_PROMPT_BONUS": "Введите размер бонуса в копейках:", "ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):", "ADMIN_PROMO_OFFER_PROMPT_SQUADS": "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':", "ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",