From 092ea034ecd7b547628f640949fbf2700222e1d2 Mon Sep 17 00:00:00 2001 From: Egor Date: Sat, 4 Oct 2025 11:30:57 +0300 Subject: [PATCH] Revert "Implement stackable percent-based promo offers" --- app/database/crud/discount_offer.py | 53 +---- app/database/crud/promo_offer_template.py | 8 +- app/database/models.py | 2 +- app/database/universal_migration.py | 58 +---- app/handlers/admin/promo_offers.py | 41 +++- app/handlers/subscription.py | 276 +++------------------- app/localization/locales/en.json | 12 +- app/localization/locales/ru.json | 12 +- app/services/monitoring_service.py | 16 +- app/states.py | 1 + locales/en.json | 19 +- locales/ru.json | 19 +- 12 files changed, 113 insertions(+), 404 deletions(-) diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index a7ef5f08..0ae6a068 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -14,9 +14,9 @@ async def upsert_discount_offer( subscription_id: Optional[int], notification_type: str, discount_percent: int, - bonus_amount_kopeks: int = 0, + bonus_amount_kopeks: int, valid_hours: int, - effect_type: str = "percent_discount", + effect_type: str = "balance_bonus", extra_data: Optional[dict] = None, ) -> DiscountOffer: """Create or refresh a discount offer for a user.""" @@ -67,6 +67,14 @@ async def get_offer_by_id(db: AsyncSession, offer_id: int) -> Optional[DiscountO 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( @@ -86,44 +94,3 @@ async def deactivate_expired_offers(db: AsyncSession) -> int: await db.commit() return count - - -async def get_active_percent_discount_offer( - db: AsyncSession, - user_id: int, -) -> Optional[DiscountOffer]: - now = datetime.utcnow() - result = await db.execute( - select(DiscountOffer) - .where( - DiscountOffer.user_id == user_id, - DiscountOffer.is_active == True, # noqa: E712 - DiscountOffer.effect_type.in_(["percent_discount", "balance_bonus"]), - DiscountOffer.claimed_at.isnot(None), - DiscountOffer.expires_at > now, - DiscountOffer.discount_percent > 0, - ) - .order_by(DiscountOffer.expires_at.asc()) - ) - return result.scalars().first() - - -async def mark_offer_claimed( - db: AsyncSession, - offer: DiscountOffer, - *, - deactivate: bool = True, -) -> DiscountOffer: - offer.claimed_at = datetime.utcnow() - if deactivate: - offer.is_active = False - await db.commit() - await db.refresh(offer) - return offer - - -async def consume_discount_offer(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: - offer.is_active = False - await db.commit() - await db.refresh(offer) - return offer diff --git a/app/database/crud/promo_offer_template.py b/app/database/crud/promo_offer_template.py index 714c31cc..cb99770b 100644 --- a/app/database/crud/promo_offer_template.py +++ b/app/database/crud/promo_offer_template.py @@ -31,13 +31,13 @@ DEFAULT_TEMPLATES: tuple[dict, ...] = ( "name": "Скидка на продление", "message_text": ( "💎 Экономия {discount_percent}% при продлении\n\n" - "Активируйте предложение — скидка автоматически уменьшит стоимость следующей оплаты.\n" + "Мы начислим {bonus_amount} на баланс после активации, чтобы продление обошлось дешевле.\n" "Срок действия предложения — {valid_hours} ч." ), "button_text": "🎁 Получить скидку", "valid_hours": 24, "discount_percent": 20, - "bonus_amount_kopeks": 0, + "bonus_amount_kopeks": settings.PRICE_30_DAYS * 20 // 100, "test_duration_hours": None, "test_squad_uuids": [], }, @@ -46,13 +46,13 @@ DEFAULT_TEMPLATES: tuple[dict, ...] = ( "name": "Скидка на покупку", "message_text": ( "🎯 Вернитесь со скидкой {discount_percent}%\n\n" - "Скидка суммируется с вашей промо-группой и уменьшит стоимость новой подписки.\n" + "Начислим {bonus_amount} после активации — используйте бонус при оплате новой подписки.\n" "Предложение действует {valid_hours} ч." ), "button_text": "🎁 Забрать скидку", "valid_hours": 48, "discount_percent": 25, - "bonus_amount_kopeks": 0, + "bonus_amount_kopeks": settings.PRICE_30_DAYS * 25 // 100, "test_duration_hours": None, "test_squad_uuids": [], }, diff --git a/app/database/models.py b/app/database/models.py index eeb08dd2..6de613e4 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -812,7 +812,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="percent_discount") + effect_type = Column(String(50), nullable=False, default="balance_bonus") 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 0527035f..4d5dd201 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 'percent_discount', + effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus', 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 'percent_discount', + effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus', 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 'percent_discount', + effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus', 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 'percent_discount'" + "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'" )) elif db_type == 'postgresql': await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" + "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'" )) elif db_type == 'mysql': await conn.execute(text( - "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'" + "ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'" )) else: raise ValueError(f"Unsupported database type: {db_type}") @@ -851,37 +851,6 @@ async def ensure_discount_offer_columns(): return False -async def update_discount_offer_effect_defaults() -> bool: - try: - effect_exists = await check_column_exists('discount_offers', 'effect_type') - if not effect_exists: - return True - - async with engine.begin() as conn: - db_type = await get_database_type() - - if db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE discount_offers ALTER COLUMN effect_type SET DEFAULT 'percent_discount'" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE discount_offers ALTER COLUMN effect_type SET DEFAULT 'percent_discount'" - )) - # SQLite requires table rebuild to change defaults; skip altering default but update rows - - await conn.execute(text( - "UPDATE discount_offers SET effect_type = 'percent_discount' WHERE effect_type = 'balance_bonus'" - )) - - logger.info("✅ Значение по умолчанию effect_type для discount_offers обновлено") - return True - - except Exception as e: - logger.error(f"Ошибка обновления значения по умолчанию effect_type: {e}") - return False - - async def create_promo_offer_templates_table(): table_exists = await check_table_exists('promo_offer_templates') if table_exists: @@ -2464,12 +2433,6 @@ async def run_universal_migration(): else: logger.warning("⚠️ Не удалось обновить колонки discount_offers") - discount_defaults_ready = await update_discount_offer_effect_defaults() - if discount_defaults_ready: - logger.info("✅ Значение по умолчанию скидочного эффекта обновлено") - else: - logger.warning("⚠️ Не удалось обновить значение по умолчанию скидочного эффекта") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===") promo_templates_created = await create_promo_offer_templates_table() if promo_templates_created: @@ -2688,7 +2651,6 @@ async def check_migration_status(): "discount_offers_table": False, "discount_offers_effect_column": False, "discount_offers_extra_column": False, - "discount_offers_effect_default": False, "promo_offer_templates_table": False, "subscription_temporary_access_table": False, } @@ -2708,14 +2670,6 @@ async def check_migration_status(): status["promo_offer_templates_table"] = await check_table_exists('promo_offer_templates') status["subscription_temporary_access_table"] = await check_table_exists('subscription_temporary_access') - if status["discount_offers_effect_column"]: - async with engine.begin() as conn: - result = await conn.execute(text( - "SELECT COUNT(*) FROM discount_offers WHERE effect_type = 'balance_bonus'" - )) - remaining_legacy = result.scalar() or 0 - status["discount_offers_effect_default"] = remaining_legacy == 0 - status["welcome_texts_is_enabled_column"] = await check_column_exists('welcome_texts', 'is_enabled') status["users_promo_group_column"] = await check_column_exists('users', 'promo_group_id') status["promo_groups_period_discounts_column"] = await check_column_exists('promo_groups', 'period_discounts') diff --git a/app/handlers/admin/promo_offers.py b/app/handlers/admin/promo_offers.py index 081c2ba8..249b3969 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -10,6 +10,7 @@ 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, @@ -44,7 +45,7 @@ OFFER_TYPE_CONFIG = { "allowed_segments": [ ("paid_active", "🟢 Активные платные"), ], - "effect_type": "percent_discount", + "effect_type": "balance_bonus", }, "purchase_discount": { "icon": "🎯", @@ -54,13 +55,18 @@ OFFER_TYPE_CONFIG = { ("paid_expired", "🔴 Истёкшие платные"), ("trial_expired", "🥶 Истёкшие триалы"), ], - "effect_type": "percent_discount", + "effect_type": "balance_bonus", }, } +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, } @@ -103,6 +109,9 @@ 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}"), @@ -147,12 +156,7 @@ def _describe_offer(template: PromoOfferTemplate, language: str) -> str: 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_STACKING", - "Скидка суммируется с промогруппой и применяется автоматически.", - ) - ) + lines.append(texts.t("ADMIN_PROMO_OFFER_BONUS", "Бонус: {amount}").format(amount=_format_bonus(template))) else: duration = template.test_duration_hours or 0 lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_DURATION", "Доступ: {hours} ч").format(hours=duration)) @@ -262,6 +266,15 @@ 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): @@ -315,6 +328,9 @@ 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) @@ -408,7 +424,7 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn sent = 0 failed = 0 - effect_type = config.get("effect_type", "percent_discount") + effect_type = config.get("effect_type", "balance_bonus") for user in users: try: @@ -418,6 +434,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, valid_hours=template.valid_hours, effect_type=effect_type, extra_data={ @@ -478,6 +495,10 @@ 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") @@ -492,6 +513,7 @@ 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_")) @@ -502,5 +524,6 @@ 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 e9ca6b12..e8181f78 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -9,12 +9,7 @@ from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings, PERIOD_PRICES, get_traffic_prices -from app.database.crud.discount_offer import ( - consume_discount_offer, - get_active_percent_discount_offer, - get_offer_by_id, - mark_offer_claimed, -) +from app.database.crud.discount_offer import get_offer_by_id, mark_offer_claimed from app.database.crud.subscription import ( create_trial_subscription, create_paid_subscription, add_subscription_traffic, add_subscription_devices, @@ -144,34 +139,10 @@ def _apply_discount_to_monthly_component( } -def _is_percent_discount_offer_active(offer: Optional["DiscountOffer"]) -> bool: - if not offer: - return False - - effect_type = (offer.effect_type or "").lower() - if effect_type not in {"percent_discount", "balance_bonus"}: - return False - - if not getattr(offer, "is_active", False): - return False - - if getattr(offer, "claimed_at", None) is None: - return False - - expires_at = getattr(offer, "expires_at", None) - if expires_at and expires_at <= datetime.utcnow(): - return False - - percent = getattr(offer, "discount_percent", 0) or 0 - return percent > 0 - - async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], texts, - db: AsyncSession, - discount_offer: Optional["DiscountOffer"] = None, ) -> Tuple[str, Dict[str, Any]]: summary_data = dict(data) countries = await _get_available_countries(db_user.promo_group_id) @@ -301,29 +272,6 @@ async def _prepare_subscription_summary( summary_data['devices_discounted_price_per_month'] = devices_component["discounted_per_month"] summary_data['total_devices_price'] = total_devices_price summary_data['discounted_monthly_additions'] = discounted_monthly_additions - summary_data['total_price_before_discount'] = total_price - summary_data['percent_discount_offer_id'] = None - summary_data['percent_discount_percent'] = 0 - summary_data['percent_discount_amount'] = 0 - - effective_discount_offer = discount_offer - if effective_discount_offer is None: - try: - effective_discount_offer = await get_active_percent_discount_offer(db, db_user.id) - except Exception: - effective_discount_offer = None - - if _is_percent_discount_offer_active(effective_discount_offer): - discount_percent = max(0, min(100, effective_discount_offer.discount_percent or 0)) - discounted_total, discount_value = apply_percentage_discount( - total_price, - discount_percent, - ) - summary_data['total_price'] = discounted_total - summary_data['percent_discount_offer_id'] = effective_discount_offer.id - summary_data['percent_discount_percent'] = discount_percent - summary_data['percent_discount_amount'] = discount_value - total_price = discounted_total if settings.is_traffic_fixed(): if final_traffic_gb == 0: @@ -384,20 +332,6 @@ async def _prepare_subscription_summary( details_text = "\n".join(details_lines) - final_price = summary_data['total_price'] - discount_amount_total = summary_data.get('percent_discount_amount', 0) or 0 - discount_percent_total = summary_data.get('percent_discount_percent', 0) or 0 - - discount_line = "" - if discount_amount_total > 0 and discount_percent_total > 0: - discount_line = texts.t( - "SUBSCRIPTION_PERSONAL_DISCOUNT", - "🎁 Персональная скидка: {percent}% (-{amount})", - ).format( - percent=discount_percent_total, - amount=texts.format_price(discount_amount_total), - ) - summary_text = ( "📋 Сводка заказа\n\n" f"📅 Период: {period_display}\n" @@ -406,14 +340,8 @@ async def _prepare_subscription_summary( f"📱 Устройства: {devices_selected}\n\n" "💰 Детализация стоимости:\n" f"{details_text}\n\n" - ) - - if discount_line: - summary_text += f"{discount_line}\n" - - summary_text += ( - f"💎 Общая стоимость: {texts.format_price(final_price)}\n\n" - + texts.t("SUBSCRIPTION_PURCHASE_CONFIRM", "Подтверждаете покупку?") + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" ) return summary_text, summary_data @@ -2445,13 +2373,7 @@ async def handle_extend_subscription( subscription_service = SubscriptionService() available_periods = settings.get_available_renewal_periods() - renewal_prices: Dict[int, int] = {} - renewal_discount_amounts: Dict[int, int] = {} - - active_discount_offer = await get_active_percent_discount_offer(db, db_user.id) - applied_discount_percent = 0 - if _is_percent_discount_offer_active(active_discount_offer): - applied_discount_percent = max(0, min(100, active_discount_offer.discount_percent or 0)) + renewal_prices = {} for days in available_periods: try: @@ -2497,17 +2419,7 @@ 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 - - if applied_discount_percent > 0: - discounted_price, discount_value = apply_percentage_discount( - price, - applied_discount_percent, - ) - renewal_prices[days] = discounted_price - renewal_discount_amounts[days] = discount_value - else: - renewal_prices[days] = price - renewal_discount_amounts[days] = 0 + renewal_prices[days] = price except Exception as e: logger.error(f"Ошибка расчета цены для периода {days}: {e}") @@ -2522,16 +2434,7 @@ async def handle_extend_subscription( for days in available_periods: if days in renewal_prices: period_display = format_period_description(days, db_user.language) - price_value = renewal_prices[days] - line = f"📅 {period_display} - {texts.format_price(price_value)}" - if applied_discount_percent > 0: - discount_amount = renewal_discount_amounts.get(days, 0) or 0 - if discount_amount > 0: - line += ( - f" (скидка {applied_discount_percent}%:" - f" -{texts.format_price(discount_amount)})" - ) - prices_text += f"{line}\n" + prices_text += f"📅 {period_display} - {texts.format_price(renewal_prices[days])}\n" promo_discounts_text = _build_promo_group_discount_text( db_user, @@ -2555,15 +2458,6 @@ async def handle_extend_subscription( message_text += "💡 Цена включает все ваши текущие серверы и настройки" - if applied_discount_percent > 0: - message_text += ( - "\n\n" - + texts.t( - "SUBSCRIPTION_PERSONAL_DISCOUNT_HINT", - "🎁 Персональная скидка {percent}% будет применена автоматически при оплате.", - ).format(percent=applied_discount_percent) - ) - await callback.message.edit_text( message_text, reply_markup=get_extend_subscription_keyboard_with_prices(db_user.language, renewal_prices), @@ -2827,22 +2721,6 @@ async def confirm_extend_subscription( price = base_price + total_servers_price + total_devices_price + total_traffic_price - applied_discount_offer = None - applied_discount_percent = 0 - applied_discount_amount = 0 - - active_discount_offer = await get_active_percent_discount_offer(db, db_user.id) - if _is_percent_discount_offer_active(active_discount_offer): - applied_discount_percent = max(0, min(100, active_discount_offer.discount_percent or 0)) - discounted_price, discount_value = apply_percentage_discount( - price, - applied_discount_percent, - ) - if discount_value > 0: - price = discounted_price - applied_discount_offer = active_discount_offer - applied_discount_amount = discount_value - monthly_additions = ( discounted_servers_price_per_month + discounted_devices_price_per_month @@ -3026,12 +2904,6 @@ async def confirm_extend_subscription( f"💰 Списано: {texts.format_price(price)}" ) - if applied_discount_amount > 0 and applied_discount_percent > 0: - success_message += ( - f" (скидка {applied_discount_percent}%:" - f" -{texts.format_price(applied_discount_amount)})" - ) - await callback.message.edit_text( success_message, reply_markup=get_back_keyboard(db_user.language) @@ -3049,16 +2921,6 @@ async def confirm_extend_subscription( reply_markup=get_back_keyboard(db_user.language) ) - if applied_discount_offer: - try: - await consume_discount_offer(db, applied_discount_offer) - except Exception as discount_error: - logger.error( - "Не удалось деактивировать персональную скидку пользователя %s: %s", - db_user.telegram_id, - discount_error, - ) - await callback.answer() @@ -3511,7 +3373,7 @@ async def devices_continue( texts = get_texts(db_user.language) try: - summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts, db) + summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) except ValueError: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) @@ -3688,81 +3550,7 @@ async def confirm_purchase( total_servers_price = data.get('total_servers_price', total_countries_price) - final_price = data.get('total_price', 0) - original_total_price = data.get('total_price_before_discount', final_price) - stored_discount_offer_id = data.get('percent_discount_offer_id') - applied_discount_offer = None - applied_discount_percent = 0 - applied_discount_amount = 0 - - if stored_discount_offer_id: - candidate_offer = await get_offer_by_id(db, int(stored_discount_offer_id)) - if not _is_percent_discount_offer_active(candidate_offer): - data['percent_discount_offer_id'] = None - data['percent_discount_percent'] = 0 - data['percent_discount_amount'] = 0 - data['total_price'] = original_total_price - await state.set_data(data) - try: - summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts, db) - except ValueError: - await callback.answer(texts.ERROR, show_alert=True) - return - await state.set_data(prepared_data) - await callback.message.edit_text( - summary_text, - reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML", - ) - await callback.answer( - texts.t( - "SUBSCRIPTION_PERSONAL_DISCOUNT_EXPIRED", - "⚠️ Персональная скидка больше не доступна. Стоимость пересчитана.", - ), - show_alert=True, - ) - return - applied_discount_offer = candidate_offer - else: - candidate_offer = await get_active_percent_discount_offer(db, db_user.id) - if _is_percent_discount_offer_active(candidate_offer): - summary_text, prepared_data = await _prepare_subscription_summary( - db_user, - data, - texts, - db, - discount_offer=candidate_offer, - ) - await state.set_data(prepared_data) - await callback.message.edit_text( - summary_text, - reply_markup=get_subscription_confirm_keyboard(db_user.language), - parse_mode="HTML", - ) - await callback.answer( - texts.t( - "SUBSCRIPTION_PERSONAL_DISCOUNT_APPLIED", - "✅ Персональная скидка {percent}% применена. Проверьте обновлённую сумму.", - ).format(percent=candidate_offer.discount_percent), - show_alert=True, - ) - return - if applied_discount_offer: - applied_discount_percent = max(0, min(100, applied_discount_offer.discount_percent or 0)) - final_price, applied_discount_amount = apply_percentage_discount( - original_total_price, - applied_discount_percent, - ) - - discount_note = "" - if applied_discount_amount > 0 and applied_discount_percent > 0: - discount_note = texts.t( - "SUBSCRIPTION_PERSONAL_DISCOUNT_NOTE", - "🎁 Персональная скидка {percent}%: -{amount}.", - ).format( - percent=applied_discount_percent, - amount=texts.format_price(applied_discount_amount), - ) + final_price = data['total_price'] discounted_monthly_additions = data.get( 'discounted_monthly_additions', @@ -4062,9 +3850,6 @@ async def confirm_purchase( f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}" ) - if discount_note: - success_text += f"\n\n{discount_note}" - connect_mode = settings.CONNECT_BUTTON_MODE if connect_mode == "miniapp_subscription": @@ -4138,27 +3923,14 @@ async def confirm_purchase( parse_mode="HTML" ) else: - purchase_text = texts.SUBSCRIPTION_PURCHASED - if discount_note: - purchase_text = f"{purchase_text}\n\n{discount_note}" await callback.message.edit_text( texts.t( "SUBSCRIPTION_LINK_GENERATING_NOTICE", "{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.", - ).format(purchase_text=purchase_text), + ).format(purchase_text=texts.SUBSCRIPTION_PURCHASED), reply_markup=get_back_keyboard(db_user.language) ) - if applied_discount_offer: - try: - await consume_discount_offer(db, applied_discount_offer) - except Exception as discount_error: - logger.error( - "Не удалось деактивировать персональную скидку пользователя %s: %s", - db_user.telegram_id, - discount_error, - ) - purchase_completed = True logger.info( f"Пользователь {db_user.telegram_id} купил подписку на {data['period_days']} дней за {final_price / 100}₽") @@ -4181,7 +3953,6 @@ async def resume_subscription_checkout( callback: types.CallbackQuery, state: FSMContext, db_user: User, - db: AsyncSession, ): texts = get_texts(db_user.language) @@ -4192,7 +3963,7 @@ async def resume_subscription_checkout( return try: - summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts, db) + summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) except ValueError as exc: logger.error( f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" @@ -5323,25 +5094,32 @@ async def claim_discount_offer( await callback.message.answer(success_message) return - if effect_type not in {"percent_discount", "balance_bonus"}: - await callback.answer( - texts.get("DISCOUNT_CLAIM_NOT_SUPPORTED", "⚠️ Этот тип предложения пока не поддерживается."), - show_alert=True, + 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", "Скидка за продление подписки"), ) - return + if not success: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), + show_alert=True, + ) + return - await mark_offer_claimed(db, offer, deactivate=False) + await mark_offer_claimed(db, offer) - expires_text = offer.expires_at.strftime("%d.%m.%Y %H:%M") if offer.expires_at else "" success_message = texts.get( "DISCOUNT_CLAIM_SUCCESS", - "🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате и действует до {expires_at}.", + "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", ).format( percent=offer.discount_percent, - expires_at=expires_text, + amount=settings.format_price(bonus_amount), ) - await callback.answer(texts.get("DISCOUNT_CLAIM_POPUP", "✅ Скидка активирована!"), show_alert=True) + 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 9ebbb238..84750376 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -547,18 +547,12 @@ "CONTACT_SUPPORT_BUTTON": "💬 Contact support", "CREATE_TICKET_BUTTON": "🎫 Create ticket", "DELETE_MESSAGE": "🗑 Delete", + "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", "DISCOUNT_CLAIM_ALREADY": "ℹ️ This discount has already been activated.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.", "DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.", "DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.", - "DISCOUNT_CLAIM_POPUP": "✅ Discount activated!", - "DISCOUNT_CLAIM_NOT_SUPPORTED": "⚠️ This offer type is not supported yet.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment and is valid until {expires_at}.", - "SUBSCRIPTION_PERSONAL_DISCOUNT": "🎁 Personal discount: {percent}% (-{amount})", - "SUBSCRIPTION_PURCHASE_CONFIRM": "Do you confirm the purchase?", - "SUBSCRIPTION_PERSONAL_DISCOUNT_HINT": "🎁 A personal discount of {percent}% will be applied automatically at checkout.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_NOTE": "🎁 Personal discount {percent}%: -{amount}.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_APPLIED": "✅ Personal discount of {percent}% applied. Please review the updated total.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_EXPIRED": "⚠️ The personal discount is no longer available. The price has been recalculated.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.", "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", diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json index 9576e106..4ca51d14 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -547,18 +547,12 @@ "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "DELETE_MESSAGE": "🗑 Удалить", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", + "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_POPUP": "✅ Скидка активирована!", - "DISCOUNT_CLAIM_NOT_SUPPORTED": "⚠️ Этот тип предложения пока не поддерживается.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она суммируется с вашей промо-группой и действует до {expires_at}.", - "SUBSCRIPTION_PERSONAL_DISCOUNT": "🎁 Персональная скидка: {percent}% (-{amount})", - "SUBSCRIPTION_PURCHASE_CONFIRM": "Подтверждаете покупку?", - "SUBSCRIPTION_PERSONAL_DISCOUNT_HINT": "🎁 Персональная скидка {percent}% будет применена автоматически при оплате.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_NOTE": "🎁 Персональная скидка {percent}%: -{amount}.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_APPLIED": "✅ Персональная скидка {percent}% применена. Проверьте обновлённую сумму.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_EXPIRED": "⚠️ Персональная скидка больше не доступна. Стоимость пересчитана.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", "LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.", "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", diff --git a/app/services/monitoring_service.py b/app/services/monitoring_service.py index e6cb2399..584e03fc 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -681,12 +681,14 @@ 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, valid_hours=valid_hours, ) success = await self._send_expired_discount_notification( @@ -696,6 +698,7 @@ class MonitoringService: offer.expires_at, offer.id, "second", + bonus_amount, ) if success: await record_notification(db, user.id, subscription.id, "expired_discount_wave2") @@ -708,12 +711,14 @@ 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, valid_hours=valid_hours, ) success = await self._send_expired_discount_notification( @@ -723,6 +728,7 @@ class MonitoringService: offer.expires_at, offer.id, "third", + bonus_amount, trigger_days=trigger_days, ) if success: @@ -1185,6 +1191,7 @@ class MonitoringService: expires_at: datetime, offer_id: int, wave: str, + bonus_amount: int, trigger_days: int = None, ) -> bool: try: @@ -1195,8 +1202,8 @@ class MonitoringService: "SUBSCRIPTION_EXPIRED_SECOND_WAVE", ( "🔥 Скидка {percent}% на продление\n\n" - "Нажмите «Получить скидку», и мы автоматически уменьшим стоимость продления. " - "Скидка суммируется с вашей промо-группой и действует до {expires_at}." + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." ), ) else: @@ -1204,13 +1211,14 @@ class MonitoringService: "SUBSCRIPTION_EXPIRED_THIRD_WAVE", ( "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь, и скидка уменьшит стоимость новой подписки. " - "Скидка суммируется с вашей промо-группой и действует до {expires_at}." + "Прошло {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 "", ) diff --git a/app/states.py b/app/states.py index 4bee07ca..767d20f6 100644 --- a/app/states.py +++ b/app/states.py @@ -110,6 +110,7 @@ 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 d8b389e2..f024ad95 100644 --- a/locales/en.json +++ b/locales/en.json @@ -527,25 +527,19 @@ "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 the renewal price will drop automatically. The discount 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 this discount will lower the price of your new plan. It stacks with your promo group and is valid until {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment and is valid until {expires_at}.", + "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_POPUP": "✅ Discount activated!", - "DISCOUNT_CLAIM_NOT_SUPPORTED": "⚠️ This offer type is not supported yet.", + "DISCOUNT_CLAIM_ERROR": "❌ Failed to credit 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!", - "SUBSCRIPTION_PERSONAL_DISCOUNT": "🎁 Personal discount: {percent}% (-{amount})", - "SUBSCRIPTION_PURCHASE_CONFIRM": "Do you confirm the purchase?", - "SUBSCRIPTION_PERSONAL_DISCOUNT_HINT": "🎁 A personal discount of {percent}% will be applied automatically at checkout.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_NOTE": "🎁 Personal discount {percent}%: -{amount}.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_APPLIED": "✅ Personal discount of {percent}% applied. Please review the updated total.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_EXPIRED": "⚠️ The personal discount is no longer available. The price has been recalculated.", + "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):", @@ -576,7 +570,7 @@ "ADMIN_PROMO_OFFER_TYPE": "Type: {label}", "ADMIN_PROMO_OFFER_VALID": "Validity: {hours} h", "ADMIN_PROMO_OFFER_DISCOUNT": "Discount: {percent}%", - "ADMIN_PROMO_OFFER_STACKING": "Discount stacks with the promo group and is applied automatically.", + "ADMIN_PROMO_OFFER_BONUS": "Bonus: {amount}", "ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h", "ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}", "ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified", @@ -586,6 +580,7 @@ "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 36e36fe2..f4d63055 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -527,25 +527,19 @@ "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Нажмите «Получить скидку», и цена продления уменьшится автоматически. Скидка суммируется с вашей промо-группой и действует до {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки — возвращайтесь, и эта скидка уменьшит стоимость новой подписки. Скидка суммируется с вашей промо-группой и действует до {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она суммируется с вашей промо-группой и действует до {expires_at}.", + "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_POPUP": "✅ Скидка активирована!", - "DISCOUNT_CLAIM_NOT_SUPPORTED": "⚠️ Этот тип предложения пока не поддерживается.", + "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": "✅ Доступ выдан!", - "SUBSCRIPTION_PERSONAL_DISCOUNT": "🎁 Персональная скидка: {percent}% (-{amount})", - "SUBSCRIPTION_PURCHASE_CONFIRM": "Подтверждаете покупку?", - "SUBSCRIPTION_PERSONAL_DISCOUNT_HINT": "🎁 Персональная скидка {percent}% будет применена автоматически при оплате.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_NOTE": "🎁 Персональная скидка {percent}%: -{amount}.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_APPLIED": "✅ Персональная скидка {percent}% применена. Проверьте обновлённую сумму.", - "SUBSCRIPTION_PERSONAL_DISCOUNT_EXPIRED": "⚠️ Персональная скидка больше не доступна. Стоимость пересчитана.", + "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):", @@ -576,7 +570,7 @@ "ADMIN_PROMO_OFFER_TYPE": "Тип: {label}", "ADMIN_PROMO_OFFER_VALID": "Срок действия: {hours} ч", "ADMIN_PROMO_OFFER_DISCOUNT": "Скидка: {percent}%", - "ADMIN_PROMO_OFFER_STACKING": "Скидка суммируется с промогруппой и применяется автоматически.", + "ADMIN_PROMO_OFFER_BONUS": "Бонус: {amount}", "ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч", "ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}", "ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны", @@ -586,6 +580,7 @@ "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": "Начинаем рассылку...",