Merge pull request #754 from Fr1ngg/bedolaga/update-promo-offers-to-apply-percentage-discount-u1051x

Fix promo offer validation to use pre-discount totals
This commit is contained in:
Egor
2025-10-04 11:43:00 +03:00
committed by GitHub
13 changed files with 320 additions and 112 deletions

View File

@@ -16,7 +16,7 @@ async def upsert_discount_offer(
discount_percent: int,
bonus_amount_kopeks: int,
valid_hours: int,
effect_type: str = "balance_bonus",
effect_type: str = "percent_discount",
extra_data: Optional[dict] = None,
) -> DiscountOffer:
"""Create or refresh a discount offer for a user."""

View File

@@ -31,13 +31,14 @@ DEFAULT_TEMPLATES: tuple[dict, ...] = (
"name": "Скидка на продление",
"message_text": (
"💎 <b>Экономия {discount_percent}% при продлении</b>\n\n"
"Мы начислим {bonus_amount} на баланс после активации, чтобы продление обошлось дешевле.\n"
"Активируйте предложение и получите дополнительную скидку на оплату продления. "
"Она суммируется с вашими промогрупповыми скидками и действует один раз.\n"
"Срок действия предложения — {valid_hours} ч."
),
"button_text": "🎁 Получить скидку",
"valid_hours": 24,
"discount_percent": 20,
"bonus_amount_kopeks": settings.PRICE_30_DAYS * 20 // 100,
"bonus_amount_kopeks": 0,
"test_duration_hours": None,
"test_squad_uuids": [],
},
@@ -46,13 +47,14 @@ DEFAULT_TEMPLATES: tuple[dict, ...] = (
"name": "Скидка на покупку",
"message_text": (
"🎯 <b>Вернитесь со скидкой {discount_percent}%</b>\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": [],
},

View File

@@ -389,6 +389,8 @@ class User(Base):
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
auto_promo_group_assigned = Column(Boolean, nullable=False, default=False)
auto_promo_group_threshold_kopeks = Column(BigInteger, nullable=False, default=0)
promo_offer_discount_percent = Column(Integer, nullable=False, default=0)
promo_offer_discount_source = Column(String(100), nullable=True)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
@@ -812,7 +814,7 @@ class DiscountOffer(Base):
expires_at = Column(DateTime, nullable=False)
claimed_at = Column(DateTime, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
effect_type = Column(String(50), nullable=False, default="balance_bonus")
effect_type = Column(String(50), nullable=False, default="percent_discount")
extra_data = Column(JSON, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())

View File

@@ -728,7 +728,7 @@ async def create_discount_offers_table():
expires_at DATETIME NOT NULL,
claimed_at DATETIME NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus',
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
extra_data TEXT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
@@ -753,7 +753,7 @@ async def create_discount_offers_table():
expires_at TIMESTAMP NOT NULL,
claimed_at TIMESTAMP NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus',
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
extra_data JSON NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
@@ -776,7 +776,7 @@ async def create_discount_offers_table():
expires_at DATETIME NOT NULL,
claimed_at DATETIME NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus',
effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount',
extra_data JSON NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
@@ -814,15 +814,15 @@ async def ensure_discount_offer_columns():
if not effect_exists:
if db_type == 'sqlite':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'"
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
))
elif db_type == 'postgresql':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'"
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
))
elif db_type == 'mysql':
await conn.execute(text(
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'balance_bonus'"
"ALTER TABLE discount_offers ADD COLUMN effect_type VARCHAR(50) NOT NULL DEFAULT 'percent_discount'"
))
else:
raise ValueError(f"Unsupported database type: {db_type}")
@@ -851,6 +851,76 @@ async def ensure_discount_offer_columns():
return False
async def ensure_user_promo_offer_discount_columns():
try:
percent_exists = await check_column_exists('users', 'promo_offer_discount_percent')
source_exists = await check_column_exists('users', 'promo_offer_discount_source')
if percent_exists and source_exists:
return True
async with engine.begin() as conn:
db_type = await get_database_type()
if not percent_exists:
column_def = 'INTEGER NOT NULL DEFAULT 0'
if db_type == 'mysql':
column_def = 'INT NOT NULL DEFAULT 0'
await conn.execute(text(
f"ALTER TABLE users ADD COLUMN promo_offer_discount_percent {column_def}"
))
if not source_exists:
if db_type == 'sqlite':
column_def = 'TEXT NULL'
elif db_type == 'postgresql':
column_def = 'VARCHAR(100) NULL'
elif db_type == 'mysql':
column_def = 'VARCHAR(100) NULL'
else:
raise ValueError(f"Unsupported database type: {db_type}")
await conn.execute(text(
f"ALTER TABLE users ADD COLUMN promo_offer_discount_source {column_def}"
))
logger.info("✅ Колонки promo_offer_discount_* для users проверены")
return True
except Exception as e:
logger.error(f"Ошибка обновления колонок promo_offer_discount_*: {e}")
return False
async def migrate_discount_offer_effect_types():
try:
async with engine.begin() as conn:
await conn.execute(text(
"UPDATE discount_offers SET effect_type = 'percent_discount' "
"WHERE effect_type = 'balance_bonus'"
))
logger.info("✅ Типы эффектов discount_offers обновлены на percent_discount")
return True
except Exception as e:
logger.error(f"Ошибка обновления типов эффектов discount_offers: {e}")
return False
async def reset_discount_offer_bonuses():
try:
async with engine.begin() as conn:
await conn.execute(text(
"UPDATE discount_offers SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0"
))
await conn.execute(text(
"UPDATE promo_offer_templates SET bonus_amount_kopeks = 0 WHERE bonus_amount_kopeks <> 0"
))
logger.info("✅ Бонусы промо-предложений сброшены до нуля")
return True
except Exception as e:
logger.error(f"Ошибка обнуления бонусов промо-предложений: {e}")
return False
async def create_promo_offer_templates_table():
table_exists = await check_table_exists('promo_offer_templates')
if table_exists:
@@ -2433,6 +2503,24 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Не удалось обновить колонки discount_offers")
user_discount_columns_ready = await ensure_user_promo_offer_discount_columns()
if user_discount_columns_ready:
logger.info("✅ Колонки пользовательских промо-скидок готовы")
else:
logger.warning("⚠️ Не удалось обновить пользовательские промо-скидки")
effect_types_updated = await migrate_discount_offer_effect_types()
if effect_types_updated:
logger.info("✅ Типы эффектов промо-предложений обновлены")
else:
logger.warning("⚠️ Не удалось обновить типы эффектов промо-предложений")
bonuses_reset = await reset_discount_offer_bonuses()
if bonuses_reset:
logger.info("✅ Бонусные начисления промо-предложений отключены")
else:
logger.warning("⚠️ Не удалось обнулить бонусы промо-предложений")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ PROMO_OFFER_TEMPLATES ===")
promo_templates_created = await create_promo_offer_templates_table()
if promo_templates_created:
@@ -2647,6 +2735,8 @@ async def check_migration_status():
"promo_groups_addon_discount_column": False,
"users_auto_promo_group_assigned_column": False,
"users_auto_promo_group_threshold_column": False,
"users_promo_offer_discount_percent_column": False,
"users_promo_offer_discount_source_column": False,
"subscription_crypto_link_column": False,
"discount_offers_table": False,
"discount_offers_effect_column": False,
@@ -2677,6 +2767,8 @@ async def check_migration_status():
status["promo_groups_addon_discount_column"] = await check_column_exists('promo_groups', 'apply_discounts_to_addons')
status["users_auto_promo_group_assigned_column"] = await check_column_exists('users', 'auto_promo_group_assigned')
status["users_auto_promo_group_threshold_column"] = await check_column_exists('users', 'auto_promo_group_threshold_kopeks')
status["users_promo_offer_discount_percent_column"] = await check_column_exists('users', 'promo_offer_discount_percent')
status["users_promo_offer_discount_source_column"] = await check_column_exists('users', 'promo_offer_discount_source')
status["subscription_crypto_link_column"] = await check_column_exists('subscriptions', 'subscription_crypto_link')
media_fields_exist = (
@@ -2716,7 +2808,14 @@ async def check_migration_status():
"promo_groups_addon_discount_column": "Колонка apply_discounts_to_addons у промо-групп",
"users_auto_promo_group_assigned_column": "Флаг автоназначения промогруппы у пользователей",
"users_auto_promo_group_threshold_column": "Порог последней авто-промогруппы у пользователей",
"users_promo_offer_discount_percent_column": "Колонка процента промо-скидки у пользователей",
"users_promo_offer_discount_source_column": "Колонка источника промо-скидки у пользователей",
"subscription_crypto_link_column": "Колонка subscription_crypto_link в subscriptions",
"discount_offers_table": "Таблица discount_offers",
"discount_offers_effect_column": "Колонка effect_type в discount_offers",
"discount_offers_extra_column": "Колонка extra_data в discount_offers",
"promo_offer_templates_table": "Таблица promo_offer_templates",
"subscription_temporary_access_table": "Таблица subscription_temporary_access",
}
for check_key, check_status in status.items():

View File

@@ -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",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
message = template.format(
percent=percent,
bonus=settings.format_price(bonus_amount),
expires_at=(now + timedelta(hours=valid_hours)).strftime("%d.%m.%Y %H:%M"),
trigger_days=3,
)
@@ -273,18 +271,16 @@ def _build_notification_preview_message(language: str, notification_type: str):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
trigger_days = NotificationSettingsService.get_third_wave_trigger_days()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
template = texts.get(
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\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"),
)

View File

@@ -10,7 +10,6 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, InlineKeyboardButton, InlineKeyboardMarkup, Message
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.crud.discount_offer import upsert_discount_offer
from app.database.crud.promo_offer_template import (
ensure_default_templates,
@@ -45,7 +44,7 @@ OFFER_TYPE_CONFIG = {
"allowed_segments": [
("paid_active", "🟢 Активные платные"),
],
"effect_type": "balance_bonus",
"effect_type": "percent_discount",
},
"purchase_discount": {
"icon": "🎯",
@@ -55,18 +54,13 @@ OFFER_TYPE_CONFIG = {
("paid_expired", "🔴 Истёкшие платные"),
("trial_expired", "🥶 Истёкшие триалы"),
],
"effect_type": "balance_bonus",
"effect_type": "percent_discount",
},
}
def _format_bonus(template: PromoOfferTemplate) -> str:
return settings.format_price(template.bonus_amount_kopeks or 0)
def _render_template_text(template: PromoOfferTemplate, language: str) -> str:
replacements = {
"discount_percent": template.discount_percent,
"bonus_amount": _format_bonus(template),
"valid_hours": template.valid_hours,
"test_duration_hours": template.test_duration_hours or 0,
}
@@ -109,9 +103,6 @@ def _build_offer_detail_keyboard(template: PromoOfferTemplate, language: str) ->
if template.offer_type != "test_access":
rows[-1].append(InlineKeyboardButton(text="📉 %", callback_data=f"promo_offer_edit_discount_{template.id}"))
rows.append([
InlineKeyboardButton(text="💰 Бонус", callback_data=f"promo_offer_edit_bonus_{template.id}"),
])
else:
rows.append([
InlineKeyboardButton(text="⏳ Длительность", callback_data=f"promo_offer_edit_duration_{template.id}"),
@@ -155,8 +146,18 @@ def _describe_offer(template: PromoOfferTemplate, language: str) -> str:
lines.append(texts.t("ADMIN_PROMO_OFFER_VALID", "Срок действия: {hours} ч").format(hours=template.valid_hours))
if template.offer_type != "test_access":
lines.append(texts.t("ADMIN_PROMO_OFFER_DISCOUNT", "Скидка: {percent}%").format(percent=template.discount_percent))
lines.append(texts.t("ADMIN_PROMO_OFFER_BONUS", "Бонус: {amount}").format(amount=_format_bonus(template)))
lines.append(
texts.t(
"ADMIN_PROMO_OFFER_DISCOUNT",
"Доп. скидка: {percent}% (суммируется с промогруппой)",
).format(percent=template.discount_percent)
)
stack_note = texts.t(
"ADMIN_PROMO_OFFER_STACKABLE_NOTE",
"Скидка применяется один раз и добавляется к промогруппе.",
)
if stack_note:
lines.append(stack_note)
else:
duration = template.test_duration_hours or 0
lines.append(texts.t("ADMIN_PROMO_OFFER_TEST_DURATION", "Доступ: {hours} ч").format(hours=duration))
@@ -266,15 +267,6 @@ async def prompt_edit_discount(callback: CallbackQuery, db_user: User, db: Async
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_discount)
@admin_required
@error_handler
async def prompt_edit_bonus(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
template_id = int(callback.data.split("_")[-1])
texts = get_texts(db_user.language)
prompt = texts.t("ADMIN_PROMO_OFFER_PROMPT_BONUS", "Введите размер бонуса в копейках:")
await _prompt_edit(callback, state, template_id, prompt, AdminStates.editing_promo_offer_bonus)
@admin_required
@error_handler
async def prompt_edit_duration(callback: CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
@@ -328,9 +320,6 @@ async def _handle_edit_field(
elif field == "discount_percent":
percent = max(0, min(100, int(value)))
await update_promo_offer_template(db, template, discount_percent=percent)
elif field == "bonus_amount_kopeks":
bonus = max(0, int(value))
await update_promo_offer_template(db, template, bonus_amount_kopeks=bonus)
elif field == "test_duration_hours":
hours = max(1, int(value))
await update_promo_offer_template(db, template, test_duration_hours=hours)
@@ -424,7 +413,7 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn
sent = 0
failed = 0
effect_type = config.get("effect_type", "balance_bonus")
effect_type = config.get("effect_type", "percent_discount")
for user in users:
try:
@@ -434,7 +423,7 @@ async def send_offer_to_segment(callback: CallbackQuery, db_user: User, db: Asyn
subscription_id=user.subscription.id if user.subscription else None,
notification_type=f"promo_template_{template.id}",
discount_percent=template.discount_percent,
bonus_amount_kopeks=template.bonus_amount_kopeks,
bonus_amount_kopeks=0,
valid_hours=template.valid_hours,
effect_type=effect_type,
extra_data={
@@ -495,10 +484,6 @@ async def process_edit_discount_percent(message: Message, state: FSMContext, db:
await _handle_edit_field(message, state, db, db_user, "discount_percent")
async def process_edit_bonus_amount(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, "bonus_amount_kopeks")
async def process_edit_test_duration(message: Message, state: FSMContext, db: AsyncSession, db_user: User):
await _handle_edit_field(message, state, db, db_user, "test_duration_hours")
@@ -513,7 +498,6 @@ def register_handlers(dp: Dispatcher):
dp.callback_query.register(prompt_edit_button, F.data.startswith("promo_offer_edit_button_"))
dp.callback_query.register(prompt_edit_valid, F.data.startswith("promo_offer_edit_valid_"))
dp.callback_query.register(prompt_edit_discount, F.data.startswith("promo_offer_edit_discount_"))
dp.callback_query.register(prompt_edit_bonus, F.data.startswith("promo_offer_edit_bonus_"))
dp.callback_query.register(prompt_edit_duration, F.data.startswith("promo_offer_edit_duration_"))
dp.callback_query.register(prompt_edit_squads, F.data.startswith("promo_offer_edit_squads_"))
dp.callback_query.register(show_send_segments, F.data.startswith("promo_offer_send_menu_"))
@@ -524,6 +508,5 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_edit_button_text, AdminStates.editing_promo_offer_button)
dp.message.register(process_edit_valid_hours, AdminStates.editing_promo_offer_valid_hours)
dp.message.register(process_edit_discount_percent, AdminStates.editing_promo_offer_discount)
dp.message.register(process_edit_bonus_amount, AdminStates.editing_promo_offer_bonus)
dp.message.register(process_edit_test_duration, AdminStates.editing_promo_offer_test_duration)
dp.message.register(process_edit_test_squads, AdminStates.editing_promo_offer_squads)

View File

@@ -16,7 +16,7 @@ from app.database.crud.subscription import (
update_subscription_autopay
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance, add_user_balance
from app.database.crud.user import subtract_user_balance
from app.database.models import (
User, TransactionType, SubscriptionStatus,
Subscription
@@ -111,6 +111,40 @@ def _apply_addon_discount(
}
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
if not user:
return 0
try:
percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
except (TypeError, ValueError):
return 0
return max(0, min(100, percent))
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Dict[str, int]:
percent = _get_promo_offer_discount_percent(user)
if amount <= 0 or percent <= 0:
return {"discounted": amount, "discount": 0, "percent": 0}
discounted, discount_value = apply_percentage_discount(amount, percent)
return {"discounted": discounted, "discount": discount_value, "percent": percent}
async def _consume_promo_offer_discount(db: AsyncSession, user: User) -> None:
if _get_promo_offer_discount_percent(user) <= 0:
return
user.promo_offer_discount_percent = 0
user.promo_offer_discount_source = None
user.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(user)
def _get_period_hint_from_subscription(subscription: Optional[Subscription]) -> Optional[int]:
if not subscription:
return None
@@ -246,7 +280,20 @@ async def _prepare_subscription_summary(
if not is_valid:
raise ValueError("Subscription price calculation validation failed")
original_total_price = total_price
promo_offer_component = _apply_promo_offer_discount(db_user, total_price)
if promo_offer_component["discount"] > 0:
total_price = promo_offer_component["discounted"]
summary_data['total_price'] = total_price
if promo_offer_component["discount"] > 0:
summary_data['promo_offer_discount_percent'] = promo_offer_component["percent"]
summary_data['promo_offer_discount_value'] = promo_offer_component["discount"]
summary_data['total_price_before_promo_offer'] = original_total_price
else:
summary_data.pop('promo_offer_discount_percent', None)
summary_data.pop('promo_offer_discount_value', None)
summary_data.pop('total_price_before_promo_offer', None)
summary_data['server_prices_for_period'] = selected_server_prices
summary_data['months_in_period'] = months_in_period
summary_data['base_price'] = base_price
@@ -330,6 +377,17 @@ async def _prepare_subscription_summary(
)
details_lines.append(devices_line)
if promo_offer_component["discount"] > 0:
details_lines.append(
texts.t(
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT",
"- Промо-предложение: -{amount} ({percent}% дополнительно)",
).format(
amount=texts.format_price(promo_offer_component["discount"]),
percent=promo_offer_component["percent"],
)
)
details_text = "\n".join(details_lines)
summary_text = (
@@ -2374,6 +2432,7 @@ async def handle_extend_subscription(
available_periods = settings.get_available_renewal_periods()
renewal_prices = {}
promo_offer_percent = _get_promo_offer_discount_percent(db_user)
for days in available_periods:
try:
@@ -2419,7 +2478,8 @@ async def handle_extend_subscription(
total_traffic_price = (traffic_price_per_month - traffic_discount_per_month) * months_in_period
price = base_price + total_servers_price + total_devices_price + total_traffic_price
renewal_prices[days] = price
promo_component = _apply_promo_offer_discount(db_user, price)
renewal_prices[days] = promo_component["discounted"]
except Exception as e:
logger.error(f"Ошибка расчета цены для периода {days}: {e}")
@@ -2456,6 +2516,15 @@ async def handle_extend_subscription(
if promo_discounts_text:
message_text += f"{promo_discounts_text}\n\n"
if promo_offer_percent > 0:
message_text += (
texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_HINT",
"⚡ Доп. скидка {percent}% активирована и будет применена автоматически. Суммируется с промогруппой.",
).format(percent=promo_offer_percent)
+ "\n\n"
)
message_text += "💡 <i>Цена включает все ваши текущие серверы и настройки</i>"
await callback.message.edit_text(
@@ -2720,13 +2789,17 @@ async def confirm_extend_subscription(
total_traffic_price = discounted_traffic_price_per_month * months_in_period
price = base_price + total_servers_price + total_devices_price + total_traffic_price
original_price = price
promo_component = _apply_promo_offer_discount(db_user, price)
if promo_component["discount"] > 0:
price = promo_component["discounted"]
monthly_additions = (
discounted_servers_price_per_month
+ discounted_devices_price_per_month
+ discounted_traffic_price_per_month
)
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, price)
is_valid = validate_pricing_calculation(base_price, monthly_additions, months_in_period, original_price)
if not is_valid:
logger.error(f"Ошибка в расчете цены продления для пользователя {db_user.telegram_id}")
@@ -2774,6 +2847,12 @@ async def confirm_extend_subscription(
else ""
)
)
if promo_component["discount"] > 0:
logger.info(
" 🎯 Промо-предложение: -%s₽ (%s%%)",
promo_component["discount"] / 100,
promo_component["percent"],
)
logger.info(f" 💎 ИТОГО: {price / 100}")
except Exception as e:
@@ -2904,6 +2983,15 @@ async def confirm_extend_subscription(
f"💰 Списано: {texts.format_price(price)}"
)
if promo_component["discount"] > 0:
success_message += (
f" (включая доп. скидку {promo_component['percent']}%:"
f" -{texts.format_price(promo_component['discount'])})"
)
if promo_component["discount"] > 0:
await _consume_promo_offer_discount(db, db_user)
await callback.message.edit_text(
success_message,
reply_markup=get_back_keyboard(db_user.language)
@@ -3551,6 +3639,14 @@ async def confirm_purchase(
total_servers_price = data.get('total_servers_price', total_countries_price)
final_price = data['total_price']
promo_offer_discount_value = data.get('promo_offer_discount_value', 0)
promo_offer_discount_percent = data.get('promo_offer_discount_percent', 0)
validation_total_price = data.get('total_price_before_promo_offer')
if validation_total_price is None and promo_offer_discount_value > 0:
validation_total_price = final_price + promo_offer_discount_value
if validation_total_price is None:
validation_total_price = final_price
discounted_monthly_additions = data.get(
'discounted_monthly_additions',
@@ -3563,7 +3659,7 @@ async def confirm_purchase(
base_price,
discounted_monthly_additions,
months_in_period,
final_price,
validation_total_price,
)
if not is_valid:
@@ -3612,6 +3708,12 @@ async def confirm_purchase(
f" -{devices_discount_total / 100}₽)"
)
logger.info(message)
if promo_offer_discount_value > 0:
logger.info(
" 🎯 Промо-предложение: -%s₽ (%s%%)",
promo_offer_discount_value / 100,
promo_offer_discount_percent,
)
logger.info(f" ИТОГО: {final_price / 100}")
if db_user.balance_kopeks < final_price:
@@ -3811,6 +3913,16 @@ async def confirm_purchase(
subscription_link = get_display_subscription_link(subscription)
hide_subscription_link = settings.should_hide_subscription_link()
discount_note = ""
if promo_offer_discount_value > 0:
discount_note = texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE",
"⚡ Доп. скидка {percent}%: -{amount}",
).format(
percent=promo_offer_discount_percent,
amount=texts.format_price(promo_offer_discount_value),
)
if remnawave_user and subscription_link:
if settings.is_happ_cryptolink_mode():
success_text = (
@@ -3850,6 +3962,9 @@ async def confirm_purchase(
f"{texts.t('SUBSCRIPTION_IMPORT_INSTRUCTION_PROMPT', '📱 Нажмите кнопку ниже, чтобы получить инструкцию по настройке VPN на вашем устройстве')}"
)
if discount_note:
success_text = f"{success_text}\n\n{discount_note}"
connect_mode = settings.CONNECT_BUTTON_MODE
if connect_mode == "miniapp_subscription":
@@ -3917,17 +4032,26 @@ async def confirm_purchase(
callback_data="back_to_menu")],
])
if promo_offer_discount_value > 0:
await _consume_promo_offer_discount(db, db_user)
await callback.message.edit_text(
success_text,
reply_markup=connect_keyboard,
parse_mode="HTML"
)
else:
purchase_text = texts.SUBSCRIPTION_PURCHASED
if discount_note:
purchase_text = f"{purchase_text}\n\n{discount_note}"
if promo_offer_discount_value > 0:
await _consume_promo_offer_discount(db, db_user)
await callback.message.edit_text(
texts.t(
"SUBSCRIPTION_LINK_GENERATING_NOTICE",
"{purchase_text}\n\nСсылка генерируется, перейдите в раздел 'Моя подписка' через несколько секунд.",
).format(purchase_text=texts.SUBSCRIPTION_PURCHASED),
).format(purchase_text=purchase_text),
reply_markup=get_back_keyboard(db_user.language)
)
@@ -5053,7 +5177,9 @@ async def claim_discount_offer(
)
return
effect_type = (offer.effect_type or "balance_bonus").lower()
effect_type = (offer.effect_type or "percent_discount").lower()
if effect_type == "balance_bonus":
effect_type = "percent_discount"
if effect_type == "test_access":
success, added_squads, expires_at, error_code = await promo_offer_service.grant_test_access(
@@ -5094,30 +5220,25 @@ async def claim_discount_offer(
await callback.message.answer(success_message)
return
bonus_amount = offer.bonus_amount_kopeks or 0
if bonus_amount > 0:
success = await add_user_balance(
db,
db_user,
bonus_amount,
texts.get("DISCOUNT_BONUS_DESCRIPTION", "Скидка за продление подписки"),
discount_percent = int(offer.discount_percent or 0)
if discount_percent <= 0:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ERROR", "Не удалось активировать скидку. Попробуйте позже."),
show_alert=True,
)
if not success:
await callback.answer(
texts.get("DISCOUNT_CLAIM_ERROR", "Не удалось начислить скидку. Попробуйте позже."),
show_alert=True,
)
return
return
db_user.promo_offer_discount_percent = discount_percent
db_user.promo_offer_discount_source = offer.notification_type
db_user.updated_at = now
await mark_offer_claimed(db, offer)
await db.refresh(db_user)
success_message = texts.get(
"DISCOUNT_CLAIM_SUCCESS",
"🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
).format(
percent=offer.discount_percent,
amount=settings.format_price(bonus_amount),
)
"🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
).format(percent=discount_percent)
await callback.answer("✅ Скидка активирована!", show_alert=True)
await callback.message.answer(success_message)

View File

@@ -548,11 +548,14 @@
"CREATE_TICKET_BUTTON": "🎫 Create ticket",
"DELETE_MESSAGE": "🗑 Delete",
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with your promo group.",
"DISCOUNT_CLAIM_ALREADY": " This discount has already been activated.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! {amount} credited to your balance.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It will be applied automatically to your next payment.",
"ENTER_BLOCK_MINUTES": "Enter the number of minutes to block the user (e.g., 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Language selection is temporarily unavailable. Using the default language.",
"MARK_AS_ANSWERED": "✅ Mark as answered",
@@ -592,8 +595,8 @@
"REPORT_CLOSE_ERROR": "❌ Failed to close the report.",
"SENDING_ATTACHMENTS": "📎 Sending attachments...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\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": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Extend subscription",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Subscription link is ready. Tap the \"Connect\" button below to open it in Happ.",

View File

@@ -548,11 +548,14 @@
"CREATE_TICKET_BUTTON": "🎫 Создать тикет",
"DELETE_MESSAGE": "🗑 Удалить",
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Доп. скидка {percent}% активирована и будет применена автоматически. Суммируется с промогруппой.",
"DISCOUNT_CLAIM_ALREADY": " Скидка уже была активирована ранее.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она автоматически применится при следующей оплате.",
"ENTER_BLOCK_MINUTES": "Введите количество минут для блокировки пользователя (например, 15):",
"LANGUAGE_SELECTION_DISABLED": "⚙️ Выбор языка временно недоступен. Используем язык по умолчанию.",
"MARK_AS_ANSWERED": "✅ Отметить как отвеченный",
@@ -592,8 +595,8 @@
"REPORT_CLOSE_ERROR": "❌ Не удалось закрыть отчет.",
"SENDING_ATTACHMENTS": "📎 Отправляю вложения...",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXTEND": "💎 Продлить подписку",
"SUBSCRIPTION_HAPP_CRYPTOLINK_BLOCK": "<blockquote expandable><code>{crypto_link}</code></blockquote>",
"SUBSCRIPTION_HAPP_LINK_PROMPT": "🔒 Ссылка на подписку создана. Нажмите кнопку \"Подключиться\" ниже, чтобы открыть её в Happ.",

View File

@@ -681,15 +681,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave2"):
percent = NotificationSettingsService.get_second_wave_discount_percent()
valid_hours = NotificationSettingsService.get_second_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave2",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -698,7 +698,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 +710,15 @@ class MonitoringService:
if not await notification_sent(db, user.id, subscription.id, "expired_discount_wave3"):
percent = NotificationSettingsService.get_third_wave_discount_percent()
valid_hours = NotificationSettingsService.get_third_wave_valid_hours()
bonus_amount = settings.PRICE_30_DAYS * percent // 100
offer = await upsert_discount_offer(
db,
user_id=user.id,
subscription_id=subscription.id,
notification_type="expired_discount_wave3",
discount_percent=percent,
bonus_amount_kopeks=bonus_amount,
bonus_amount_kopeks=0,
valid_hours=valid_hours,
effect_type="percent_discount",
)
success = await self._send_expired_discount_notification(
user,
@@ -728,7 +727,6 @@ class MonitoringService:
offer.expires_at,
offer.id,
"third",
bonus_amount,
trigger_days=trigger_days,
)
if success:
@@ -1191,7 +1189,6 @@ class MonitoringService:
expires_at: datetime,
offer_id: int,
wave: str,
bonus_amount: int,
trigger_days: int = None,
) -> bool:
try:
@@ -1202,8 +1199,8 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_SECOND_WAVE",
(
"🔥 <b>Скидка {percent}% на продление</b>\n\n"
"Нажмите «Получить скидку», и мы начислим {bonus} на баланс. "
"Предложение действует до {expires_at}."
"Активируйте предложение, чтобы получить дополнительную скидку. "
"Она суммируется с вашей промогруппой и действует до {expires_at}."
),
)
else:
@@ -1211,14 +1208,13 @@ class MonitoringService:
"SUBSCRIPTION_EXPIRED_THIRD_WAVE",
(
"🎁 <b>Индивидуальная скидка {percent}%</b>\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 "",
)

View File

@@ -110,7 +110,6 @@ class AdminStates(StatesGroup):
editing_promo_offer_button = State()
editing_promo_offer_valid_hours = State()
editing_promo_offer_discount = State()
editing_promo_offer_bonus = State()
editing_promo_offer_test_duration = State()
editing_promo_offer_squads = State()

View File

@@ -527,19 +527,22 @@
"TRIAL_INACTIVE_1H": "⏳ <b>An hour has passed and we haven't seen any traffic yet</b>\n\nOpen the connection guide and follow the steps. We're always ready to help!",
"TRIAL_INACTIVE_24H": "⏳ <b>A full day passed without activity</b>\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": "⛔ <b>Your subscription expired</b>\n\nAccess was disabled on {end_date}. Renew to return to the service.\n\n💎 Renewal price: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>{percent}% discount on renewal</b>\n\nTap “Get discount” and we'll add {bonus} to your balance. The offer is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\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": "🔥 <b>{percent}% discount on renewal</b>\n\nActivate the offer to get an additional discount. It stacks with your promo group and is valid until {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Personal {percent}% discount</b>\n\nIt's been {trigger_days} days without a subscription. Come back and activate the extra discount — it stacks with your promo group and is valid until {expires_at}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Discount of {percent}% activated! It stacks with your promo group and will apply automatically to your next payment.",
"DISCOUNT_CLAIM_ALREADY": " This discount has already been activated.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ The offer has expired.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Offer not found.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to credit the discount. Please try again later.",
"DISCOUNT_CLAIM_ERROR": "❌ Failed to activate the discount. Please try again later.",
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ You need an active subscription to use this offer.",
"TEST_ACCESS_NO_SQUADS": "❌ Unable to determine servers for the test access. Please contact support.",
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Failed to activate the offer. Please try again later.",
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Test servers are connected! Access is active until {expires_at}.",
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Access granted!",
"DISCOUNT_BONUS_DESCRIPTION": "Renewal discount bonus",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Promo offer: -{amount} ({percent}% extra)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Extra discount {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Extra {percent}% discount is active and will apply automatically. It stacks with your promo group.",
"NOTIFICATION_VALUE_INVALID": "❌ Invalid value, please enter a number.",
"NOTIFICATION_VALUE_UPDATED": "✅ Settings updated.",
"NOTIFY_PROMPT_SECOND_PERCENT": "Enter a new discount percentage for the 2-3 day reminder (0-100):",
@@ -569,8 +572,8 @@
"ADMIN_PROMO_OFFER_PURCHASE": "Purchase discount",
"ADMIN_PROMO_OFFER_TYPE": "Type: {label}",
"ADMIN_PROMO_OFFER_VALID": "Validity: {hours} h",
"ADMIN_PROMO_OFFER_DISCOUNT": "Discount: {percent}%",
"ADMIN_PROMO_OFFER_BONUS": "Bonus: {amount}",
"ADMIN_PROMO_OFFER_DISCOUNT": "Extra discount: {percent}% (stacks with promo group)",
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "The discount applies once and stacks with the promo group.",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Access: {hours} h",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Squads: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Squads: not specified",
@@ -580,7 +583,6 @@
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Enter the button label:",
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Enter validity (hours):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Enter discount percentage:",
"ADMIN_PROMO_OFFER_PROMPT_BONUS": "Enter bonus amount in kopeks:",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Enter test access duration (hours):",
"ADMIN_PROMO_OFFER_PROMPT_SQUADS": "List squad UUIDs separated by commas or spaces. Send 'clear' to reset:",
"ADMIN_PROMO_OFFER_SENDING": "Starting broadcast...",

View File

@@ -527,19 +527,22 @@
"TRIAL_INACTIVE_1H": "⏳ <b>Прошёл час, а подключение не выполнено</b>\n\nЕсли возникли сложности — откройте инструкцию и следуйте шагам. Мы всегда готовы помочь!",
"TRIAL_INACTIVE_24H": "⏳ <b>Прошли сутки с начала теста</b>\n\nМы не видим трафика по вашей подписке. Загляните в инструкцию или напишите в поддержку — поможем подключиться!",
"SUBSCRIPTION_EXPIRED_1D": "⛔ <b>Подписка закончилась</b>\n\nДоступ был отключён {end_date}. Продлите подписку, чтобы вернуть полный доступ.\n\n💎 Стоимость продления: {price}",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nНажмите «Получить скидку», и мы начислим {bonus} на ваш баланс. Предложение действительно до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь — нажмите «Получить скидку», и {bonus} поступит на баланс. Предложение действительно до {expires_at}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! На баланс начислено {amount}.",
"SUBSCRIPTION_EXPIRED_SECOND_WAVE": "🔥 <b>Скидка {percent}% на продление</b>\n\nАктивируйте предложение, чтобы получить дополнительную скидку. Она суммируется с вашей промогруппой и действует до {expires_at}.",
"SUBSCRIPTION_EXPIRED_THIRD_WAVE": "🎁 <b>Индивидуальная скидка {percent}%</b>\n\nПрошло {trigger_days} дней без подписки. Вернитесь и активируйте дополнительную скидку — она суммируется с промогруппой и действует до {expires_at}.",
"DISCOUNT_CLAIM_SUCCESS": "🎉 Скидка {percent}% активирована! Она суммируется с промогруппой и автоматически применится при следующей оплате.",
"DISCOUNT_CLAIM_ALREADY": " Скидка уже была активирована ранее.",
"DISCOUNT_CLAIM_EXPIRED": "⚠️ Время действия предложения истекло.",
"DISCOUNT_CLAIM_NOT_FOUND": "❌ Предложение не найдено.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось начислить скидку. Попробуйте позже.",
"DISCOUNT_CLAIM_ERROR": "❌ Не удалось активировать скидку. Попробуйте позже.",
"TEST_ACCESS_NO_SUBSCRIPTION": "❌ Для активации предложения необходима действующая подписка.",
"TEST_ACCESS_NO_SQUADS": "❌ Не удалось определить список серверов для теста. Обратитесь к администратору.",
"TEST_ACCESS_UNKNOWN_ERROR": "❌ Не удалось активировать предложение. Попробуйте позже.",
"TEST_ACCESS_ACTIVATED_MESSAGE": "🎉 Тестовые сервера подключены! Доступ активен до {expires_at}.",
"TEST_ACCESS_ACTIVATED_POPUP": "✅ Доступ выдан!",
"DISCOUNT_BONUS_DESCRIPTION": "Скидка за продление подписки",
"SUBSCRIPTION_SUMMARY_PROMO_DISCOUNT": "- Промо-предложение: -{amount} ({percent}% дополнительно)",
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE": "⚡ Доп. скидка {percent}%: -{amount}",
"SUBSCRIPTION_PROMO_DISCOUNT_HINT": "⚡ Доп. скидка {percent}% активирована и будет применена автоматически. Суммируется с промогруппой.",
"NOTIFICATION_VALUE_INVALID": "❌ Некорректное значение, укажите число.",
"NOTIFICATION_VALUE_UPDATED": "✅ Настройки обновлены.",
"NOTIFY_PROMPT_SECOND_PERCENT": "Введите новый процент скидки для уведомления через 2-3 дня (0-100):",
@@ -569,8 +572,8 @@
"ADMIN_PROMO_OFFER_PURCHASE": "Скидка на покупку",
"ADMIN_PROMO_OFFER_TYPE": "Тип: {label}",
"ADMIN_PROMO_OFFER_VALID": "Срок действия: {hours} ч",
"ADMIN_PROMO_OFFER_DISCOUNT": "Скидка: {percent}%",
"ADMIN_PROMO_OFFER_BONUS": "Бонус: {amount}",
"ADMIN_PROMO_OFFER_DISCOUNT": "Доп. скидка: {percent}% (суммируется с промогруппой)",
"ADMIN_PROMO_OFFER_STACKABLE_NOTE": "Скидка применяется один раз и добавляется к промогруппе.",
"ADMIN_PROMO_OFFER_TEST_DURATION": "Доступ: {hours} ч",
"ADMIN_PROMO_OFFER_TEST_SQUADS": "Сквады: {squads}",
"ADMIN_PROMO_OFFER_TEST_SQUADS_EMPTY": "Сквады: не указаны",
@@ -580,7 +583,6 @@
"ADMIN_PROMO_OFFER_PROMPT_BUTTON": "Введите новый текст кнопки:",
"ADMIN_PROMO_OFFER_PROMPT_VALID": "Укажите срок действия (в часах):",
"ADMIN_PROMO_OFFER_PROMPT_DISCOUNT": "Введите размер скидки в процентах:",
"ADMIN_PROMO_OFFER_PROMPT_BONUS": "Введите размер бонуса в копейках:",
"ADMIN_PROMO_OFFER_PROMPT_DURATION": "Введите длительность тестового доступа (в часах):",
"ADMIN_PROMO_OFFER_PROMPT_SQUADS": "Перечислите UUID сквадов через запятую или пробел. Для очистки отправьте 'clear':",
"ADMIN_PROMO_OFFER_SENDING": "Начинаем рассылку...",