feat(promo): Добавлены промокоды на скидку в процентах

This commit is contained in:
VAQYBIN
2026-01-19 03:50:33 +05:00
parent 0701af0f35
commit 3bf9acf4d4
14 changed files with 682 additions and 62 deletions
+150 -16
View File
@@ -28,25 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
return return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
# Step 1: Ask for promo code # Step 0: Ask for promo type (bonus_days or discount)
prompt_text = _( prompt_text = _(
"admin_promo_step1_code" "admin_promo_step0_type"
)
# Create keyboard for type selection
builder = InlineKeyboardBuilder()
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_bonus_days"),
callback_data="promo_type_select:bonus_days"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_promo_type_discount"),
callback_data="promo_type_select:discount"
)
)
builder.row(
InlineKeyboardButton(
text=_("admin_back_to_panel"),
callback_data="admin_action:main"
)
) )
try: try:
await callback.message.edit_text( await callback.message.edit_text(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=builder.as_markup(),
parse_mode="HTML") parse_mode="HTML")
except Exception as e: except Exception as e:
logging.warning( logging.warning(
f"Could not edit message for promo prompt: {e}. Sending new.") f"Could not edit message for promo type prompt: {e}. Sending new.")
await callback.message.answer( await callback.message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=builder.as_markup(),
parse_mode="HTML") parse_mode="HTML")
await callback.answer() await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code) await state.set_state(AdminStates.waiting_for_promo_type_selection)
# Step 0: Process type selection
@router.callback_query(F.data.startswith("promo_type_select:"), StateFilter(AdminStates.waiting_for_promo_type_selection))
async def process_promo_type_selection(callback: types.CallbackQuery,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n or not callback.message:
await callback.answer("Error processing type selection.", show_alert=True)
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
promo_type = callback.data.split(":")[-1] # "bonus_days" or "discount"
await state.update_data(promo_type=promo_type)
# Step 1: Ask for promo code
prompt_text = _(
"admin_promo_step1_code"
)
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await callback.answer()
await state.set_state(AdminStates.waiting_for_promo_code)
except Exception as e:
logging.error(f"Error processing promo type selection: {e}")
await callback.message.answer(_("error_occurred_try_again"))
await callback.answer()
# Step 1: Process promo code # Step 1: Process promo code
@@ -81,18 +138,30 @@ async def process_promo_code_handler(message: types.Message,
await state.update_data(promo_code=code_str) await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days # Get promo type from state
prompt_text = _( data = await state.get_data()
"admin_promo_step2_bonus_days", promo_type = data.get("promo_type", "bonus_days")
code=code_str
) # Step 2: Ask for bonus days OR discount percentage based on type
if promo_type == "discount":
prompt_text = _(
"admin_promo_step2_discount_percentage",
code=code_str
)
next_state = AdminStates.waiting_for_promo_discount_percentage
else:
prompt_text = _(
"admin_promo_step2_bonus_days",
code=code_str
)
next_state = AdminStates.waiting_for_promo_bonus_days
await message.answer( await message.answer(
prompt_text, prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML" parse_mode="HTML"
) )
await state.set_state(AdminStates.waiting_for_promo_bonus_days) await state.set_state(next_state)
except Exception as e: except Exception as e:
logging.error(f"Error processing promo code: {e}") logging.error(f"Error processing promo code: {e}")
@@ -146,6 +215,53 @@ async def process_promo_bonus_days_handler(message: types.Message,
await message.answer(_("error_occurred_try_again")) await message.answer(_("error_occurred_try_again"))
# Step 2: Process discount percentage
@router.message(AdminStates.waiting_for_promo_discount_percentage, F.text)
async def process_promo_discount_percentage_handler(message: types.Message,
state: FSMContext,
i18n_data: dict,
settings: Settings):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
if not i18n:
await message.reply("Language service error.")
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
try:
discount_percentage = int(message.text.strip())
if not (1 <= discount_percentage <= 100):
await message.answer(
"❌ Discount percentage must be between 1 and 100."
)
return
await state.update_data(discount_percentage=discount_percentage)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
"admin_promo_step3_max_activations",
code=data.get("promo_code"),
bonus_days=f"{discount_percentage}%" # Display as percentage in place of bonus_days
)
await message.answer(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_max_activations)
except ValueError:
await message.answer(_(
"admin_promo_invalid_number"
))
except Exception as e:
logging.error(f"Error processing discount percentage: {e}")
await message.answer(_("error_occurred_try_again"))
# Step 3: Process max activations # Step 3: Process max activations
@router.message(AdminStates.waiting_for_promo_max_activations, F.text) @router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message, async def process_promo_max_activations_handler(message: types.Message,
@@ -310,11 +426,12 @@ async def create_promo_code_final(callback_or_message,
try: try:
data = await state.get_data() data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Prepare promo code data # Prepare promo code data
promo_data = { promo_data = {
"code": data["promo_code"], "code": data["promo_code"],
"bonus_days": data["bonus_days"], "promo_type": promo_type,
"max_activations": data["max_activations"], "max_activations": data["max_activations"],
"current_activations": 0, "current_activations": 0,
"is_active": True, "is_active": True,
@@ -322,6 +439,14 @@ async def create_promo_code_final(callback_or_message,
"created_at": datetime.now(timezone.utc) "created_at": datetime.now(timezone.utc)
} }
# Set type-specific fields
if promo_type == "discount":
promo_data["discount_percentage"] = data["discount_percentage"]
promo_data["bonus_days"] = None
else:
promo_data["bonus_days"] = data["bonus_days"]
promo_data["discount_percentage"] = None
# Set validity # Set validity
if data.get("validity_days"): if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"]) promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
@@ -333,14 +458,21 @@ async def create_promo_code_final(callback_or_message,
await session.commit() await session.commit()
# Log successful creation # Log successful creation
logging.info(f"Promo code '{data['promo_code']}' created with ID {created_promo.promo_code_id}") logging.info(f"Promo code '{data['promo_code']}' ({promo_type}) created with ID {created_promo.promo_code_id}")
# Success message # Success message
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней" valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} days"
# Format success message based on type
if promo_type == "discount":
value_display = f"{data['discount_percentage']}%"
else:
value_display = f"{data['bonus_days']} days"
success_text = _( success_text = _(
"admin_promo_created_success", "admin_promo_created_success",
code=data["promo_code"], code=data["promo_code"],
bonus_days=data["bonus_days"], bonus_days=value_display, # Reusing bonus_days placeholder for display
max_activations=data["max_activations"], max_activations=data["max_activations"],
valid_until_str=valid_until_str valid_until_str=valid_until_str
) )
@@ -385,8 +517,10 @@ async def create_promo_code_final(callback_or_message,
@router.callback_query( @router.callback_query(
F.data == "admin_action:main", F.data == "admin_action:main",
StateFilter( StateFilter(
AdminStates.waiting_for_promo_type_selection,
AdminStates.waiting_for_promo_code, AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days, AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_discount_percentage,
AdminStates.waiting_for_promo_max_activations, AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days, AdminStates.waiting_for_promo_validity_days,
), ),
+66 -10
View File
@@ -47,9 +47,19 @@ async def get_promo_detail_text_and_keyboard(promo_id: int, session: AsyncSessio
created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A" created = promo.created_at.strftime("%d.%m.%Y %H:%M") if promo.created_at else "N/A"
# Determine promo type and value to display
promo_type = getattr(promo, "promo_type", "bonus_days")
if promo_type == "discount":
type_name = _("admin_promo_type_discount")
value_line = _("admin_promo_card_discount_percentage", percentage=promo.discount_percentage)
else:
type_name = _("admin_promo_type_bonus_days")
value_line = _("admin_promo_card_bonus_days", days=promo.bonus_days)
text = "\n".join([ text = "\n".join([
_("admin_promo_card_title", code=promo.code), _("admin_promo_card_title", code=promo.code),
_("admin_promo_card_bonus_days", days=promo.bonus_days), _("admin_promo_card_type", type=type_name),
value_line,
_("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations), _("admin_promo_card_activations", current=promo.current_activations, max=promo.max_activations),
_("admin_promo_card_validity", validity=validity), _("admin_promo_card_validity", validity=validity),
_("admin_promo_card_status", status=status), _("admin_promo_card_status", status=status),
@@ -76,12 +86,22 @@ async def view_promo_codes_handler(callback: types.CallbackQuery, i18n_data: dic
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0) promo_models = await promo_code_dal.get_all_active_promo_codes(session, limit=20, offset=0)
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}" if not promo_models else "\n".join( if not promo_models:
[_("admin_active_promos_list_header"), ""] + [ text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
f"{get_promo_status_emoji_and_text(p, i18n, current_lang)[0]} <code>{p.code}</code> | 🎁 {p.bonus_days}д | 📊 {p.current_activations}/{p.max_activations} | ⏰ {p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')}" else:
for p in promo_models promo_lines = [_("admin_active_promos_list_header"), ""]
] for p in promo_models:
) status_emoji = get_promo_status_emoji_and_text(p, i18n, current_lang)[0]
promo_type = getattr(p, "promo_type", "bonus_days")
if promo_type == "discount":
value_display = f"💰 {p.discount_percentage}%"
else:
value_display = f"🎁 {p.bonus_days}д"
validity_display = p.valid_until.strftime('%d.%m.%Y') if p.valid_until else _('admin_promo_valid_indefinitely')
promo_lines.append(
f"{status_emoji} <code>{p.code}</code> | {value_display} | 📊 {p.current_activations}/{p.max_activations} | ⏰ {validity_display}"
)
text = "\n".join(promo_lines)
await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML") await callback.message.edit_text(text, reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n), parse_mode="HTML")
await callback.answer() await callback.answer()
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
# CSV headers (forced to English) # CSV headers (forced to English)
writer.writerow([ writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"), i18n.gettext(export_lang, "admin_promo_csv_code"),
"Type",
i18n.gettext(export_lang, "admin_promo_csv_bonus_days"), i18n.gettext(export_lang, "admin_promo_csv_bonus_days"),
"Discount %",
i18n.gettext(export_lang, "admin_promo_csv_max_activations"), i18n.gettext(export_lang, "admin_promo_csv_max_activations"),
i18n.gettext(export_lang, "admin_promo_csv_current_activations"), i18n.gettext(export_lang, "admin_promo_csv_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"), i18n.gettext(export_lang, "admin_promo_csv_status"),
@@ -314,10 +336,17 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
# Определяем статус # Определяем статус
status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang) status_emoji, status_text = get_promo_status_emoji_and_text(promo, i18n, export_lang)
# Determine promo type and values
promo_type = getattr(promo, "promo_type", "bonus_days")
bonus_days_val = promo.bonus_days if promo_type == "bonus_days" else "N/A"
discount_val = promo.discount_percentage if promo_type == "discount" else "N/A"
# Формируем данные для CSV # Формируем данные для CSV
row = [ row = [
promo.code, promo.code,
promo.bonus_days, promo_type,
bonus_days_val,
discount_val,
promo.max_activations, promo.max_activations,
promo.current_activations, promo.current_activations,
status_text, status_text,
@@ -375,8 +404,21 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) _ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
promo_id = int(callback.data.split(":")[1]) promo_id = int(callback.data.split(":")[1])
# Get promo to check type
promo = await promo_code_dal.get_promo_code_by_id(session, promo_id)
if not promo:
await callback.answer(_("admin_promo_not_found"), show_alert=True)
return
promo_type = getattr(promo, "promo_type", "bonus_days")
builder = InlineKeyboardBuilder() builder = InlineKeyboardBuilder()
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}")) # Show appropriate edit option based on type
if promo_type == "discount":
builder.row(InlineKeyboardButton(text="💰 Edit Discount %", callback_data=f"promo_edit_field:discount_percentage:{promo_id}"))
else:
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_bonus_days"), callback_data=f"promo_edit_field:bonus_days:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}")) builder.row(InlineKeyboardButton(text=_("admin_promo_edit_max_activations"), callback_data=f"promo_edit_field:max_activations:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}")) builder.row(InlineKeyboardButton(text=_("admin_promo_edit_validity"), callback_data=f"promo_edit_field:valid_until:{promo_id}"))
builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}")) builder.row(InlineKeyboardButton(text=_("admin_promo_back_to_detail_button"), callback_data=f"promo_detail:{promo_id}"))
@@ -397,11 +439,19 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont
prompts = { prompts = {
"bonus_days": "admin_promo_prompt_bonus_days", "bonus_days": "admin_promo_prompt_bonus_days",
"discount_percentage": "admin_promo_prompt_discount_percentage",
"max_activations": "admin_promo_prompt_max_activations", "max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days" "valid_until": "admin_promo_prompt_validity_days"
} }
prompt_key = prompts.get(field, "error_occurred_try_again")
if field == "discount_percentage":
prompt_text = "Enter the new discount percentage (1-100):"
else:
prompt_text = _(prompt_key)
await state.set_state(AdminStates.waiting_for_promo_edit_details) await state.set_state(AdminStates.waiting_for_promo_edit_details)
await callback.message.edit_text(_(prompts.get(field, "error_occurred_try_again"))) await callback.message.edit_text(prompt_text)
await callback.answer() await callback.answer()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details)) @router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
@@ -421,6 +471,12 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
if field == "bonus_days": if field == "bonus_days":
update_data["bonus_days"] = int(value) update_data["bonus_days"] = int(value)
elif field == "discount_percentage":
discount_pct = int(value)
if not (1 <= discount_pct <= 100):
await message.answer("❌ Discount percentage must be between 1 and 100.")
return
update_data["discount_percentage"] = discount_pct
elif field == "max_activations": elif field == "max_activations":
update_data["max_activations"] = int(value) update_data["max_activations"] = int(value)
elif field == "valid_until": elif field == "valid_until":
+31 -9
View File
@@ -123,13 +123,15 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
code=hcode(code_input.upper())) code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n) reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else: else:
# Try as BONUS code first (existing behavior)
success, result = await promo_code_service.apply_promo_code( success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang) session, user.id, code_input, current_lang)
if success: if success:
# Bonus code success
await session.commit() await session.commit()
logging.info( logging.info(
f"Promo code '{code_input}' successfully applied for user {user.id}." f"Bonus promo code '{code_input}' successfully applied for user {user.id}."
) )
new_end_date = result if isinstance(result, datetime) else None new_end_date = result if isinstance(result, datetime) else None
@@ -151,15 +153,35 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
connect_button_url=connect_button_url, connect_button_url=connect_button_url,
) )
else: else:
await session.rollback() # Bonus code failed, try as DISCOUNT code
logging.info( success_discount, result_discount = await promo_code_service.apply_discount_promo_code(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}" session, user.id, code_input, current_lang
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
) )
if success_discount:
# Discount code success
await session.commit()
logging.info(
f"Discount promo code '{code_input}' successfully applied for user {user.id}."
)
discount_pct = result_discount # Returns percentage
response_to_user_text = _(
"discount_promo_code_applied_success",
code=hcode(code_input.upper()),
discount=discount_pct
)
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
# Both failed
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result # Original error message from bonus code attempt
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await message.answer( await message.answer(
response_to_user_text, response_to_user_text,
reply_markup=reply_markup, reply_markup=reply_markup,
@@ -0,0 +1,40 @@
"""
Helper функция для применения скидок к платежам
Используется всеми платежными обработчиками
"""
import logging
from typing import Optional, Tuple
from sqlalchemy.ext.asyncio import AsyncSession
from db.dal import active_discount_dal
async def apply_discount_to_payment(
session: AsyncSession,
user_id: int,
original_price: float,
promo_code_service=None
) -> Tuple[float, Optional[float], Optional[int]]:
"""
Apply active discount to payment if exists.
Returns:
(final_price, discount_amount, promo_code_id)
"""
if not promo_code_service:
return original_price, None, None
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return original_price, None, None
# Calculate discounted price
final_price, discount_amount = promo_code_service.calculate_discounted_price(
original_price, active_discount.discount_percentage
)
logging.info(
f"Applying {active_discount.discount_percentage}% discount to payment for user {user_id}: "
f"{original_price} -> {final_price}"
)
return final_price, discount_amount, active_discount.promo_code_id
@@ -17,6 +17,7 @@ async def select_subscription_period_callback_handler(
settings: Settings, settings: Settings,
i18n_data: dict, i18n_data: dict,
session: AsyncSession, session: AsyncSession,
promo_code_service=None, # Injected from dispatcher
): ):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -49,6 +50,29 @@ async def select_subscription_period_callback_handler(
stars_price = stars_price_source.get(months) stars_price = stars_price_source.get(months)
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
# Check for active discount and apply if exists
discount_text = ""
if promo_code_service and price_rub:
active_discount_info = await promo_code_service.get_user_active_discount(
session, callback.from_user.id
)
if active_discount_info:
discount_pct, promo_code = active_discount_info
original_price_rub = price_rub
price_rub, discount_amt = promo_code_service.calculate_discounted_price(
price_rub, discount_pct
)
discount_text = get_text(
"active_discount_notice",
code=promo_code,
discount_pct=discount_pct,
original_price=original_price_rub,
discounted_price=price_rub,
discount_amount=discount_amt
)
# Note: Stars prices typically don't get discounts (can be added if needed)
if price_rub is None: if price_rub is None:
if traffic_mode and not price_source and stars_price is not None: if traffic_mode and not price_source and stars_price is not None:
currency_methods_enabled = any( currency_methods_enabled = any(
@@ -83,6 +107,9 @@ async def select_subscription_period_callback_handler(
return return
text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method") text_content = get_text("choose_payment_method_traffic") if traffic_mode else get_text("choose_payment_method")
if discount_text:
text_content = f"{discount_text}\n\n{text_content}"
reply_markup = get_payment_method_keyboard( reply_markup = get_payment_method_keyboard(
months, months,
price_rub, price_rub,
@@ -13,7 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService from bot.services.yookassa_service import YooKassaService
from config.settings import Settings from config.settings import Settings
from db.dal import payment_dal, user_billing_dal from db.dal import payment_dal, user_billing_dal, active_discount_dal
router = Router(name="user_subscription_payments_yookassa_router") router = Router(name="user_subscription_payments_yookassa_router")
@@ -60,6 +60,7 @@ async def _initiate_yk_payment(
settings: Settings, settings: Settings,
session: AsyncSession, session: AsyncSession,
yookassa_service: YooKassaService, yookassa_service: YooKassaService,
promo_code_service, # NEW: Added promo_code_service
i18n: Optional[JsonI18n], i18n: Optional[JsonI18n],
current_lang: str, current_lang: str,
get_text, get_text,
@@ -77,6 +78,24 @@ async def _initiate_yk_payment(
if not callback.message: if not callback.message:
return False return False
# NEW: Check for active discount and apply if exists
original_price = price_rub
discount_amount = None
active_promo_code_id = None
if promo_code_service:
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
final_price, discount_amount = promo_code_service.calculate_discounted_price(
price_rub, active_discount.discount_percentage
)
price_rub = final_price
active_promo_code_id = active_discount.promo_code_id
logging.info(
f"Applying {active_discount.discount_percentage}% discount to YooKassa payment: "
f"{original_price} -> {price_rub}"
)
payment_description = ( payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months)) get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_mode == "traffic" if sale_mode == "traffic"
@@ -84,11 +103,14 @@ async def _initiate_yk_payment(
) )
payment_record_data = { payment_record_data = {
"user_id": user_id, "user_id": user_id,
"amount": price_rub, "amount": price_rub, # Discounted amount
"original_amount": original_price if discount_amount else None, # NEW
"discount_applied": discount_amount, # NEW
"currency": currency_code_for_yk, "currency": currency_code_for_yk,
"status": "pending_yookassa", "status": "pending_yookassa",
"description": payment_description, "description": payment_description,
"subscription_duration_months": int(months), "subscription_duration_months": int(months),
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
} }
db_payment_record = None db_payment_record = None
@@ -319,7 +341,7 @@ async def _initiate_yk_payment(
@router.callback_query(F.data.startswith("pay_yk:")) @router.callback_query(F.data.startswith("pay_yk:"))
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -417,6 +439,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
@@ -435,7 +458,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
@router.callback_query(F.data.startswith("pay_yk_new:")) @router.callback_query(F.data.startswith("pay_yk_new:"))
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -491,6 +514,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
@@ -653,7 +677,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
@router.callback_query(F.data.startswith("pay_yk_use_saved:")) @router.callback_query(F.data.startswith("pay_yk_use_saved:"))
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -752,6 +776,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
settings=settings, settings=settings,
session=session, session=session,
yookassa_service=yookassa_service, yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n, i18n=i18n,
current_lang=current_lang, current_lang=current_lang,
get_text=get_text, get_text=get_text,
+151 -1
View File
@@ -6,7 +6,7 @@ from aiogram import Bot
from config.settings import Settings from config.settings import Settings
from db.dal import promo_code_dal, user_dal from db.dal import promo_code_dal, user_dal, active_discount_dal
from db.models import PromoCode, User from db.models import PromoCode, User
from .subscription_service import SubscriptionService from .subscription_service import SubscriptionService
@@ -83,3 +83,153 @@ class PromoCodeService:
return False, _("error_applying_promo_bonus") return False, _("error_applying_promo_bonus")
else: else:
return False, _("error_applying_promo_bonus") return False, _("error_applying_promo_bonus")
async def apply_discount_promo_code(
self,
session: AsyncSession,
user_id: int,
code_input: str,
user_lang: str,
) -> Tuple[bool, int | str]:
"""
Apply a discount promo code (sets active discount for user).
Returns: (success: bool, discount_percentage or error_message)
"""
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
# Check if user already has an active discount
existing_discount = await active_discount_dal.get_active_discount(session, user_id)
if existing_discount:
# Get the promo code for the existing discount
existing_promo = await promo_code_dal.get_promo_code_by_id(
session, existing_discount.promo_code_id
)
if existing_promo:
return False, _("discount_promo_already_active",
code=existing_promo.code,
discount_pct=existing_discount.discount_percentage)
else:
# Existing discount but promo not found - clear it and continue
await active_discount_dal.clear_active_discount(session, user_id)
# Get discount promo code
promo_data = await promo_code_dal.get_active_discount_promo_code_by_code_str(
session, code_input_upper
)
if not promo_data:
return False, _("promo_code_not_found_or_not_discount", code=code_input_upper)
# Check if user already used this code
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_data.promo_code_id, user_id
)
if existing_activation:
return False, _("promo_code_already_used_by_user", code=code_input_upper)
# Set active discount
active_discount = await active_discount_dal.set_active_discount(
session,
user_id=user_id,
promo_code_id=promo_data.promo_code_id,
discount_percentage=promo_data.discount_percentage
)
if not active_discount:
# This shouldn't happen since we checked above, but just in case
return False, _("error_applying_promo_discount")
logging.info(
f"Discount promo code {code_input_upper} activated for user {user_id}: "
f"{promo_data.discount_percentage}% off"
)
return True, promo_data.discount_percentage
async def get_user_active_discount(
self,
session: AsyncSession,
user_id: int
) -> Optional[Tuple[int, str]]:
"""
Get user's active discount if any.
Returns: (discount_percentage, promo_code) or None
"""
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return None
# Fetch promo code for code string
promo = await promo_code_dal.get_promo_code_by_id(
session, active_discount.promo_code_id
)
if not promo:
# Discount exists but promo not found - clear it
await active_discount_dal.clear_active_discount(session, user_id)
return None
return (active_discount.discount_percentage, promo.code)
def calculate_discounted_price(
self,
original_price: float,
discount_percentage: int
) -> Tuple[float, float]:
"""
Calculate discounted price and discount amount.
Returns: (final_price, discount_amount)
"""
discount_amount = round(original_price * (discount_percentage / 100), 2)
final_price = round(original_price - discount_amount, 2)
# Ensure price doesn't go negative
if final_price < 0:
final_price = 0
discount_amount = original_price
return final_price, discount_amount
async def consume_discount(
self,
session: AsyncSession,
user_id: int,
payment_id: int
) -> bool:
"""
Consume active discount: record activation, increment usage, clear active discount.
Call this AFTER successful payment.
"""
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if not active_discount:
return False
# Record activation
activation_recorded = await promo_code_dal.record_promo_activation(
session,
active_discount.promo_code_id,
user_id,
payment_id=payment_id
)
# Increment usage
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session,
active_discount.promo_code_id
)
# Clear active discount
await active_discount_dal.clear_active_discount(session, user_id)
if activation_recorded and promo_incremented:
await session.flush()
logging.info(
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
f"payment {payment_id}"
)
return True
else:
logging.error(
f"Failed to consume discount for user {user_id}, "
f"promo {active_discount.promo_code_id}"
)
return False
+30 -1
View File
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot from aiogram import Bot
from bot.middlewares.i18n import JsonI18n from bot.middlewares.i18n import JsonI18n
from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal, active_discount_dal
from bot.utils.date_utils import add_months from bot.utils.date_utils import add_months
from bot.utils.config_link import prepare_config_links from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription from db.models import User, Subscription
@@ -691,6 +691,35 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl") final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid) final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
# NEW: Consume discount promo code if payment had one
try:
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if payment_record and payment_record.discount_applied:
# This payment had a discount applied - consume it
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Record promo activation
await promo_code_dal.record_promo_activation(
session,
active_discount.promo_code_id,
user_id,
payment_id=payment_db_id
)
# Increment usage
await promo_code_dal.increment_promo_code_usage(
session,
active_discount.promo_code_id
)
# Clear active discount
await active_discount_dal.clear_active_discount(session, user_id)
logging.info(
f"Discount consumed for user {user_id}, promo {active_discount.promo_code_id}, "
f"payment {payment_db_id}"
)
except Exception as e:
logging.error(f"Failed to consume discount for user {user_id}, payment {payment_db_id}: {e}")
# Don't fail the subscription activation if discount consumption fails
return { return {
"subscription_id": new_or_updated_sub.subscription_id, "subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date, "end_date": final_end_date,
+2
View File
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_message = State() waiting_for_broadcast_message = State()
confirming_broadcast = State() confirming_broadcast = State()
waiting_for_promo_details = State() waiting_for_promo_details = State()
waiting_for_promo_type_selection = State()
waiting_for_promo_code = State() waiting_for_promo_code = State()
waiting_for_promo_bonus_days = State() waiting_for_promo_bonus_days = State()
waiting_for_promo_discount_percentage = State()
waiting_for_promo_max_activations = State() waiting_for_promo_max_activations = State()
waiting_for_promo_validity_days = State() waiting_for_promo_validity_days = State()
waiting_for_promo_edit_details = State() waiting_for_promo_edit_details = State()
+71
View File
@@ -0,0 +1,71 @@
import logging
from typing import Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from sqlalchemy import delete
from datetime import datetime, timezone
from db.models import ActiveDiscount, PromoCode
async def set_active_discount(
session: AsyncSession,
user_id: int,
promo_code_id: int,
discount_percentage: int
) -> Optional[ActiveDiscount]:
"""
Set active discount for user.
Returns None if user already has an active discount (enforce one-at-a-time rule).
"""
# Check if user already has an active discount
existing = await get_active_discount(session, user_id)
if existing:
logging.warning(
f"User {user_id} already has active discount (promo_code_id: {existing.promo_code_id}). "
f"Cannot activate new discount {promo_code_id}."
)
return None
# Create new active discount
new_discount = ActiveDiscount(
user_id=user_id,
promo_code_id=promo_code_id,
discount_percentage=discount_percentage,
activated_at=datetime.now(timezone.utc)
)
session.add(new_discount)
await session.flush()
await session.refresh(new_discount)
logging.info(
f"Active discount set for user {user_id}: promo_code_id={promo_code_id}, "
f"discount={discount_percentage}%"
)
return new_discount
async def get_active_discount(
session: AsyncSession,
user_id: int
) -> Optional[ActiveDiscount]:
"""Get active discount for user if exists."""
stmt = select(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def clear_active_discount(
session: AsyncSession,
user_id: int
) -> bool:
"""
Clear active discount for user.
Returns True if discount was cleared, False if no discount was found.
"""
stmt = delete(ActiveDiscount).where(ActiveDiscount.user_id == user_id)
result = await session.execute(stmt)
await session.flush()
cleared = result.rowcount > 0
if cleared:
logging.info(f"Active discount cleared for user {user_id}")
return cleared
+14
View File
@@ -43,6 +43,20 @@ async def get_active_promo_code_by_code_str(
return result.scalar_one_or_none() return result.scalar_one_or_none()
async def get_active_discount_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active discount-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "discount",
PromoCode.is_active == True,
PromoCode.current_activations < PromoCode.max_activations,
or_(PromoCode.valid_until == None, PromoCode.valid_until
> datetime.now(timezone.utc)))
result = await session.execute(stmt)
return result.scalar_one_or_none()
async def get_all_active_promo_codes(session: AsyncSession, async def get_all_active_promo_codes(session: AsyncSession,
limit: int = 20, limit: int = 20,
offset: int = 0) -> List[PromoCode]: offset: int = 0) -> List[PromoCode]:
+30 -2
View File
@@ -100,7 +100,12 @@ class Payment(Base):
provider_payment_id = Column(String, unique=True, nullable=True) provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=True) provider = Column(String, nullable=False, default="yookassa", index=True)
idempotence_key = Column(String, unique=True, nullable=True) idempotence_key = Column(String, unique=True, nullable=True)
amount = Column(Float, nullable=False) amount = Column(Float, nullable=False) # Final amount paid (after discount if any)
# Discount tracking fields
original_amount = Column(Float, nullable=True) # Amount before discount
discount_applied = Column(Float, nullable=True) # Discount amount (not percentage)
currency = Column(String, nullable=False) currency = Column(String, nullable=False)
status = Column(String, nullable=False, index=True) status = Column(String, nullable=False, index=True)
description = Column(String, nullable=True) description = Column(String, nullable=True)
@@ -154,7 +159,17 @@ class PromoCode(Base):
promo_code_id = Column(Integer, primary_key=True, autoincrement=True) promo_code_id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String, unique=True, nullable=False, index=True) code = Column(String, unique=True, nullable=False, index=True)
bonus_days = Column(Integer, nullable=False)
# Type field to distinguish promo code types
promo_type = Column(String, nullable=False, default="bonus_days", index=True)
# Values: "bonus_days" or "discount"
# For bonus_days type: number of days to add to subscription
bonus_days = Column(Integer, nullable=True)
# For discount type: percentage discount (1-100)
discount_percentage = Column(Integer, nullable=True)
max_activations = Column(Integer, nullable=False) max_activations = Column(Integer, nullable=False)
current_activations = Column(Integer, default=0) current_activations = Column(Integer, default=0)
is_active = Column(Boolean, default=True) is_active = Column(Boolean, default=True)
@@ -191,6 +206,19 @@ class PromoCodeActivation(Base):
name='uq_promo_user_activation'), ) name='uq_promo_user_activation'), )
class ActiveDiscount(Base):
"""Tracks pending discount promo codes awaiting payment (permanent until used)"""
__tablename__ = "active_discounts"
user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True)
promo_code_id = Column(Integer, ForeignKey("promo_codes.promo_code_id"), nullable=False)
discount_percentage = Column(Integer, nullable=False)
activated_at = Column(DateTime(timezone=True), server_default=func.now())
promo_code = relationship("PromoCode")
user = relationship("User")
class MessageLog(Base): class MessageLog(Base):
__tablename__ = "message_logs" __tablename__ = "message_logs"
+10
View File
@@ -76,6 +76,10 @@
"promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.", "promo_code_not_found": "Promo code <code>{code}</code> not found, expired, or already used the maximum number of times.",
"promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.", "promo_code_already_used_by_user": "You have already used promo code <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇", "promo_code_applied_success_full": "✅ Promo code applied successfully!\nSubscription active until {end_date}.\n\nConnection key:\n<code>{config_link}</code>\n\nTo connect, open the link and follow the instructions 👇",
"discount_promo_code_applied_success": "✅ Promo code <code>{code}</code> activated!\n\n💰 A {discount}% discount will be applied to your next purchase.\n\nSelect a plan for payment.",
"discount_promo_already_active": "❌ You already have an active discount promo code (<code>{code}</code>, -{discount_pct}%). Use it first or wait until the payment is complete.",
"promo_code_not_found_or_not_discount": "❌ Promo code <code>{code}</code> not found or is invalid.",
"active_discount_notice": "🎁 Active discount: <code>{code}</code> (-{discount_pct}%)\n💵 Price: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Savings: {discount_amount}",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.", "error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
"promo_input_cancelled_short": "Promo code entry cancelled.", "promo_input_cancelled_short": "Promo code entry cancelled.",
"trial_feature_disabled": "Free trial is currently unavailable.", "trial_feature_disabled": "Free trial is currently unavailable.",
@@ -335,8 +339,12 @@
"admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000", "admin_bulk_promo_invalid_quantity": "❌ Quantity must be between 1 and 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):", "admin_bulk_promo_enter_validity_days": "⏰ Enter the number of validity days for promo codes (1-365):",
"admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...", "admin_bulk_promo_creating": "⏳ Creating {quantity} promo codes...",
"admin_promo_step0_type": "Choose promo code type:",
"admin_promo_type_bonus_days": "🎁 Bonus Days (subscription extension)",
"admin_promo_type_discount": "💰 Purchase Discount (%)",
"admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):", "admin_promo_step1_code": "🎟 <b>Create Promo Code</b>\n\n<b>Step 1 of 4:</b> Promo Code\n\nEnter promo code (3-30 characters, letters and numbers only):",
"admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):", "admin_promo_step2_bonus_days": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Bonus Days\n\nCode: <b>{code}</b>\n\nEnter the number of bonus days (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Create Promo Code</b>\n\n<b>Step 2 of 4:</b> Discount Percentage\n\nCode: <b>{code}</b>\n\nEnter the discount percentage for the promo code (1-100):",
"admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):", "admin_promo_step3_max_activations": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\n\nEnter the maximum number of activations (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:", "admin_promo_step4_validity": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nBonus days: <b>{bonus_days}</b>\nMax activations: <b>{max_activations}</b>\n\nChoose the validity period for the promo code:",
"admin_promo_code_already_exists": "❌ A promo code with this code already exists", "admin_promo_code_already_exists": "❌ A promo code with this code already exists",
@@ -373,7 +381,9 @@
"admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:", "admin_promo_management_title": "🎟 <b>Promo Code Management</b>\n\nSelect a promo code for detailed view:",
"admin_promo_management_empty": "📭 No promo codes available", "admin_promo_management_empty": "📭 No promo codes available",
"admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>", "admin_promo_card_title": "🎟 <b>Promo Code: {code}</b>",
"admin_promo_card_type": "📌 Type: {type}",
"admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>", "admin_promo_card_bonus_days": "🎁 Bonus days: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Discount: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>", "admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>", "admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
"admin_promo_card_status": "📊 Status: <b>{status}</b>", "admin_promo_card_status": "📊 Status: <b>{status}</b>",
+12
View File
@@ -76,7 +76,12 @@
"promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.", "promo_code_not_found": "Промокод <code>{code}</code> не найден, истек или уже использован максимальное количество раз.",
"promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.", "promo_code_already_used_by_user": "Вы уже активировали промокод <code>{code}</code>.",
"promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇", "promo_code_applied_success_full": "✅ Промокод успешно применен!\nПодписка активна до {end_date}.\n\nКлюч подключения:\n<code>{config_link}</code>\n\nЧтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"discount_promo_code_applied_success": "✅ Промокод <code>{code}</code> активирован!\n\n💰 Скидка {discount}% будет применена к вашей следующей покупке.\n\nВыберите тариф для оплаты.",
"discount_promo_already_active": "❌ У вас уже есть активированный промокод на скидку (<code>{code}</code>, -{discount_pct}%). Используйте его сначала или дождитесь окончания платежа.",
"promo_code_not_found_or_not_discount": "❌ Промокод <code>{code}</code> не найден или недействителен.",
"active_discount_notice": "🎁 Активна скидка: <code>{code}</code> (-{discount_pct}%)\n💵 Цена: <s>{original_price}</s> ➔ <b>{discounted_price}</b>\n💰 Экономия: {discount_amount}",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.", "error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
"promo_input_cancelled_short": "Ввод промокода отменен.", "promo_input_cancelled_short": "Ввод промокода отменен.",
"trial_feature_disabled": "Пробный период в данный момент недоступен.", "trial_feature_disabled": "Пробный период в данный момент недоступен.",
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.", "trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
@@ -335,8 +340,13 @@
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000", "admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):", "admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
"admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...", "admin_bulk_promo_creating": "⏳ Создаю {quantity} промокодов...",
"admin_promo_step0_type": "Выберите тип промокода:",
"admin_promo_type_bonus_days": "🎁 Бонусные дни (продление подписки)",
"admin_promo_type_discount": "💰 Скидка на покупку (%)",
"admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):", "admin_promo_step1_code": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 1 из 4:</b> Код промокода\n\nВведите код промокода (3-30 символов, только буквы и цифры):",
"admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):", "admin_promo_step2_bonus_days": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Бонусные дни\n\nКод: <b>{code}</b>\n\nВведите количество бонусных дней (1-365):",
"admin_promo_step2_discount_percentage": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 2 из 4:</b> Процент скидки\n\nКод: <b>{code}</b>\n\nВведите процент скидки для промокода <code>{code}</code> (от 1 до 100):",
"admin_promo_invalid_discount_percentage": "❌ Процент скидки должен быть от 1 до 100.",
"admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):", "admin_promo_step3_max_activations": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\n\nВведите максимальное количество активаций (1-10000):",
"admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:", "admin_promo_step4_validity": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nБонусные дни: <b>{bonus_days}</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует", "admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
@@ -373,7 +383,9 @@
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:", "admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
"admin_promo_management_empty": "📭 Промокоды отсутствуют", "admin_promo_management_empty": "📭 Промокоды отсутствуют",
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>", "admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
"admin_promo_card_type": "📌 Тип: {type}",
"admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>", "admin_promo_card_bonus_days": "🎁 Бонусные дни: <b>{days}</b>",
"admin_promo_card_discount_percentage": "💰 Скидка: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>", "admin_promo_card_activations": "🔢 Активации: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>", "admin_promo_card_validity": "⏰ Действует до: <b>{validity}</b>",
"admin_promo_card_status": "📊 Статус: <b>{status}</b>", "admin_promo_card_status": "📊 Статус: <b>{status}</b>",