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):",