diff --git a/app/database/crud/discount_offer.py b/app/database/crud/discount_offer.py index 0ae6a068..fcf1bad3 100644 --- a/app/database/crud/discount_offer.py +++ b/app/database/crud/discount_offer.py @@ -1,6 +1,9 @@ from datetime import datetime, timedelta from typing import Optional +from datetime import datetime, timedelta +from typing import Optional + from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -14,9 +17,9 @@ async def upsert_discount_offer( subscription_id: Optional[int], notification_type: str, discount_percent: int, - bonus_amount_kopeks: int, + bonus_amount_kopeks: int = 0, 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.""" @@ -41,6 +44,7 @@ async def upsert_discount_offer( offer.subscription_id = subscription_id offer.effect_type = effect_type offer.extra_data = extra_data + offer.consumed_at = None else: offer = DiscountOffer( user_id=user_id, @@ -75,6 +79,30 @@ async def mark_offer_claimed(db: AsyncSession, offer: DiscountOffer) -> Discount return offer +async def consume_discount_offer(db: AsyncSession, offer: DiscountOffer) -> DiscountOffer: + offer.consumed_at = datetime.utcnow() + await db.commit() + await db.refresh(offer) + return offer + + +async def get_active_percent_discount_offer( + db: AsyncSession, + user_id: int, +) -> Optional[DiscountOffer]: + result = await db.execute( + select(DiscountOffer) + .where( + DiscountOffer.user_id == user_id, + DiscountOffer.effect_type == "percent_discount", + DiscountOffer.claimed_at.isnot(None), + DiscountOffer.consumed_at.is_(None), + ) + .order_by(DiscountOffer.claimed_at.desc()) + ) + return result.scalars().first() + + async def deactivate_expired_offers(db: AsyncSession) -> int: now = datetime.utcnow() result = await db.execute( diff --git a/app/database/crud/promo_offer_template.py b/app/database/crud/promo_offer_template.py index cb99770b..34ca774b 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" - "Мы начислим {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 +46,13 @@ 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..b18b5279 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -811,8 +811,9 @@ class DiscountOffer(Base): bonus_amount_kopeks = Column(Integer, nullable=False, default=0) expires_at = Column(DateTime, nullable=False) claimed_at = Column(DateTime, nullable=True) + consumed_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..f972cb5f 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -727,8 +727,9 @@ async def create_discount_offers_table(): bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, expires_at DATETIME NOT NULL, claimed_at DATETIME NULL, + consumed_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, @@ -752,8 +753,9 @@ async def create_discount_offers_table(): bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, expires_at TIMESTAMP NOT NULL, claimed_at TIMESTAMP NULL, + consumed_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 @@ -775,8 +777,9 @@ async def create_discount_offers_table(): bonus_amount_kopeks INTEGER NOT NULL DEFAULT 0, expires_at DATETIME NOT NULL, claimed_at DATETIME NULL, + consumed_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, @@ -804,8 +807,9 @@ async def ensure_discount_offer_columns(): try: effect_exists = await check_column_exists('discount_offers', 'effect_type') extra_exists = await check_column_exists('discount_offers', 'extra_data') + consumed_exists = await check_column_exists('discount_offers', 'consumed_at') - if effect_exists and extra_exists: + if effect_exists and extra_exists and consumed_exists: return True async with engine.begin() as conn: @@ -814,15 +818,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}") @@ -843,7 +847,24 @@ async def ensure_discount_offer_columns(): else: raise ValueError(f"Unsupported database type: {db_type}") - logger.info("✅ Колонки effect_type и extra_data для discount_offers проверены") + if not consumed_exists: + column_sql = "ALTER TABLE discount_offers ADD COLUMN consumed_at" + if db_type == 'sqlite': + await conn.execute(text(f"{column_sql} DATETIME NULL")) + elif db_type == 'postgresql': + await conn.execute(text(f"{column_sql} TIMESTAMP NULL")) + elif db_type == 'mysql': + await conn.execute(text(f"{column_sql} DATETIME NULL")) + else: + raise ValueError(f"Unsupported database type: {db_type}") + + # Ensure existing offers use the new effect type naming + await conn.execute(text( + "UPDATE discount_offers SET effect_type = 'percent_discount' " + "WHERE effect_type = 'balance_bonus'" + )) + + logger.info("✅ Колонки discount_offers приведены к актуальному состоянию") return True except Exception as e: diff --git a/app/handlers/admin/monitoring.py b/app/handlers/admin/monitoring.py index 528288b4..9f4119e6 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}." ), ) 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..73ecf980 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": "balance_bonus", + "effect_type": "percent_discount", }, "purchase_discount": { "icon": "🎯", @@ -55,18 +55,14 @@ 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), + "bonus_amount": settings.format_price(template.bonus_amount_kopeks or 0), "valid_hours": template.valid_hours, "test_duration_hours": template.test_duration_hours or 0, } @@ -109,9 +105,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}"), @@ -156,7 +149,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))) + lines.append(texts.t("ADMIN_PROMO_OFFER_AUTO_APPLY", "Скидка применяется автоматически без начислений на баланс.")) else: duration = template.test_duration_hours or 0 lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_DURATION", "Доступ: {hours} ч").format(hours=duration)) @@ -266,15 +259,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): @@ -424,7 +408,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 +418,6 @@ 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={ @@ -495,10 +478,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 +492,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 +502,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..038963d0 100644 --- a/app/handlers/subscription.py +++ b/app/handlers/subscription.py @@ -9,17 +9,25 @@ 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 get_offer_by_id, mark_offer_claimed +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.subscription import ( create_trial_subscription, create_paid_subscription, add_subscription_traffic, add_subscription_devices, 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 + DiscountOffer, + Subscription, + SubscriptionStatus, + TransactionType, + User, ) from app.keyboards.inline import ( get_subscription_keyboard, get_trial_keyboard, @@ -143,10 +151,21 @@ async def _prepare_subscription_summary( db_user: User, data: Dict[str, Any], texts, + active_offer: Optional[DiscountOffer] = None, ) -> Tuple[str, Dict[str, Any]]: summary_data = dict(data) countries = await _get_available_countries(db_user.promo_group_id) + offer_discount_percent = summary_data.get('offer_discount_percent') or 0 + discount_offer_id = summary_data.get('discount_offer_id') + + if active_offer: + offer_discount_percent = max(0, active_offer.discount_percent) + discount_offer_id = active_offer.id + else: + offer_discount_percent = 0 + discount_offer_id = None + months_in_period = calculate_months_from_days(summary_data['period_days']) period_display = format_period_description(summary_data['period_days'], db_user.language) @@ -230,6 +249,14 @@ async def _prepare_subscription_summary( total_price = base_price + total_traffic_price + total_countries_price + total_devices_price + offer_discount_total = 0 + final_total_price = total_price + if offer_discount_percent > 0 and total_price > 0: + final_total_price, offer_discount_total = apply_percentage_discount( + total_price, + offer_discount_percent, + ) + discounted_monthly_additions = ( traffic_component["discounted_per_month"] + discounted_servers_price_per_month @@ -246,7 +273,8 @@ async def _prepare_subscription_summary( if not is_valid: raise ValueError("Subscription price calculation validation failed") - summary_data['total_price'] = total_price + summary_data['total_price_before_offer'] = total_price + summary_data['total_price'] = final_total_price summary_data['server_prices_for_period'] = selected_server_prices summary_data['months_in_period'] = months_in_period summary_data['base_price'] = base_price @@ -272,6 +300,9 @@ 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['offer_discount_percent'] = offer_discount_percent + summary_data['offer_discount_total'] = offer_discount_total + summary_data['discount_offer_id'] = discount_offer_id if offer_discount_percent > 0 else None if settings.is_traffic_fixed(): if final_traffic_gb == 0: @@ -330,8 +361,23 @@ async def _prepare_subscription_summary( ) details_lines.append(devices_line) + if offer_discount_total > 0: + details_lines.append( + texts.t( + "SUBSCRIPTION_OFFER_DISCOUNT_LINE", + "🎯 Доп. скидка: -{amount} (скидка {percent}%)", + ).format( + amount=texts.format_price(offer_discount_total), + percent=offer_discount_percent, + ) + ) + details_text = "\n".join(details_lines) + total_display = texts.format_price(final_total_price) + if offer_discount_total > 0 and total_price > final_total_price: + total_display = f"{texts.format_price(total_price)} {total_display}" + summary_text = ( "📋 Сводка заказа\n\n" f"📅 Период: {period_display}\n" @@ -340,7 +386,7 @@ async def _prepare_subscription_summary( f"📱 Устройства: {devices_selected}\n\n" "💰 Детализация стоимости:\n" f"{details_text}\n\n" - f"💎 Общая стоимость: {texts.format_price(total_price)}\n\n" + f"💎 Общая стоимость: {total_display}\n\n" "Подтверждаете покупку?" ) @@ -1369,6 +1415,15 @@ async def apply_countries_changes( data = await state.get_data() texts = get_texts(db_user.language) + offer_discount_percent = data.get('offer_discount_percent', 0) + offer_discount_total = data.get('offer_discount_total', 0) + final_price = data.get('total_price', 0) + original_total_price = data.get('total_price_before_offer', final_price) + applied_discount_offer_id = data.get('discount_offer_id') + applied_discount_offer: Optional[DiscountOffer] = None + if applied_discount_offer_id: + applied_discount_offer = await get_offer_by_id(db, applied_discount_offer_id) + await save_subscription_checkout_draft(db_user.id, dict(data)) resume_callback = ( "subscription_resume_checkout" @@ -3373,7 +3428,13 @@ async def devices_continue( texts = get_texts(db_user.language) try: - summary_text, prepared_data = await _prepare_subscription_summary(db_user, data, texts) + active_offer = await get_active_percent_discount_offer(db, db_user.id) + summary_text, prepared_data = await _prepare_subscription_summary( + db_user, + data, + texts, + active_offer, + ) except ValueError: logger.error(f"Ошибка в расчете цены подписки для пользователя {db_user.telegram_id}") await callback.answer("Ошибка расчета цены. Обратитесь в поддержку.", show_alert=True) @@ -3550,8 +3611,6 @@ async def confirm_purchase( total_servers_price = data.get('total_servers_price', total_countries_price) - final_price = data['total_price'] - discounted_monthly_additions = data.get( 'discounted_monthly_additions', discounted_traffic_price_per_month @@ -3563,7 +3622,7 @@ async def confirm_purchase( base_price, discounted_monthly_additions, months_in_period, - final_price, + original_total_price, ) if not is_valid: @@ -3612,10 +3671,25 @@ async def confirm_purchase( f" -{devices_discount_total / 100}₽)" ) logger.info(message) - logger.info(f" ИТОГО: {final_price / 100}₽") + if offer_discount_total > 0 and original_total_price > final_price: + logger.info( + " ИТОГО: %s₽ → %s₽ (доп. скидка %s%%: -%s₽)", + original_total_price / 100, + final_price / 100, + offer_discount_percent, + offer_discount_total / 100, + ) + else: + logger.info(f" ИТОГО: {final_price / 100}₽") if db_user.balance_kopeks < final_price: missing_kopeks = final_price - db_user.balance_kopeks + required_display = texts.format_price(final_price) + if offer_discount_total > 0 and original_total_price > final_price: + required_display = ( + f"{texts.format_price(original_total_price)} " + f"{texts.format_price(final_price)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -3626,11 +3700,20 @@ async def confirm_purchase( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=texts.format_price(final_price), + required=required_display, balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) + if offer_discount_total > 0 and original_total_price > final_price: + message_text += "\n" + texts.t( + "ADDON_INSUFFICIENT_FUNDS_DISCOUNT_NOTE", + "🎯 Доп. скидка {percent}% уменьшила стоимость на {amount}.", + ).format( + percent=offer_discount_percent, + amount=texts.format_price(offer_discount_total), + ) + await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( @@ -3653,6 +3736,12 @@ async def confirm_purchase( if not success: missing_kopeks = final_price - db_user.balance_kopeks + required_display = texts.format_price(final_price) + if offer_discount_total > 0 and original_total_price > final_price: + required_display = ( + f"{texts.format_price(original_total_price)} " + f"{texts.format_price(final_price)}" + ) message_text = texts.t( "ADDON_INSUFFICIENT_FUNDS_MESSAGE", ( @@ -3663,11 +3752,20 @@ async def confirm_purchase( "Выберите способ пополнения. Сумма подставится автоматически." ), ).format( - required=texts.format_price(final_price), + required=required_display, balance=texts.format_price(db_user.balance_kopeks), missing=texts.format_price(missing_kopeks), ) + if offer_discount_total > 0 and original_total_price > final_price: + message_text += "\n" + texts.t( + "ADDON_INSUFFICIENT_FUNDS_DISCOUNT_NOTE", + "🎯 Доп. скидка {percent}% уменьшила стоимость на {amount}.", + ).format( + percent=offer_discount_percent, + amount=texts.format_price(offer_discount_total), + ) + await callback.message.edit_text( message_text, reply_markup=get_insufficient_balance_keyboard( @@ -3942,6 +4040,16 @@ async def confirm_purchase( reply_markup=get_back_keyboard(db_user.language) ) + if purchase_completed and applied_discount_offer and applied_discount_offer.consumed_at is None: + try: + await consume_discount_offer(db, applied_discount_offer) + except Exception as exc: # pragma: no cover - defensive logging + logger.error( + "Не удалось отметить скидочное предложение %s использованным: %s", + applied_discount_offer.id, + exc, + ) + if purchase_completed: await clear_subscription_checkout_draft(db_user.id) @@ -3953,6 +4061,7 @@ async def resume_subscription_checkout( callback: types.CallbackQuery, state: FSMContext, db_user: User, + db: AsyncSession, ): texts = get_texts(db_user.language) @@ -3963,7 +4072,13 @@ async def resume_subscription_checkout( return try: - summary_text, prepared_data = await _prepare_subscription_summary(db_user, draft, texts) + active_offer = await get_active_percent_discount_offer(db, db_user.id) + summary_text, prepared_data = await _prepare_subscription_summary( + db_user, + draft, + texts, + active_offer, + ) except ValueError as exc: logger.error( f"Ошибка восстановления заказа подписки для пользователя {db_user.telegram_id}: {exc}" @@ -5053,7 +5168,7 @@ 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 == "test_access": success, added_squads, expires_at, error_code = await promo_offer_service.grant_test_access( @@ -5094,29 +5209,13 @@ 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", "Скидка за продление подписки"), - ) - if not success: - await callback.answer( - texts.get("DISCOUNT_CLAIM_ERROR", "❌ Не удалось начислить скидку. Попробуйте позже."), - show_alert=True, - ) - return - await mark_offer_claimed(db, offer) success_message = texts.get( "DISCOUNT_CLAIM_SUCCESS", - "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.", + "🎉 Скидка {percent}% активирована! Она автоматически применится к следующему платежу.", ).format( percent=offer.discount_percent, - amount=settings.format_price(bonus_amount), ) await callback.answer("✅ Скидка активирована!", show_alert=True) diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json index 84750376..10aea4d5 100644 --- a/app/localization/locales/en.json +++ b/app/localization/locales/en.json @@ -547,12 +547,14 @@ "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_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.", + "SUBSCRIPTION_OFFER_DISCOUNT_LINE": "🎯 Extra discount: -{amount} (discount {percent}%)", + "ADDON_INSUFFICIENT_FUNDS_DISCOUNT_NOTE": "🎯 The extra {percent}% discount reduced the price by {amount}.", + "ADMIN_PROMO_OFFER_AUTO_APPLY": "The discount is applied automatically with no balance credit.", "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 +594,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\nTap “Get discount” and we'll apply it to your next renewal. 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 — the discount will be applied to your next order. 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 4ca51d14..e6aa253e 100644 --- a/app/localization/locales/ru.json +++ b/app/localization/locales/ru.json @@ -547,12 +547,14 @@ "CONTACT_SUPPORT_BUTTON": "💬 Связаться с поддержкой", "CREATE_TICKET_BUTTON": "🎫 Создать тикет", "DELETE_MESSAGE": "🗑 Удалить", - "DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки", "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}% активирована! Она автоматически применится к следующему платежу.", + "SUBSCRIPTION_OFFER_DISCOUNT_LINE": "🎯 Доп. скидка: -{amount} (скидка {percent}%)", + "ADDON_INSUFFICIENT_FUNDS_DISCOUNT_NOTE": "🎯 Доп. скидка {percent}% уменьшила стоимость на {amount}.", + "ADMIN_PROMO_OFFER_AUTO_APPLY": "Скидка применяется автоматически без начислений на баланс.", "ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):", "LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.", "MARK_AS_ANSWERED": "✅ Отметить как отвеченный", @@ -592,8 +594,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..778541a2 100644 --- a/app/services/monitoring_service.py +++ b/app/services/monitoring_service.py @@ -681,15 +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, + effect_type="percent_discount", ) success = await self._send_expired_discount_notification( user, @@ -698,7 +697,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 +709,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, + effect_type="percent_discount", ) success = await self._send_expired_discount_notification( user, @@ -728,7 +725,6 @@ class MonitoringService: offer.expires_at, offer.id, "third", - bonus_amount, trigger_days=trigger_days, ) if success: @@ -1191,7 +1187,6 @@ class MonitoringService: expires_at: datetime, offer_id: int, wave: str, - bonus_amount: int, trigger_days: int = None, ) -> bool: try: @@ -1202,7 +1197,7 @@ class MonitoringService: "SUBSCRIPTION_EXPIRED_SECOND_WAVE", ( "🔥 Скидка {percent}% на продление\n\n" - "Нажмите «Получить скидку», и мы начислим {bonus} на баланс. " + "Нажмите «Получить скидку», и мы применим её к следующему продлению. " "Предложение действует до {expires_at}." ), ) @@ -1211,14 +1206,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/locales/en.json b/locales/en.json index f024ad95..1f0d1350 100644 --- a/locales/en.json +++ b/locales/en.json @@ -527,19 +527,21 @@ "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\nTap “Get discount” and we'll apply it to your next renewal. 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 — the discount will be applied to your next order. Offer valid until {expires_at}.", + "DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied 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_OFFER_DISCOUNT_LINE": "🎯 Extra discount: -{amount} (discount {percent}%)", + "ADDON_INSUFFICIENT_FUNDS_DISCOUNT_NOTE": "🎯 The extra {percent}% discount reduced the price by {amount}.", + "ADMIN_PROMO_OFFER_AUTO_APPLY": "The discount is applied automatically with no balance credit.", "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 f4d63055..ce6ad71d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -527,19 +527,21 @@ "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_OFFER_DISCOUNT_LINE": "🎯 Доп. скидка: -{amount} (скидка {percent}%)", + "ADDON_INSUFFICIENT_FUNDS_DISCOUNT_NOTE": "🎯 Доп. скидка {percent}% уменьшила стоимость на {amount}.", + "ADMIN_PROMO_OFFER_AUTO_APPLY": "Скидка применяется автоматически без начислений на баланс.", "NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.", "NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.", "NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",