diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index c0bc9800..0ae6a068 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 = "percent_discount", + effect_type: str = "balance_bonus", 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 0b42574d..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" - "Активируйте предложение, и дополнительная скидка {discount_percent}% автоматически применится при оплате.\n" - "Скидка суммируется с вашей промогруппой и действует {valid_hours} ч." + "Мы начислим {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" - "Скидка {discount_percent}% автоматически применится при покупке новой подписки и суммируется с промогруппой.\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/crud/user.py b/app/database/crud/user.py index c52fb34d..147c8fd3 100644 --- a/app/database/crud/user.py +++ b/app/database/crud/user.py @@ -315,24 +315,6 @@ async def subtract_user_balance( return False -async def clear_user_pending_discount(db: AsyncSession, user: User) -> None: - try: - user.pending_discount_percent = 0 - user.pending_discount_expires_at = None - user.pending_discount_offer_id = None - user.updated_at = datetime.utcnow() - - await db.commit() - await db.refresh(user) - logger.info(f"🔄 Сброшена активная скидка для пользователя {user.id}") - except Exception as e: - logger.error(f"Ошибка сброса скидки пользователя {user.id}: {e}") - try: - await db.rollback() - except Exception as rollback_error: - logger.error(f"Ошибка отката транзакции при сбросе скидки пользователя {user.id}: {rollback_error}") - - async def get_users_list( db: AsyncSession, offset: int = 0, diff --git a/app/database/models.py b/app/database/models.py index 6377e560..6de613e4 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -396,10 +396,7 @@ class User(Base): has_made_first_topup: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) promo_group_id = Column(Integer, ForeignKey("promo_groups.id", ondelete="RESTRICT"), nullable=False, index=True) promo_group = relationship("PromoGroup", back_populates="users") - pending_discount_percent = Column(Integer, nullable=False, default=0) - pending_discount_expires_at = Column(DateTime, nullable=True) - pending_discount_offer_id = Column(Integer, ForeignKey("discount_offers.id", ondelete="SET NULL"), nullable=True) - + @property def balance_rubles(self) -> float: return self.balance_kopeks / 100 @@ -815,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 1b7e14ac..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}") @@ -843,13 +843,6 @@ async def ensure_discount_offer_columns(): else: raise ValueError(f"Unsupported database type: {db_type}") - await conn.execute( - text( - "UPDATE discount_offers SET effect_type = 'percent_discount' " - "WHERE effect_type = 'balance_bonus'" - ) - ) - logger.info("✅ Колонки effect_type и extra_data для discount_offers проверены") return True @@ -1918,64 +1911,6 @@ async def add_referral_system_columns(): logger.error(f"Ошибка миграции реферальной системы: {e}") return False - -async def ensure_user_pending_discount_columns() -> bool: - try: - percent_exists = await check_column_exists('users', 'pending_discount_percent') - expires_exists = await check_column_exists('users', 'pending_discount_expires_at') - offer_exists = await check_column_exists('users', 'pending_discount_offer_id') - - if percent_exists and expires_exists and offer_exists: - return True - - async with engine.begin() as conn: - db_type = await get_database_type() - - if not percent_exists: - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN pending_discount_percent INTEGER NOT NULL DEFAULT 0" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN pending_discount_percent INTEGER NOT NULL DEFAULT 0" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN pending_discount_percent INTEGER NOT NULL DEFAULT 0" - )) - else: - raise ValueError(f"Unsupported database type: {db_type}") - - if not expires_exists: - if db_type == 'sqlite': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN pending_discount_expires_at DATETIME NULL" - )) - elif db_type == 'postgresql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN pending_discount_expires_at TIMESTAMP NULL" - )) - elif db_type == 'mysql': - await conn.execute(text( - "ALTER TABLE users ADD COLUMN pending_discount_expires_at DATETIME NULL" - )) - else: - raise ValueError(f"Unsupported database type: {db_type}") - - if not offer_exists: - await conn.execute(text( - "ALTER TABLE users ADD COLUMN pending_discount_offer_id INTEGER NULL" - )) - - logger.info("✅ Колонки pending_discount_* для users проверены") - return True - - except Exception as e: - logger.error(f"Ошибка обновления колонок скидок пользователя: {e}") - return False - - async def create_subscription_conversions_table(): table_exists = await check_table_exists('subscription_conversions') if table_exists: @@ -2437,10 +2372,6 @@ async def run_universal_migration(): if not referral_migration_success: logger.warning("⚠️ Проблемы с миграцией реферальной системы") - pending_discount_columns_ready = await ensure_user_pending_discount_columns() - if not pending_discount_columns_ready: - logger.warning("⚠️ Не удалось обновить колонки скидок пользователя") - logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ SYSTEM_SETTINGS ===") system_settings_ready = await create_system_settings_table() if system_settings_ready: @@ -2722,9 +2653,6 @@ async def check_migration_status(): "discount_offers_extra_column": False, "promo_offer_templates_table": False, "subscription_temporary_access_table": False, - "users_pending_discount_percent_column": False, - "users_pending_discount_expires_column": False, - "users_pending_discount_offer_column": False, } status["has_made_first_topup_column"] = await check_column_exists('users', 'has_made_first_topup') @@ -2750,9 +2678,6 @@ async def check_migration_status(): 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["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link') - status["users_pending_discount_percent_column"] = await check_column_exists('users', 'pending_discount_percent') - status["users_pending_discount_expires_column"] = await check_column_exists('users', 'pending_discount_expires_at') - status["users_pending_discount_offer_column"] = await check_column_exists('users', 'pending_discount_offer_id') media_fields_exist = ( await check_column_exists('broadcast_history', 'has_media') and @@ -2792,9 +2717,6 @@ async def check_migration_status(): "users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей", "users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей", "subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions", - "users_pending_discount_percent_column": "Колонка pending_discount_percent у пользователей", - "users_pending_discount_expires_column": "Колонка pending_discount_expires_at у пользователей", - "users_pending_discount_offer_column": "Колонка pending_discount_offer_id у пользователей", } for check_key, check_status in status.items(): diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 35ad4e97..528288b4 100644 --- a/app/handlers/admin/monitoring.py +++ b/app/handlers/admin/monitoring.py @@ -226,16 +226,18 @@ 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" - "Активируйте предложение — дополнительная скидка {percent}% автоматически применится при оплате. " - "Скидка суммируется с вашей промогруппой и действует до {expires_at}." + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {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, ) @@ -271,16 +273,18 @@ 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} дней без подписки — возвращайтесь, и скидка {percent}% автоматически применится при оплате. " - "Скидка суммируется с вашей промогруппой и действует до {expires_at}." + "Прошло {trigger_days} дней без подписки — возвращайтесь, и мы добавим {bonus} на баланс. " + "Скидка действует до {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 9a0df901..249b3969 100644 --- a/app/handlers/admin/promo_offers.py +++ b/app/handlers/admin/promo_offers.py @@ -45,7 +45,7 @@ OFFER_TYPE_CONFIG = { "allowed_segments": [ ("paid_active", "🟢 Активные платные"), ], - "effect_type": "percent_discount", + "effect_type": "balance_bonus", }, "purchase_discount": { "icon": "🎯", @@ -55,7 +55,7 @@ OFFER_TYPE_CONFIG = { ("paid_expired", "🔴 Истёкшие платные"), ("trial_expired", "🥶 Истёкшие триалы"), ], - "effect_type": "percent_discount", + "effect_type": "balance_bonus", }, } @@ -109,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}"), @@ -153,6 +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_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)) @@ -420,22 +424,17 @@ 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: - bonus_amount = ( - template.bonus_amount_kopeks - if effect_type not in {"percent_discount"} - else 0 - ) offer_record = await upsert_discount_offer( db, user_id=user.id, 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=bonus_amount, + bonus_amount_kopeks=template.bonus_amount_kopeks, valid_hours=template.valid_hours, effect_type=effect_type, extra_data={ diff --git a/app/handlers/subscription.py b/app/handlers/subscription.py index f6192235..e8181f78 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, clear_user_pending_discount +from app.database.crud.user import subtract_user_balance, add_user_balance from app.database.models import ( User, TransactionType, SubscriptionStatus, Subscription @@ -139,36 +139,10 @@ def _apply_discount_to_monthly_component( } -async def _get_pending_offer_discount( - user: User, - db: Optional[AsyncSession] = None, -) -> Tuple[int, Optional[datetime]]: - percent = getattr(user, "pending_discount_percent", 0) or 0 - if percent <= 0: - return 0, None - - expires_at = getattr(user, "pending_discount_expires_at", None) - if expires_at and expires_at < datetime.utcnow(): - if db is not None: - try: - await clear_user_pending_discount(db, user) - percent = 0 - expires_at = getattr(user, "pending_discount_expires_at", None) - except Exception: - logger.exception( - "Не удалось сбросить просроченную скидку пользователя %s", - getattr(user, "id", "unknown"), - ) - return 0, expires_at - - return percent, expires_at - - async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], texts, - db: Optional[AsyncSession] = None, ) -> Tuple[str, Dict[str, Any]]: summary_data = dict(data) countries = await _get_available_countries(db_user.promo_group_id) @@ -272,22 +246,6 @@ async def _prepare_subscription_summary( if not is_valid: raise ValueError("Subscription price calculation validation failed") - original_total_price = total_price - offer_discount_percent, pending_discount_expires_at = await _get_pending_offer_discount( - db_user, - db, - ) - offer_discount_total = 0 - - if offer_discount_percent > 0: - total_price, offer_discount_total = apply_percentage_discount( - original_total_price, - offer_discount_percent, - ) - - summary_data['total_price_before_offer'] = original_total_price - summary_data['pending_discount_percent'] = offer_discount_percent - summary_data['pending_discount_total'] = offer_discount_total summary_data['total_price'] = total_price summary_data['server_prices_for_period'] = selected_server_prices summary_data['months_in_period'] = months_in_period @@ -372,28 +330,8 @@ async def _prepare_subscription_summary( ) details_lines.append(devices_line) - if offer_discount_total > 0: - discount_line = texts.t( - "SUBSCRIPTION_EXTRA_DISCOUNT_LINE", - "- Доп. скидка по предложению: -{amount} ({percent}%)", - ).format( - amount=texts.format_price(offer_discount_total), - percent=offer_discount_percent, - ) - details_lines.append(discount_line) - details_text = "\n".join(details_lines) - total_line = f"💎 Общая стоимость: {texts.format_price(total_price)}" - if offer_discount_total > 0: - total_line += texts.t( - "SUBSCRIPTION_TOTAL_WITH_EXTRA_DISCOUNT", - " (включая доп. скидку {percent}%: -{amount})", - ).format( - percent=offer_discount_percent, - amount=texts.format_price(offer_discount_total), - ) - summary_text = ( "📋 Сводка заказа\n\n" f"📅 Период: {period_display}\n" @@ -402,26 +340,10 @@ async def _prepare_subscription_summary( f"📱 Устройства: {devices_selected}\n\n" "💰 Детализация стоимости:\n" f"{details_text}\n\n" - f"{total_line}\n\n" + f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + "Подтверждаете покупку?" ) - if ( - offer_discount_percent > 0 - and pending_discount_expires_at - and pending_discount_expires_at > datetime.utcnow() - ): - summary_text += ( - texts.t( - "SUBSCRIPTION_EXTRA_DISCOUNT_NOTE", - "⏳ Доп. скидка действует до {expires_at}.", - ).format( - expires_at=pending_discount_expires_at.strftime("%d.%m.%Y %H:%M"), - ) - + "\n\n" - ) - - summary_text += "Подтверждаете покупку?" - return summary_text, summary_data @@ -3451,12 +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) @@ -3633,9 +3550,6 @@ async def confirm_purchase( total_servers_price = data.get('total_servers_price', total_countries_price) - original_total_price = data.get('total_price_before_offer', data['total_price']) - offer_discount_percent = data.get('pending_discount_percent', 0) - offer_discount_total = data.get('pending_discount_total', 0) final_price = data['total_price'] discounted_monthly_additions = data.get( @@ -3649,7 +3563,7 @@ async def confirm_purchase( base_price, discounted_monthly_additions, months_in_period, - original_total_price, + final_price, ) if not is_valid: @@ -3657,17 +3571,6 @@ async def confirm_purchase( await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) return - if offer_discount_percent > 0: - recalculated_total, expected_discount = apply_percentage_discount( - original_total_price, - offer_discount_percent, - ) - if final_price != recalculated_total: - final_price = recalculated_total - offer_discount_total = expected_discount - else: - offer_discount_total = 0 - logger.info(f"Расчет покупки подписки на {data['period_days']} дней ({months_in_period} мес):") base_log = f" Период: {base_price_original / 100}₽" if base_discount_total and base_discount_total > 0: @@ -3709,11 +3612,6 @@ async def confirm_purchase( f" -{devices_discount_total / 100}₽)" ) logger.info(message) - if offer_discount_total > 0: - logger.info( - f" Доп. скидка предложения: -{offer_discount_total / 100}₽" - f" ({offer_discount_percent}%)" - ) logger.info(f" ИТОГО: {final_price / 100}₽") if db_user.balance_kopeks < final_price: @@ -4045,12 +3943,6 @@ async def confirm_purchase( ) if purchase_completed: - if ( - getattr(db_user, 'pending_discount_percent', 0) > 0 - or getattr(db_user, 'pending_discount_offer_id', None) is not None - or getattr(db_user, 'pending_discount_expires_at', None) is not None - ): - await clear_user_pending_discount(db, db_user) await clear_subscription_checkout_draft(db_user.id) await state.clear() @@ -4061,7 +3953,6 @@ async def resume_subscription_checkout( callback: types.CallbackQuery, state: FSMContext, db_user: User, - db: AsyncSession, ): texts = get_texts(db_user.language) @@ -4072,12 +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}" @@ -5167,7 +5053,7 @@ async def claim_discount_offer( ) return - effect_type = (offer.effect_type or "percent_discount").lower() + effect_type = (offer.effect_type or "balance_bonus").lower() if effect_type == "test_access": success, added_squads, expires_at, error_code = await promo_offer_service.grant_test_access( @@ -5207,36 +5093,34 @@ async def claim_discount_offer( await callback.answer(popup_text, show_alert=True) await callback.message.answer(success_message) return - elif effect_type in {"percent_discount", "balance_bonus"}: - percent = max(0, offer.discount_percent or 0) - db_user.pending_discount_percent = percent - db_user.pending_discount_expires_at = offer.expires_at - db_user.pending_discount_offer_id = offer.id + bonus_amount = offer.bonus_amount_kopeks or 0 + if bonus_amount > 0: + success = await add_user_balance( + db, + db_user, + bonus_amount, + texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"), + ) + if not success: + await callback.answer( + texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), + show_alert=True, + ) + return - await mark_offer_claimed(db, offer) - await db.refresh(db_user) + await mark_offer_claimed(db, offer) - success_message = texts.get( - "DISCOUNT_CLAIM_SUCCESS", - "🎉 Скидка {percent}% активирована! Она автоматически применится при оплате подписки.", - ).format(percent=percent) + success_message = texts.get( + "DISCOUNT_CLAIM_SUCCESS", + "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + ).format( + percent=offer.discount_percent, + amount=settings.format_price(bonus_amount), + ) - if offer.expires_at: - expires_text = offer.expires_at.strftime("%d.%m.%Y %H:%M") - expiry_note = texts.get( - "DISCOUNT_CLAIM_EXPIRY_NOTE", - "⏳ Скидка действует до {expires_at}.", - ).format(expires_at=expires_text) - success_message = f"{success_message}\n{expiry_note}" - - await callback.answer(texts.get("DISCOUNT_CLAIM_POPUP", "✅ Скидка активирована!"), show_alert=True) - await callback.message.answer(success_message) - return - - else: - await mark_offer_claimed(db, offer) - await callback.answer(texts.get("DISCOUNT_CLAIM_POPUP", "✅ Скидка активирована!"), show_alert=True) + await callback.answer("✅ Скидка активирована!", show_alert=True) + await callback.message.answer(success_message) async def handle_device_guide( diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 35b0f52c..84750376 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -548,16 +548,11 @@ "CREATE_TICKET_BUTTON": "🎫 Create ticket", "DELETE_MESSAGE": "🗑 Delete", "DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus", - "SUBSCRIPTION_EXTRA_DISCOUNT_LINE": "- Extra offer discount: -{amount} ({percent}%).", - "SUBSCRIPTION_TOTAL_WITH_EXTRA_DISCOUNT": " (includes extra {percent}% discount: -{amount})", - "SUBSCRIPTION_EXTRA_DISCOUNT_NOTE": "⏳ Extra discount valid until {expires_at}.", "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_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically at checkout.", - "DISCOUNT_CLAIM_EXPIRY_NOTE": "⏳ Discount valid until {expires_at}.", - "DISCOUNT_CLAIM_POPUP": "✅ Discount activated!", + "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", @@ -597,8 +592,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}% renewal discount\n\nActivate the offer — an extra {percent}% discount will be applied automatically at checkout. 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 an extra {percent}% discount will be applied automatically at checkout. It stacks with your promo group 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}.", "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 8a8ce126..4ca51d14 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -548,16 +548,11 @@ "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "DELETE_MESSAGE": "🗑 Удалить", "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", - "SUBSCRIPTION_EXTRA_DISCOUNT_LINE": "- Доп. скидка по предложению: -{amount} ({percent}%).", - "SUBSCRIPTION_TOTAL_WITH_EXTRA_DISCOUNT": " (включая доп. скидку {percent}%: -{amount})", - "SUBSCRIPTION_EXTRA_DISCOUNT_NOTE": "⏳ Доп. скидка действует до {expires_at}.", "DISCOUNT_CLAIM_ALREADY": "ℹ️ Скидка уже была активирована ранее.", "DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.", "DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.", "DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она автоматически применится при оплате подписки.", - "DISCOUNT_CLAIM_EXPIRY_NOTE": "⏳ Скидка действует до {expires_at}.", - "DISCOUNT_CLAIM_POPUP": "✅ Скидка активирована!", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", "LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.", "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", @@ -597,8 +592,8 @@ "REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.", "SENDING_ATTACHMENTS": "📎 Отправляю вложения...", "SUBSCRIPTION_EXPIRED_1D": "⛔ Подписка закончилась\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}", - "SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 Скидка {percent}% на продление\n\nАктивируйте предложение — дополнительная скидка {percent}% автоматически применится при оплате. Скидка суммируется с вашей промогруппой и действует до {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки — возвращайтесь, и скидка {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}.", "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 5c567958..584e03fc 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -681,13 +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=0, + bonus_amount_kopeks=bonus_amount, valid_hours=valid_hours, ) success = await self._send_expired_discount_notification( @@ -697,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") @@ -709,13 +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=0, + bonus_amount_kopeks=bonus_amount, valid_hours=valid_hours, ) success = await self._send_expired_discount_notification( @@ -725,6 +728,7 @@ class MonitoringService: offer.expires_at, offer.id, "third", + bonus_amount, trigger_days=trigger_days, ) if success: @@ -1187,6 +1191,7 @@ class MonitoringService: expires_at: datetime, offer_id: int, wave: str, + bonus_amount: int, trigger_days: int = None, ) -> bool: try: @@ -1197,8 +1202,8 @@ class MonitoringService: "SUBSCRIPTION_EXPIRED_SECOND_WAVE", ( "🔥 Скидка {percent}% на продление\n\n" - "Активируйте предложение — дополнительная скидка {percent}% автоматически применится при оплате. " - "Скидка суммируется с вашей промогруппой и действует до {expires_at}." + "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Предложение действует до {expires_at}." ), ) else: @@ -1206,13 +1211,14 @@ class MonitoringService: "SUBSCRIPTION_EXPIRED_THIRD_WAVE", ( "🎁 Индивидуальная скидка {percent}%\n\n" - "Прошло {trigger_days} дней без подписки — возвращайтесь, и скидка {percent}% автоматически применится при оплате. " - "Скидка суммируется с вашей промогруппой и действует до {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/locales/en.json b/locales/en.json index 4e6ad5eb..f024ad95 100644 --- a/locales/en.json +++ b/locales/en.json @@ -527,11 +527,9 @@ "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}% renewal discount\n\nActivate the offer — an extra {percent}% discount will be applied automatically at checkout. 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 an extra {percent}% discount will be applied automatically at checkout. It stacks with your promo group and is valid until {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically at checkout.", - "DISCOUNT_CLAIM_EXPIRY_NOTE": "⏳ Discount valid until {expires_at}.", - "DISCOUNT_CLAIM_POPUP": "✅ Discount activated!", + "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.", @@ -542,9 +540,6 @@ "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_EXTRA_DISCOUNT_LINE": "- Extra offer discount: -{amount} ({percent}%).", - "SUBSCRIPTION_TOTAL_WITH_EXTRA_DISCOUNT": " (includes extra {percent}% discount: -{amount})", - "SUBSCRIPTION_EXTRA_DISCOUNT_NOTE": "⏳ Extra discount valid until {expires_at}.", "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):", diff --git a/locales/ru.json b/locales/ru.json index 0d6adb34..f4d63055 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -527,11 +527,9 @@ "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Активируйте предложение — дополнительная скидка {percent}% автоматически применится при оплате. Скидка суммируется с вашей промогруппой и действует до {expires_at}.", - "SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 Индивидуальная скидка {percent}%\n\nПрошло {trigger_days} дней без подписки — возвращайтесь, и скидка {percent}% автоматически применится при оплате. Скидка суммируется с вашей промогруппой и действует до {expires_at}.", - "DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она автоматически применится при оплате подписки.", - "DISCOUNT_CLAIM_EXPIRY_NOTE": "⏳ Скидка действует до {expires_at}.", - "DISCOUNT_CLAIM_POPUP": "✅ Скидка активирована!", + "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": "❌ Предложение не найдено.", @@ -542,9 +540,6 @@ "TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.", "TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!", "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", - "SUBSCRIPTION_EXTRA_DISCOUNT_LINE": "- Доп. скидка по предложению: -{amount} ({percent}%).", - "SUBSCRIPTION_TOTAL_WITH_EXTRA_DISCOUNT": " (включая доп. скидку {percent}%: -{amount})", - "SUBSCRIPTION_EXTRA_DISCOUNT_NOTE": "⏳ Доп. скидка действует до {expires_at}.", "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",