Compare commits

...
24 Commits
Author SHA1 Message Date
kavoreandGitHub 8dde585d49 Merge pull request #159 from kavore/dev
bugfix
2026-02-03 12:18:37 +03:00
kavore 3c84007f17 fix(promo): count activations on payment
Allow promo usage increments to overflow the max limit when a payment completes so counters reflect paid activations only.
2026-02-03 12:05:16 +03:00
kavore d11b0cabaa fix(users): handle active discounts on delete
Ensure active_discounts cascades on user/promo removal, clean orphan rows during migration, and rely on DB cascade for user deletion.
2026-02-03 11:25:53 +03:00
kavore 4b5908d6cc fix(promo): Improve error handling for promo code application
- Enhanced the logic for determining the response to users when a promo code application fails, ensuring clearer messaging based on the type of error encountered.
- Introduced specific error messages for cases where the promo code is not found or not applicable as a discount, improving user experience and clarity.
2026-02-02 23:55:04 +03:00
kavore 5bb8f2add0 feat(promo): Integrate promo code service into subscription options
- Enhanced the display_subscription_options and reshow_subscription_options_callback functions to accept a promo_code_service parameter for improved discount handling.
- Updated the logic to calculate and display discounted prices based on active promo codes, including support for both regular and star-based pricing.
- Modified relevant locales to include currency symbols in discount notices for better user clarity.
2026-02-02 00:35:31 +03:00
kavore 484327a032 feat(promo): Enhance promo code functionality and discount consumption logic
- Integrated promo code service into subscription service to streamline discount consumption during payment processing.
- Improved error handling and logging for promo code activation and usage increment.
- Updated payment services to handle discount calculations more robustly, including fallback mechanisms for invalid discount scenarios.
- Added new methods for managing promo code activations and usage in the database layer.
2026-02-01 19:29:31 +03:00
kavoreandGitHub 9ea9f571bb Merge pull request #155 from kavore/dev 2026-01-31 07:13:59 +03:00
kavoreandGitHub 9b4a58f82f Merge pull request #154 from VAQYBIN/dev
Исправление бага при удалении промокода на скидку
2026-01-28 16:18:23 +03:00
VAQYBIN e6adbbece4 Merge branch 'dev' of https://github.com/VAQYBIN/remnawave-tg-shop into dev 2026-01-25 20:43:20 +05:00
VAQYBIN 1e79b15351 fix(promo): Исправлена ошибка с удалением промокода на скидку
Исправлен баг, когда промокод на скидку не мог быть удалён, если пользователь применил скидку, но не воспользовался ей.
Добавлено истечение времени действия промокода, даже если промокод на скидку уже был введён
2026-01-25 20:43:01 +05:00
kavoreandGitHub 6f3c123b3a Merge pull request #153 from VAQYBIN/dev
Промокоды на скидку
2026-01-24 22:15:06 +03:00
VAQYBINandGitHub 4a0f763307 Delete CLAUDE.md 2026-01-23 12:42:40 +05:00
VAQYBIN a121d38fbb fix(payments): Исправлена ошибка с дублированием скидки для всех платёжных систем 2026-01-23 00:00:16 +05:00
VAQYBIN 1e8b97888a fix(promo): Исправлен баг с применением двойной скидки при оплате через CryptoBot 2026-01-21 01:26:10 +05:00
VAQYBIN 9ca2fe487c fix(promo): Исправлена ошибка с зацыкленностью импортов для оплат звёздами и криптой 2026-01-21 01:13:26 +05:00
VAQYBIN 229ce7e1e0 feat(promo): Добавлена поддержка промокодов на скидку для всех платёжных систем 2026-01-21 01:09:49 +05:00
VAQYBIN 39d5fd1856 chore(promo): Добавлена локализация кнопки изменения процента скидки 2026-01-20 01:44:22 +05:00
VAQYBIN fed1c1c960 feat(promo): Добавлена локализация при изменении процента скидки и логи при активации промокода на скидку 2026-01-20 01:26:14 +05:00
VAQYBIN e7df5e539c chore(promo): Добавлены локализации при создании промокодов на скидку 2026-01-20 01:06:18 +05:00
VAQYBIN dcc7f9eb72 fix(promo): Исправлена ошибка с дублированием скидки при использование промокода 2026-01-20 00:51:05 +05:00
VAQYBIN baaf5c457f fix(promo): Исправлена ошибка при применении промокода на скидку 2026-01-20 00:34:17 +05:00
VAQYBIN 62bf5c35a8 fix(db): Добавлены новые таблицы для хранения информации о новых и старых ценах при использовании промокодов на скидку 2026-01-20 00:16:24 +05:00
VAQYBIN d619afff29 test: fix ci/cd 2026-01-19 04:00:10 +05:00
VAQYBIN 3bf9acf4d4 feat(promo): Добавлены промокоды на скидку в процентах 2026-01-19 03:50:33 +05:00
31 changed files with 1460 additions and 109 deletions
+1 -1
View File
@@ -16,4 +16,4 @@ __pycache__/
*.pid
locales/ru_backup.json
locales/en_backup.json
db/models_old.py
db/models_old.py
+2
View File
@@ -81,6 +81,8 @@ def build_core_services(
# Wire services that depend on each other
try:
# Allow subscription service to consume promo codes
setattr(subscription_service, "promo_code_service", promo_code_service)
# Attach YooKassa to subscription service for auto-renew charges
setattr(subscription_service, "yookassa_service", yookassa_service)
# Allow panel webhook to trigger renewals through subscription service
+196 -45
View File
@@ -28,25 +28,82 @@ async def create_promo_prompt_handler(callback: types.CallbackQuery,
return
_ = 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 = _(
"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:
await callback.message.edit_text(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
reply_markup=builder.as_markup(),
parse_mode="HTML")
except Exception as e:
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(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
reply_markup=builder.as_markup(),
parse_mode="HTML")
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
@@ -80,19 +137,31 @@ async def process_promo_code_handler(message: types.Message,
return
await state.update_data(promo_code=code_str)
# Step 2: Ask for bonus days
prompt_text = _(
"admin_promo_step2_bonus_days",
code=code_str
)
# Get promo type from state
data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# 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(
prompt_text,
reply_markup=get_back_to_admin_panel_keyboard(current_lang, i18n),
parse_mode="HTML"
)
await state.set_state(AdminStates.waiting_for_promo_bonus_days)
await state.set_state(next_state)
except Exception as e:
logging.error(f"Error processing promo code: {e}")
@@ -119,9 +188,9 @@ async def process_promo_bonus_days_handler(message: types.Message,
"admin_promo_invalid_bonus_days"
))
return
await state.update_data(bonus_days=bonus_days)
# Step 3: Ask for max activations
data = await state.get_data()
prompt_text = _(
@@ -129,14 +198,14 @@ async def process_promo_bonus_days_handler(message: types.Message,
code=data.get("promo_code"),
bonus_days=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"
@@ -146,6 +215,53 @@ async def process_promo_bonus_days_handler(message: types.Message,
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_discount",
code=data.get("promo_code"),
discount_percentage=discount_percentage
)
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
@router.message(AdminStates.waiting_for_promo_max_activations, F.text)
async def process_promo_max_activations_handler(message: types.Message,
@@ -168,15 +284,25 @@ async def process_promo_max_activations_handler(message: types.Message,
return
await state.update_data(max_activations=max_activations)
# Step 4: Ask for validity
data = await state.get_data()
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
promo_type = data.get("promo_type", "bonus_days")
if promo_type == "discount":
prompt_text = _(
"admin_promo_step4_validity_discount",
code=data.get("promo_code"),
discount_percentage=data.get("discount_percentage"),
max_activations=max_activations
)
else:
prompt_text = _(
"admin_promo_step4_validity",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=max_activations
)
# Create keyboard for validity options
builder = InlineKeyboardBuilder()
@@ -240,12 +366,15 @@ async def process_promo_set_validity(callback: types.CallbackQuery,
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
data = await state.get_data()
prompt_text = _(
"admin_promo_enter_validity_days",
code=data.get("promo_code"),
bonus_days=data.get("bonus_days"),
max_activations=data.get("max_activations")
)
promo_type = data.get("promo_type", "bonus_days")
# Display the correct text based on promo type
if promo_type == "discount":
value_info = f"{data.get('discount_percentage')}%"
else:
value_info = f"{data.get('bonus_days')} дней"
prompt_text = f"⏰ Введите количество дней действия промокода (1-365):\n\nКод: <b>{data.get('promo_code')}</b>\n{'Скидка' if promo_type == 'discount' else 'Бонус'}: <b>{value_info}</b>\nМакс. активаций: <b>{data.get('max_activations')}</b>"
try:
await callback.message.edit_text(
@@ -310,40 +439,60 @@ async def create_promo_code_final(callback_or_message,
try:
data = await state.get_data()
promo_type = data.get("promo_type", "bonus_days")
# Prepare promo code data
promo_data = {
"code": data["promo_code"],
"bonus_days": data["bonus_days"],
"promo_type": promo_type,
"max_activations": data["max_activations"],
"current_activations": 0,
"is_active": True,
"created_by_admin_id": callback_or_message.from_user.id,
"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
if data.get("validity_days"):
promo_data["valid_until"] = datetime.now(timezone.utc) + timedelta(days=data["validity_days"])
else:
promo_data["valid_until"] = None
# Create promo code
created_promo = await promo_code_dal.create_promo_code(session, promo_data)
await session.commit()
# 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
valid_until_str = _("admin_promo_unlimited") if not data.get("validity_days") else f"{data['validity_days']} дней"
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data["bonus_days"],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
# Format success message based on type
if promo_type == "discount":
success_text = _(
"admin_promo_created_success_discount",
code=data["promo_code"],
discount_percentage=data['discount_percentage'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
else:
success_text = _(
"admin_promo_created_success",
code=data["promo_code"],
bonus_days=data['bonus_days'],
max_activations=data["max_activations"],
valid_until_str=valid_until_str
)
if hasattr(callback_or_message, 'message'): # CallbackQuery
try:
@@ -385,8 +534,10 @@ async def create_promo_code_final(callback_or_message,
@router.callback_query(
F.data == "admin_action:main",
StateFilter(
AdminStates.waiting_for_promo_type_selection,
AdminStates.waiting_for_promo_code,
AdminStates.waiting_for_promo_bonus_days,
AdminStates.waiting_for_promo_discount_percentage,
AdminStates.waiting_for_promo_max_activations,
AdminStates.waiting_for_promo_validity_days,
),
+70 -17
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"
# 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([
_("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_validity", validity=validity),
_("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)
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(
[_("admin_active_promos_list_header"), ""] + [
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')}"
for p in promo_models
]
)
if not promo_models:
text = f"{_('admin_active_promos_list_header')}\n\n{_('admin_no_active_promos')}"
else:
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.answer()
@@ -300,7 +320,9 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
# CSV headers (forced to English)
writer.writerow([
i18n.gettext(export_lang, "admin_promo_csv_code"),
"Type",
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_current_activations"),
i18n.gettext(export_lang, "admin_promo_csv_status"),
@@ -309,15 +331,22 @@ async def promo_export_all_handler(callback: types.CallbackQuery, i18n_data: dic
i18n.gettext(export_lang, "admin_promo_csv_created_at"),
i18n.gettext(export_lang, "admin_promo_csv_created_by_admin_id"),
])
for promo in all_promos:
# Определяем статус
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
row = [
promo.code,
promo.bonus_days,
promo_type,
bonus_days_val,
discount_val,
promo.max_activations,
promo.current_activations,
status_text,
@@ -374,13 +403,26 @@ async def promo_edit_select_handler(callback: types.CallbackQuery, i18n_data: di
return
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs)
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.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=_("admin_promo_edit_discount_percentage"), 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_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}"))
await callback.message.edit_text(_("admin_promo_edit_select_field"), reply_markup=builder.as_markup())
await callback.answer()
@@ -394,14 +436,19 @@ async def promo_edit_field_handler(callback: types.CallbackQuery, state: FSMCont
action, field, promo_id_str = callback.data.split(":")
await state.update_data(promo_id=int(promo_id_str), field_to_edit=field)
prompts = {
"bonus_days": "admin_promo_prompt_bonus_days",
"discount_percentage": "admin_promo_prompt_discount_percentage",
"max_activations": "admin_promo_prompt_max_activations",
"valid_until": "admin_promo_prompt_validity_days"
}
prompt_key = prompts.get(field, "error_occurred_try_again")
prompt_text = _(prompt_key)
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()
@router.message(StateFilter(AdminStates.waiting_for_promo_edit_details))
@@ -418,9 +465,15 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
try:
value = message.text
update_data = {}
if field == "bonus_days":
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":
update_data["max_activations"] = int(value)
elif field == "valid_until":
@@ -433,7 +486,7 @@ async def process_promo_edit_details(message: types.Message, state: FSMContext,
if await promo_code_dal.update_promo_code(session, promo_id, update_data):
await session.commit()
await message.answer(_("admin_promo_edit_success"))
# Reset state and show updated details
await state.clear()
text, keyboard = await get_promo_detail_text_and_keyboard(promo_id, session, i18n, current_lang)
+56 -9
View File
@@ -123,13 +123,15 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
code=hcode(code_input.upper()))
reply_markup = get_back_to_main_menu_markup(current_lang, i18n)
else:
# Try as BONUS code first (existing behavior)
success, result = await promo_code_service.apply_promo_code(
session, user.id, code_input, current_lang)
if success:
# Bonus code success
await session.commit()
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
@@ -151,15 +153,60 @@ async def process_promo_code_input(message: types.Message, state: FSMContext,
connect_button_url=connect_button_url,
)
else:
await session.rollback()
logging.info(
f"Promo code '{code_input}' application failed for user {user.id}. Reason: {result}"
)
response_to_user_text = result
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
# Bonus code failed, try as DISCOUNT code
success_discount, result_discount = await promo_code_service.apply_discount_promo_code(
session, user.id, code_input, current_lang
)
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
# Send notification about discount promo activation
if settings.LOG_PROMO_ACTIVATIONS:
try:
from bot.services.notification_service import NotificationService
notification_service = NotificationService(bot, settings, i18n)
await notification_service.notify_discount_promo_activation(
user_id=user.id,
promo_code=code_input.upper(),
discount_percentage=discount_pct,
username=user.username
)
except Exception as e:
logging.error(f"Failed to send discount promo activation notification: {e}")
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}. "
f"Bonus reason: {result}. Discount reason: {result_discount}"
)
bonus_not_found_text = _(
"promo_code_not_found", code=code_input.upper()
)
discount_not_found_text = _(
"promo_code_not_found_or_not_discount", code=code_input.upper()
)
if result != bonus_not_found_text and result_discount == discount_not_found_text:
response_to_user_text = result
else:
response_to_user_text = result_discount # Prefer the discount attempt error
reply_markup = get_back_to_main_menu_markup(
current_lang, i18n
)
await message.answer(
response_to_user_text,
reply_markup=reply_markup,
+1 -1
View File
@@ -675,7 +675,7 @@ async def main_action_callback_handler(
if action == "subscribe":
await user_subscription_handlers.display_subscription_options(
callback, i18n_data, settings, session)
callback, i18n_data, settings, session, promo_code_service=promo_code_service)
elif action == "my_subscription":
await user_subscription_handlers.my_subscription_command_handler(
callback, i18n_data, settings, panel_service, subscription_service,
+45 -5
View File
@@ -1,5 +1,6 @@
import hashlib
import logging
import math
from aiogram import Router, F, types, Bot
from aiogram.filters import Command
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup, WebAppInfo
@@ -39,7 +40,13 @@ def _hwid_callback_token(hwid: Optional[str]) -> str:
return hashlib.sha256(hwid_str.encode()).hexdigest()[:32]
async def display_subscription_options(event: Union[types.Message, types.CallbackQuery], i18n_data: dict, settings: Settings, session: AsyncSession):
async def display_subscription_options(
event: Union[types.Message, types.CallbackQuery],
i18n_data: dict,
settings: Settings,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -61,21 +68,46 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
stars_traffic_packages = getattr(settings, "stars_traffic_packages", {}) or {}
traffic_mode = bool(getattr(settings, "traffic_sale_mode", False) or stars_traffic_packages)
options_are_stars = False
if traffic_mode:
if traffic_packages:
options = traffic_packages
elif stars_traffic_packages:
options = stars_traffic_packages
currency_symbol_val = ""
options_are_stars = True
else:
options = {}
else:
options = settings.subscription_options
if options:
display_options = options
if options and promo_code_service:
try:
active_discount_info = await promo_code_service.get_user_active_discount(
session, event.from_user.id
)
except Exception:
active_discount_info = None
if active_discount_info:
discount_pct, _promo_code = active_discount_info
discounted_options = {}
for period, price in options.items():
if price is None:
discounted_options[period] = price
else:
discounted_price, _ = promo_code_service.calculate_discounted_price(
price, discount_pct
)
if options_are_stars:
discounted_price = math.ceil(discounted_price)
discounted_options[period] = discounted_price
display_options = discounted_options
if display_options:
text_content = get_text("select_traffic_package") if traffic_mode else get_text("select_subscription_period")
reply_markup = get_subscription_options_keyboard(
options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
display_options, currency_symbol_val, current_lang, i18n, traffic_mode=traffic_mode
)
else:
text_content = get_text("no_subscription_options_available")
@@ -104,8 +136,16 @@ async def display_subscription_options(event: Union[types.Message, types.Callbac
@router.callback_query(F.data == "main_action:subscribe")
async def reshow_subscription_options_callback(callback: types.CallbackQuery, i18n_data: dict, settings: Settings, session: AsyncSession):
await display_subscription_options(callback, i18n_data, settings, session)
async def reshow_subscription_options_callback(
callback: types.CallbackQuery,
i18n_data: dict,
settings: Settings,
session: AsyncSession,
promo_code_service=None,
):
await display_subscription_options(
callback, i18n_data, settings, session, promo_code_service=promo_code_service
)
async def my_subscription_command_handler(
@@ -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
@@ -18,6 +18,7 @@ async def pay_crypto_callback_handler(
i18n_data: dict,
session: AsyncSession,
cryptopay_service: CryptoPayService,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -65,6 +66,7 @@ async def pay_crypto_callback_handler(
amount=price_amount,
description=payment_description,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if invoice_url:
@@ -21,6 +21,7 @@ async def pay_fk_callback_handler(
i18n_data: dict,
freekassa_service: FreeKassaService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -68,14 +69,19 @@ async def pay_fk_callback_handler(
)
currency_code = getattr(freekassa_service, "default_currency", None) or settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_freekassa",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "freekassa",
"promo_code_id": None,
}
try:
@@ -108,6 +114,8 @@ async def pay_fk_callback_handler(
extra_params={
"us_method": freekassa_service.payment_method_id,
},
promo_code_service=promo_code_service,
session=session,
)
if success:
@@ -21,6 +21,7 @@ async def pay_platega_callback_handler(
i18n_data: dict,
platega_service: PlategaService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -68,14 +69,19 @@ async def pay_platega_callback_handler(
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_platega",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "platega",
"promo_code_id": None,
}
try:
@@ -114,6 +120,8 @@ async def pay_platega_callback_handler(
currency=currency_code,
description=payment_description,
payload=payload_meta,
promo_code_service=promo_code_service,
session=session,
)
if success:
@@ -20,6 +20,7 @@ async def pay_severpay_callback_handler(
i18n_data: dict,
severpay_service: SeverPayService,
session: AsyncSession,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -67,14 +68,19 @@ async def pay_severpay_callback_handler(
)
currency_code = settings.DEFAULT_CURRENCY_SYMBOL or "RUB"
# Price is already discounted at payments_subscription.py stage
# Service will handle discount metadata if needed
payment_record_payload = {
"user_id": user_id,
"amount": price_rub,
"original_amount": None,
"discount_applied": None,
"currency": currency_code,
"status": "pending_severpay",
"description": payment_description,
"subscription_duration_months": int(months),
"provider": "severpay",
"promo_code_id": None,
}
try:
@@ -103,6 +109,8 @@ async def pay_severpay_callback_handler(
amount=price_rub,
currency=currency_code,
description=payment_description,
promo_code_service=promo_code_service,
session=session,
)
if success:
@@ -19,6 +19,7 @@ async def pay_stars_callback_handler(
i18n_data: dict,
session: AsyncSession,
stars_service: StarsService,
promo_code_service=None,
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -66,6 +67,7 @@ async def pay_stars_callback_handler(
stars_price=stars_price,
description=payment_description,
sale_mode=sale_mode,
promo_code_service=promo_code_service,
)
if payment_db_id:
@@ -1,4 +1,5 @@
import logging
import math
from typing import Optional
from aiogram import F, Router, types
@@ -17,6 +18,7 @@ async def select_subscription_period_callback_handler(
settings: Settings,
i18n_data: dict,
session: AsyncSession,
promo_code_service=None, # Injected from dispatcher
):
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
@@ -49,6 +51,48 @@ async def select_subscription_period_callback_handler(
stars_price = stars_price_source.get(months)
currency_symbol_val = settings.DEFAULT_CURRENCY_SYMBOL
# Check for active discount and apply if exists
discount_text = ""
if promo_code_service and (price_rub is not None or stars_price is not None):
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
if price_rub is not None:
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,
currency_symbol=currency_symbol_val,
)
if stars_price is not None:
original_stars_price = stars_price
discounted_stars_price, _ = promo_code_service.calculate_discounted_price(
float(stars_price), discount_pct
)
discounted_stars_price = math.ceil(discounted_stars_price)
stars_price = discounted_stars_price
if not discount_text:
discount_amt = original_stars_price - discounted_stars_price
discount_text = get_text(
"active_discount_notice",
code=promo_code,
discount_pct=discount_pct,
original_price=original_stars_price,
discounted_price=discounted_stars_price,
discount_amount=discount_amt,
currency_symbol="",
)
if price_rub is None:
if traffic_mode and not price_source and stars_price is not None:
currency_methods_enabled = any(
@@ -83,6 +127,9 @@ async def select_subscription_period_callback_handler(
return
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(
months,
price_rub,
@@ -13,7 +13,7 @@ from bot.keyboards.inline.user_keyboards import (
from bot.middlewares.i18n import JsonI18n
from bot.services.yookassa_service import YooKassaService
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")
@@ -60,6 +60,7 @@ async def _initiate_yk_payment(
settings: Settings,
session: AsyncSession,
yookassa_service: YooKassaService,
promo_code_service, # NEW: Added promo_code_service
i18n: Optional[JsonI18n],
current_lang: str,
get_text,
@@ -77,6 +78,46 @@ async def _initiate_yk_payment(
if not callback.message:
return False
# Check for active discount to save metadata (price already discounted from previous step)
original_price = None
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:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
active_promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
price_source = (
getattr(settings, "traffic_packages", {}) or {}
if sale_mode == "traffic"
else (settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_price = fallback_original
discount_amount = original_price - price_rub
logging.info(
f"Recording {discount_pct}% discount for YooKassa payment: "
f"original {original_price:.2f} -> final {price_rub}"
)
else:
logging.warning(
"YooKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_price = price_rub / denominator
discount_amount = original_price - price_rub
logging.info(
f"Recording {discount_pct}% discount for YooKassa payment: "
f"original {original_price:.2f} -> final {price_rub}"
)
payment_description = (
get_text("payment_description_traffic", traffic_gb=_format_value(months))
if sale_mode == "traffic"
@@ -84,11 +125,14 @@ async def _initiate_yk_payment(
)
payment_record_data = {
"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,
"status": "pending_yookassa",
"description": payment_description,
"subscription_duration_months": int(months),
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
}
db_payment_record = None
@@ -319,7 +363,7 @@ async def _initiate_yk_payment(
@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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -417,6 +461,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
@@ -435,7 +480,7 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti
@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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -491,6 +536,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
@@ -653,7 +699,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set
@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)
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
@@ -752,6 +798,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett
settings=settings,
session=session,
yookassa_service=yookassa_service,
promo_code_service=promo_code_service,
i18n=i18n,
current_lang=current_lang,
get_text=get_text,
+53 -1
View File
@@ -65,23 +65,68 @@ class CryptoPayService:
amount: float,
description: str,
sale_mode: str = "subscription",
promo_code_service=None,
) -> Optional[str]:
if not self.configured or not self.client:
logging.error("CryptoPayService not configured")
return None
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if sale_mode == "traffic"
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for CryptoPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"CryptoPay discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for CryptoPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Create pending payment in DB and commit to persist
try:
payment_record = await payment_dal.create_payment_record(
session,
{
"user_id": user_id,
"amount": float(amount),
"amount": amount,
"original_amount": original_amount,
"discount_applied": discount_amount,
"currency": self.settings.CRYPTOPAY_ASSET,
"status": "pending_cryptopay",
"description": description,
"subscription_duration_months": int(months),
"provider": "cryptopay",
"promo_code_id": promo_code_id,
},
)
await session.commit()
@@ -153,6 +198,12 @@ class CryptoPayService:
async with async_session_factory() as session:
try:
# Fetch payment record to get promo_code_id
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
if not payment_record:
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
return
await payment_dal.update_provider_payment_and_status(
session,
payment_db_id,
@@ -165,6 +216,7 @@ class CryptoPayService:
int(months) if sale_mode != "traffic" else 0,
float(invoice.amount),
payment_db_id,
promo_code_id_from_payment=payment_record.promo_code_id,
provider="cryptopay",
sale_mode=sale_mode,
traffic_gb=traffic_gb if sale_mode == "traffic" else None,
+60
View File
@@ -78,11 +78,70 @@ class FreeKassaService:
ip_address: Optional[str] = None,
payment_method_id: Optional[int] = None,
extra_params: Optional[Dict[str, Any]] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("FreeKassaService is not configured. Cannot create order.")
return False, {"message": "service_not_configured"}
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if traffic_mode
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for FreeKassa payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"FreeKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for FreeKassa payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"FreeKassa: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
ip_address = ip_address or self.server_ip
if not ip_address:
logging.error("FreeKassaService: payment IP is required but not configured.")
@@ -293,6 +352,7 @@ class FreeKassaService:
int(months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="freekassa",
sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None,
+30 -4
View File
@@ -271,15 +271,15 @@ class NotificationService:
"""Send notification about promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=user_id,
username=username,
)
message = _(
"log_promo_activation",
user_display=user_display,
@@ -287,7 +287,33 @@ class NotificationService:
bonus_days=bonus_days,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
async def notify_discount_promo_activation(self, user_id: int, promo_code: str, discount_percentage: int,
username: Optional[str] = None):
"""Send notification about discount promo code activation"""
if not self.settings.LOG_PROMO_ACTIVATIONS:
return
admin_lang = self.settings.DEFAULT_LANGUAGE
_ = lambda k, **kw: self.i18n.gettext(admin_lang, k, **kw) if self.i18n else k
user_display = self._format_user_display(
user_id=user_id,
username=username,
)
message = _(
"log_promo_discount_activation",
user_display=user_display,
promo_code=promo_code,
discount_percentage=discount_percentage,
timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S")
)
# Send to log channel
profile_keyboard = self._build_profile_keyboard(_, user_id)
await self._send_to_log_channel(message, reply_markup=profile_keyboard)
+62 -2
View File
@@ -76,12 +76,71 @@ class PlategaService:
currency: Optional[str],
description: str,
payload: Optional[str] = None,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("PlategaService is not configured. Cannot create transaction.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if traffic_mode
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for Platega payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"Platega discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for Platega payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"Platega: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
http_session = await self._get_session()
url = f"{self.base_url}/transaction/process"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
@@ -98,7 +157,7 @@ class PlategaService:
clean_body = {k: v for k, v in body.items() if v not in (None, "")}
try:
async with session.post(url, json=clean_body, headers=self._auth_headers) as response:
async with http_session.post(url, json=clean_body, headers=self._auth_headers) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
@@ -189,6 +248,7 @@ class PlategaService:
int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="platega",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
+211 -3
View File
@@ -1,12 +1,12 @@
import logging
from datetime import datetime
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Optional, Tuple, Dict
from aiogram import Bot
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, payment_dal
from db.models import PromoCode, User
from .subscription_service import SubscriptionService
@@ -34,7 +34,7 @@ class PromoCodeService:
_ = lambda k, **kw: self.i18n.gettext(user_lang, k, **kw)
code_input_upper = code_input.strip().upper()
promo_data = await promo_code_dal.get_active_promo_code_by_code_str(
promo_data = await promo_code_dal.get_active_bonus_promo_code_by_code_str(
session, code_input_upper)
if not promo_data:
@@ -83,3 +83,211 @@ class PromoCodeService:
return False, _("error_applying_promo_bonus")
else:
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
# Check if promo code has expired
if promo.valid_until and promo.valid_until <= datetime.now(timezone.utc):
# Promo code expired - clear the discount
logging.info(
f"Promo code {promo.code} expired (valid_until: {promo.valid_until}). "
f"Clearing active discount for user {user_id}"
)
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: link activation to payment, increment usage, clear active discount.
Call this AFTER successful payment.
"""
payment_record = await payment_dal.get_payment_by_db_id(session, payment_id)
if not payment_record:
logging.warning(
"Payment %s not found for discount consumption (user %s).",
payment_id,
user_id,
)
return False
if not payment_record.discount_applied:
return False
promo_code_id = payment_record.promo_code_id
if not promo_code_id:
logging.warning(
"Payment %s for user %s has discount_applied but no promo_code_id.",
payment_id,
user_id,
)
return False
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount and active_discount.promo_code_id != promo_code_id:
logging.info(
"Active discount promo %s differs from payment promo %s; leaving active discount intact.",
active_discount.promo_code_id,
promo_code_id,
)
active_discount = None
existing_activation = await promo_code_dal.get_user_activation_for_promo(
session, promo_code_id, user_id
)
if existing_activation:
if existing_activation.payment_id is None:
updated_payment = await promo_code_dal.set_activation_payment_id(
session, promo_code_id, user_id, payment_id
)
if updated_payment:
logging.info(
"Linked discount promo %s activation to payment %s for user %s.",
promo_code_id,
payment_id,
user_id,
)
else:
activation_recorded = await promo_code_dal.record_promo_activation(
session,
promo_code_id,
user_id,
payment_id=payment_id,
)
if not activation_recorded:
logging.error(
"Failed to record discount activation for user %s, promo %s.",
user_id,
promo_code_id,
)
return False
promo_incremented = await promo_code_dal.increment_promo_code_usage(
session, promo_code_id, allow_overflow=True
)
if not promo_incremented:
logging.error(
"Failed to increment discount usage for user %s, promo %s.",
user_id,
promo_code_id,
)
return False
if active_discount and active_discount.promo_code_id == promo_code_id:
await active_discount_dal.clear_active_discount(session, user_id)
await session.flush()
logging.info(
"Discount consumed for user %s, promo %s, payment %s",
user_id,
promo_code_id,
payment_id,
)
return True
+62 -2
View File
@@ -99,12 +99,71 @@ class SeverPayService:
amount: float,
currency: Optional[str],
description: str,
promo_code_service=None,
session=None,
) -> Tuple[bool, Dict[str, Any]]:
if not self.configured:
logging.error("SeverPayService is not configured. Cannot create payment.")
return False, {"message": "service_not_configured"}
session = await self._get_session()
# Check for active discount to save metadata (price already discounted from previous step)
original_amount = None
discount_amount = None
promo_code_id = None
if promo_code_service and session:
from db.dal import active_discount_dal
active_discount = await active_discount_dal.get_active_discount(session, user_id)
if active_discount:
# Price is already discounted, calculate original price backwards
discount_pct = active_discount.discount_percentage
promo_code_id = active_discount.promo_code_id
denominator = 1 - discount_pct / 100
if denominator <= 0:
traffic_mode = bool(getattr(self.settings, "traffic_sale_mode", False))
price_source = (
getattr(self.settings, "traffic_packages", {}) or {}
if traffic_mode
else (self.settings.subscription_options or {})
)
fallback_original = price_source.get(months)
if fallback_original is not None:
original_amount = fallback_original
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for SeverPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
else:
logging.warning(
"SeverPay discount %s%% has invalid denominator and no fallback price for months=%s.",
discount_pct,
months,
)
else:
original_amount = amount / denominator
discount_amount = original_amount - amount
logging.info(
f"Recording {discount_pct}% discount for SeverPay payment: "
f"original {original_amount:.2f} -> final {amount}"
)
# Update payment record with discount metadata
try:
await payment_dal.update_payment_discount_info(
session,
payment_db_id,
original_amount,
discount_amount,
promo_code_id,
)
await session.commit()
except Exception as e_update:
logging.warning(
f"SeverPay: failed to update discount metadata for payment {payment_db_id}: {e_update}"
)
http_session = await self._get_session()
url = f"{self.base_url}/payin/create"
currency_code = (currency or self.settings.DEFAULT_CURRENCY_SYMBOL or "RUB").upper()
amount_str = self._format_amount(amount)
@@ -124,7 +183,7 @@ class SeverPayService:
signed_body = self._build_signed_body(body)
try:
async with session.post(url, json=signed_body) as response:
async with http_session.post(url, json=signed_body) as response:
response_text = await response.text()
try:
response_data = json.loads(response_text) if response_text else {}
@@ -207,6 +266,7 @@ class SeverPayService:
int(payment_months) if sale_mode != "traffic" else 0,
float(payment.amount),
payment.payment_id,
promo_code_id_from_payment=payment.promo_code_id,
provider="severpay",
sale_mode=sale_mode,
traffic_gb=payment_months if sale_mode == "traffic" else None,
+32 -1
View File
@@ -1,4 +1,5 @@
import logging
import math
from typing import Optional
from aiogram import Bot, types
@@ -27,15 +28,40 @@ class StarsService:
self.referral_service = referral_service
async def create_invoice(self, session: AsyncSession, user_id: int, months: int,
stars_price: int, description: str, sale_mode: str = "subscription") -> Optional[int]:
stars_price: int, description: str, sale_mode: str = "subscription",
promo_code_service=None) -> Optional[int]:
# Apply active discount if exists (Stars use ceiling rounding)
original_stars_price = stars_price
discount_amount_stars = None
promo_code_id = None
if promo_code_service:
# Import here to avoid circular import
from bot.handlers.user.subscription.payment_discount_helper import apply_discount_to_payment
# Apply discount and round up using ceiling
final_price_float, discount_float, promo_code_id = await apply_discount_to_payment(
session, user_id, float(stars_price), promo_code_service
)
if discount_float:
# Apply ceiling rounding for fractional Stars amounts
stars_price = math.ceil(final_price_float)
discount_amount_stars = original_stars_price - stars_price
logging.info(
f"Stars discount applied: {original_stars_price} -> {final_price_float:.2f} -> {stars_price} (ceiling)"
)
payment_record_data = {
"user_id": user_id,
"amount": float(stars_price),
"original_amount": float(original_stars_price) if discount_amount_stars else None,
"discount_applied": float(discount_amount_stars) if discount_amount_stars else None,
"currency": "XTR",
"status": "pending_stars",
"description": description,
"subscription_duration_months": int(months),
"provider": "telegram_stars",
"promo_code_id": promo_code_id,
}
try:
db_payment_record = await payment_dal.create_payment_record(
@@ -72,6 +98,10 @@ class StarsService:
stars_amount: int,
i18n_data: dict,
sale_mode: str = "subscription") -> None:
# Fetch payment record to get promo_code_id
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
promo_code_id_from_payment = payment_record.promo_code_id if payment_record else None
try:
await payment_dal.update_provider_payment_and_status(
session, payment_db_id,
@@ -91,6 +121,7 @@ class StarsService:
int(months) if sale_mode != "traffic" else 0,
float(stars_amount),
payment_db_id,
promo_code_id_from_payment=promo_code_id_from_payment,
provider="telegram_stars",
sale_mode=sale_mode,
traffic_gb=months if sale_mode == "traffic" else None,
+18 -2
View File
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
from aiogram import Bot
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, user_billing_dal
from bot.utils.date_utils import add_months
from bot.utils.config_link import prepare_config_links
from db.models import User, Subscription
@@ -615,7 +615,7 @@ class SubscriptionService:
)
if activation:
await promo_code_dal.increment_promo_code_usage(
session, promo_code_id_from_payment
session, promo_code_id_from_payment, allow_overflow=True
)
else:
logging.warning(
@@ -691,6 +691,22 @@ class SubscriptionService:
final_subscription_url = updated_panel_user.get("subscriptionUrl")
final_panel_short_uuid = updated_panel_user.get("shortUuid", panel_short_uuid)
# Consume discount promo code if payment had one
try:
promo_code_service = getattr(self, "promo_code_service", None)
if not promo_code_service:
from .promo_code_service import PromoCodeService
promo_code_service = PromoCodeService(
self.settings, self, self.bot, self.i18n
)
await promo_code_service.consume_discount(session, user_id, 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 {
"subscription_id": new_or_updated_sub.subscription_id,
"end_date": final_end_date,
+2
View File
@@ -6,8 +6,10 @@ class AdminStates(StatesGroup):
waiting_for_broadcast_message = State()
confirming_broadcast = State()
waiting_for_promo_details = State()
waiting_for_promo_type_selection = State()
waiting_for_promo_code = State()
waiting_for_promo_bonus_days = State()
waiting_for_promo_discount_percentage = State()
waiting_for_promo_max_activations = State()
waiting_for_promo_validity_days = State()
waiting_for_promo_edit_details = State()
+88
View File
@@ -0,0 +1,88 @@
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
async def clear_active_discounts_by_promo_code(
session: AsyncSession,
promo_code_id: int
) -> int:
"""
Clear all active discounts associated with a specific promo code.
Returns the number of discounts cleared.
"""
stmt = delete(ActiveDiscount).where(ActiveDiscount.promo_code_id == promo_code_id)
result = await session.execute(stmt)
await session.flush()
count = result.rowcount
if count > 0:
logging.info(f"Cleared {count} active discount(s) for promo_code_id={promo_code_id}")
return count
+26
View File
@@ -176,6 +176,32 @@ async def update_provider_payment_and_status(
return payment
async def update_payment_discount_info(
session: AsyncSession,
payment_db_id: int,
original_amount: Optional[float],
discount_applied: Optional[float],
promo_code_id: Optional[int]) -> Optional[Payment]:
"""Update payment record with discount metadata."""
payment = await get_payment_by_db_id(session, payment_db_id)
if payment:
payment.original_amount = original_amount
payment.discount_applied = discount_applied
payment.promo_code_id = promo_code_id
payment.updated_at = func.now()
await session.flush()
await session.refresh(payment)
logging.info(
f"Payment record {payment.payment_id} updated with discount info: "
f"original {original_amount}, discount {discount_applied}, promo {promo_code_id}"
)
else:
logging.warning(
f"Payment record with DB ID {payment_db_id} not found for discount info update."
)
return payment
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
"""Get comprehensive financial statistics."""
from datetime import datetime, timedelta
+98 -9
View File
@@ -43,6 +43,34 @@ async def get_active_promo_code_by_code_str(
return result.scalar_one_or_none()
async def get_active_bonus_promo_code_by_code_str(
session: AsyncSession, code_str: str) -> Optional[PromoCode]:
"""Get active bonus_days-type promo code by code string"""
stmt = select(PromoCode).where(
PromoCode.code == code_str.upper(),
PromoCode.promo_type == "bonus_days",
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_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,
limit: int = 20,
offset: int = 0) -> List[PromoCode]:
@@ -104,36 +132,78 @@ async def update_promo_code(session: AsyncSession, promo_id: int,
async def delete_promo_code(session: AsyncSession, promo_id: int) -> Optional[PromoCode]:
from db.dal import active_discount_dal
promo = await get_promo_code_by_id(session, promo_id)
if not promo:
return None
# First, delete related activations due to foreign key constraint
# 1. Clear all active discounts referencing this promo code
await active_discount_dal.clear_active_discounts_by_promo_code(session, promo_id)
# 2. Set promo_code_id to NULL in payments table to avoid FK violation
stmt = update(Payment).where(Payment.promo_code_id == promo_id).values(promo_code_id=None)
await session.execute(stmt)
# 3. Delete related activations
activations = await get_promo_activations_by_code_id(session, promo_id)
for activation in activations:
await session.delete(activation)
# 4. Delete the promo code itself
await session.delete(promo)
await session.flush()
logging.info(f"Promo code '{promo.code}' (ID: {promo_id}) deleted successfully")
return promo
async def increment_promo_code_usage(
session: AsyncSession, promo_code_id: int) -> Optional[PromoCode]:
session: AsyncSession,
promo_code_id: int,
allow_overflow: bool = False) -> Optional[PromoCode]:
conditions = [PromoCode.promo_code_id == promo_code_id]
if not allow_overflow:
conditions.append(PromoCode.current_activations < PromoCode.max_activations)
stmt = (
update(PromoCode)
.where(*conditions)
.values(current_activations=PromoCode.current_activations + 1)
)
result = await session.execute(stmt)
if result.rowcount and result.rowcount > 0:
await session.flush()
return await get_promo_code_by_id(session, promo_code_id)
promo = await get_promo_code_by_id(session, promo_code_id)
if promo:
if promo.current_activations < promo.max_activations:
promo.current_activations += 1
await session.flush()
await session.refresh(promo)
return promo
if allow_overflow:
logging.warning(
f"Failed to increment promo usage for promo {promo.code} (ID: {promo_code_id})."
)
else:
logging.warning(
f"Promo code {promo.code} (ID: {promo_code_id}) already reached max activations."
)
return None
return None
async def decrement_promo_code_usage(
session: AsyncSession, promo_code_id: int) -> bool:
stmt = (
update(PromoCode)
.where(
PromoCode.promo_code_id == promo_code_id,
PromoCode.current_activations > 0,
)
.values(current_activations=PromoCode.current_activations - 1)
)
result = await session.execute(stmt)
await session.flush()
return bool(result.rowcount and result.rowcount > 0)
async def get_user_activation_for_promo(
session: AsyncSession, promo_code_id: int,
user_id: int) -> Optional[PromoCodeActivation]:
@@ -189,3 +259,22 @@ async def record_promo_activation(
f"Promo code {promo_code_id} activated by user {user_id}. Activation ID: {new_activation.activation_id}"
)
return new_activation
async def set_activation_payment_id(
session: AsyncSession,
promo_code_id: int,
user_id: int,
payment_id: int) -> bool:
stmt = (
update(PromoCodeActivation)
.where(
PromoCodeActivation.promo_code_id == promo_code_id,
PromoCodeActivation.user_id == user_id,
PromoCodeActivation.payment_id == None,
)
.values(payment_id=payment_id)
)
result = await session.execute(stmt)
await session.flush()
return bool(result.rowcount and result.rowcount > 0)
+108
View File
@@ -112,6 +112,104 @@ def _migration_0003_normalize_referral_codes(connection: Connection) -> None:
)
)
def _migration_0004_add_discount_promo_codes(connection: Connection) -> None:
inspector = inspect(connection)
# 1. Добавить поля в payments
payment_columns: Set[str] = {col["name"] for col in inspector.get_columns("payments")}
if "original_amount" not in payment_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN original_amount FLOAT"))
if "discount_applied" not in payment_columns:
connection.execute(text("ALTER TABLE payments ADD COLUMN discount_applied FLOAT"))
# 2. Модифицировать promo_codes
promo_columns: Set[str] = {col["name"] for col in inspector.get_columns("promo_codes")}
if "promo_type" not in promo_columns:
connection.execute(
text(
"ALTER TABLE promo_codes ADD COLUMN promo_type VARCHAR NOT NULL DEFAULT 'bonus_days'"
)
)
if "discount_percentage" not in promo_columns:
connection.execute(
text("ALTER TABLE promo_codes ADD COLUMN discount_percentage INTEGER")
)
# Изменить bonus_days на nullable (если еще не nullable)
connection.execute(
text("ALTER TABLE promo_codes ALTER COLUMN bonus_days DROP NOT NULL")
)
# Создать индекс на promo_type
connection.execute(
text(
"CREATE INDEX IF NOT EXISTS idx_promo_codes_promo_type ON promo_codes (promo_type)"
)
)
# 3. Создать таблицу active_discounts
connection.execute(
text(
"""
CREATE TABLE IF NOT EXISTS active_discounts (
user_id BIGINT PRIMARY KEY,
promo_code_id INTEGER NOT NULL,
discount_percentage INTEGER NOT NULL,
activated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT fk_active_discounts_user
FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE,
CONSTRAINT fk_active_discounts_promo_code
FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE
)
"""
)
)
def _migration_0005_fix_active_discounts_fk_cascade(connection: Connection) -> None:
inspector = inspect(connection)
if not inspector.has_table("active_discounts"):
return
connection.execute(
text(
"DELETE FROM active_discounts ad "
"WHERE NOT EXISTS (SELECT 1 FROM users u WHERE u.user_id = ad.user_id) "
"OR NOT EXISTS (SELECT 1 FROM promo_codes p WHERE p.promo_code_id = ad.promo_code_id)"
)
)
connection.execute(
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS active_discounts_user_id_fkey")
)
connection.execute(
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS fk_active_discounts_user")
)
connection.execute(
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS active_discounts_promo_code_id_fkey")
)
connection.execute(
text("ALTER TABLE active_discounts DROP CONSTRAINT IF EXISTS fk_active_discounts_promo_code")
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"ADD CONSTRAINT fk_active_discounts_user "
"FOREIGN KEY (user_id) REFERENCES users (user_id) ON DELETE CASCADE"
)
)
connection.execute(
text(
"ALTER TABLE active_discounts "
"ADD CONSTRAINT fk_active_discounts_promo_code "
"FOREIGN KEY (promo_code_id) REFERENCES promo_codes (promo_code_id) ON DELETE CASCADE"
)
)
MIGRATIONS: List[Migration] = [
Migration(
id="0001_add_channel_subscription_fields",
@@ -128,6 +226,16 @@ MIGRATIONS: List[Migration] = [
description="Normalize referral codes to uppercase for consistent lookups",
upgrade=_migration_0003_normalize_referral_codes,
),
Migration(
id="0004_add_discount_promo_codes",
description="Add support for percentage discount promo codes",
upgrade=_migration_0004_add_discount_promo_codes,
),
Migration(
id="0005_fix_active_discounts_fk_cascade",
description="Ensure active_discounts FKs cascade on user/promo delete",
upgrade=_migration_0005_fix_active_discounts_fk_cascade,
),
]
+38 -2
View File
@@ -100,7 +100,12 @@ class Payment(Base):
provider_payment_id = Column(String, unique=True, nullable=True)
provider = Column(String, nullable=False, default="yookassa", index=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)
status = Column(String, nullable=False, index=True)
description = Column(String, nullable=True)
@@ -154,7 +159,17 @@ class PromoCode(Base):
promo_code_id = Column(Integer, primary_key=True, autoincrement=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)
current_activations = Column(Integer, default=0)
is_active = Column(Boolean, default=True)
@@ -191,6 +206,27 @@ class PromoCodeActivation(Base):
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", ondelete="CASCADE"),
primary_key=True,
)
promo_code_id = Column(
Integer,
ForeignKey("promo_codes.promo_code_id", ondelete="CASCADE"),
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):
__tablename__ = "message_logs"
+16
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_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 👇",
"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}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Savings: {discount_amount}{currency_symbol}",
"error_applying_promo_bonus": "Failed to apply promo bonus. Please try again later or contact support.",
"promo_input_cancelled_short": "Promo code entry cancelled.",
"trial_feature_disabled": "Free trial is currently unavailable.",
@@ -169,6 +173,7 @@
"admin_promo_invalid_max_activations": "Max activations must be a positive number.",
"admin_promo_invalid_validity_days": "Validity period (in days) must be a positive number.",
"admin_promo_created_success": "✅ Promo code <code>{code}</code> created successfully!\nBonus: {bonus_days} days\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_created_success_discount": "✅ Promo code <code>{code}</code> created successfully!\nDiscount: {discount_percentage}%\nMax uses: {max_activations}\nValid until: {valid_until_str}.",
"admin_promo_set_validity_days": "⏰ Set validity (days)",
"admin_back_to_panel": "⬅️ Back to panel",
"admin_promo_unlimited": "♾️ Unlimited",
@@ -203,11 +208,13 @@
"csv_no": "No",
"admin_promo_edit_select_field": "Select a field to edit:",
"admin_promo_prompt_bonus_days": "Enter the new number of bonus days:",
"admin_promo_prompt_discount_percentage": "Enter the new discount percentage (1-100):",
"admin_promo_prompt_max_activations": "Enter the new maximum number of activations:",
"admin_promo_prompt_validity_days": "Enter the new validity period in days (0 for indefinite):",
"admin_promo_edit_success": "Promo code updated successfully.",
"admin_promo_invalid_input": "Invalid input, please try again.",
"admin_promo_edit_bonus_days": "🎁 Bonus Days",
"admin_promo_edit_discount_percentage": "💰 Discount %",
"admin_promo_edit_max_activations": "🔢 Max Activations",
"admin_promo_edit_validity": "⏰ Validity",
"admin_ban_user_prompt": "Enter user ID or @username to ban:",
@@ -308,6 +315,7 @@
"log_payment_received": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n📅 Period: <b>{months} mo.</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Payment Received</b>\n\n👤 User: {user_display}\n💰 Amount: <b>{amount} {currency}</b>\n🗂 Traffic: <b>{traffic_gb} GB</b>\n🏦 Provider: {payment_provider}\n🕐 Time: {timestamp}",
"log_promo_activation": "🎁 <b>Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n🎯 Bonus: <b>+{bonus_days}d</b>\n🕐 Time: {timestamp}",
"log_promo_discount_activation": "💰 <b>Discount Promo Code Activated</b>\n\n👤 User: {user_display}\n🏷 Code: <code>{promo_code}</code>\n💵 Discount: <b>{discount_percentage}%</b>\n🕐 Time: {timestamp}",
"log_trial_activation": "🆓 <b>Trial Activated</b>\n\n👤 User: {user_display}\n⏰ Valid until: <b>{end_date}</b>\n🕐 Time: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Panel Synchronization</b>\n\n📊 Status: <b>{status}</b>\n👥 Users processed: <b>{users_processed}</b>\n📋 Subscriptions synced: <b>{subs_synced}</b>\n🕐 Time: {timestamp}\n\n📝 Details:\n{details}",
"log_suspicious_promo": "⚠️ <b>Suspicious Promo Code Attempt</b>\n\n👤 User: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Input: <pre>{suspicious_input}</pre>\n🕐 Time: {timestamp}",
@@ -335,10 +343,16 @@
"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_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_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_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 3 of 4:</b> Max Activations\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</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_discount": "🎟 <b>Create Promo Code</b>\n\n<b>Step 4 of 4:</b> Validity Period\n\nCode: <b>{code}</b>\nDiscount: <b>{discount_percentage}%</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_unlimited_validity": "♾️ Unlimited",
"admin_promo_enter_validity_days": "⏰ Enter the number of validity days for the promo code (1-365):",
@@ -373,7 +387,9 @@
"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_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_discount_percentage": "💰 Discount: <b>{percentage}%</b>",
"admin_promo_card_activations": "🔢 Activations: <b>{current}/{max}</b>",
"admin_promo_card_validity": "⏰ Valid until: <b>{validity}</b>",
"admin_promo_card_status": "📊 Status: <b>{status}</b>",
+18
View File
@@ -76,7 +76,12 @@
"promo_code_not_found": "Промокод <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Чтобы подключиться, перейдите по ссылке и следуйте инструкции 👇",
"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}{currency_symbol}</s> ➔ <b>{discounted_price}{currency_symbol}</b>\n💰 Экономия: {discount_amount}{currency_symbol}",
"error_applying_promo_bonus": "Не удалось применить бонус по промокоду. Пожалуйста, попробуйте позже или свяжитесь с поддержкой.",
"error_applying_promo_discount": "❌ Ошибка при активации промокода. Попробуйте позже.",
"promo_input_cancelled_short": "Ввод промокода отменен.",
"trial_feature_disabled": "Пробный период в данный момент недоступен.",
"trial_already_had_subscription_or_trial": "Вы уже использовали пробный период или у вас была платная подписка. Пробный период доступен только один раз для новых пользователей.",
@@ -169,6 +174,7 @@
"admin_promo_invalid_max_activations": "Максимальное количество активаций должно быть положительным числом.",
"admin_promo_invalid_validity_days": "Срок действия промокода (в днях) должен быть положительным числом.",
"admin_promo_created_success": "✅ Промокод <code>{code}</code> успешно создан!\nБонус: {bonus_days} дней\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"admin_promo_created_success_discount": "✅ Промокод <code>{code}</code> успешно создан!\nСкидка: {discount_percentage}%\nМакс. активаций: {max_activations}\nДействителен: {valid_until_str}",
"subscription_72h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 3 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_48h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 2 дня — {end_date}.\n\nПродлите её по кнопке ниже.",
"subscription_24h_notification": "👋 Привет, {user_name}!\n\n⏳ Ваша подписка на VPN истекает через 1 день — {end_date}.\n\nПродлите её по кнопке ниже.",
@@ -212,11 +218,13 @@
"csv_no": "Нет",
"admin_promo_edit_select_field": "Выберите поле для редактирования:",
"admin_promo_prompt_bonus_days": "Введите новое количество бонусных дней:",
"admin_promo_prompt_discount_percentage": "Введите новый процент скидки (от 1 до 100):",
"admin_promo_prompt_max_activations": "Введите новое максимальное количество активаций:",
"admin_promo_prompt_validity_days": "Введите новый срок действия в днях (0 для бессрочного):",
"admin_promo_edit_success": "Промокод успешно обновлен.",
"admin_promo_invalid_input": "Неверный ввод, попробуйте еще раз.",
"admin_promo_edit_bonus_days": "🎁 Бонусные дни",
"admin_promo_edit_discount_percentage": "💰 Процент скидки",
"admin_promo_edit_max_activations": "🔢 Макс. активации",
"admin_promo_edit_validity": "⏰ Срок действия",
"admin_ban_user_prompt": "Введите ID или @username пользователя для блокировки:",
@@ -308,6 +316,7 @@
"log_payment_received": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n📅 Период: <b>{months} мес.</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_payment_received_traffic": "{provider_emoji} <b>Получен платеж</b>\n\n👤 Пользователь: {user_display}\n💰 Сумма: <b>{amount} {currency}</b>\n🗂 Трафик: <b>{traffic_gb} ГБ</b>\n🏦 Провайдер: {payment_provider}\n🕐 Время: {timestamp}",
"log_promo_activation": "🎁 <b>Активирован промокод</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n🎯 Бонус: <b>+{bonus_days} дн.</b>\n🕐 Время: {timestamp}",
"log_promo_discount_activation": "💰 <b>Активирован промокод на скидку</b>\n\n👤 Пользователь: {user_display}\n🏷 Код: <code>{promo_code}</code>\n💵 Скидка: <b>{discount_percentage}%</b>\n🕐 Время: {timestamp}",
"log_trial_activation": "🆓 <b>Активирован триал</b>\n\n👤 Пользователь: {user_display}\n⏰ Действует до: <b>{end_date}</b>\n🕐 Время: {timestamp}",
"log_panel_sync": "{status_emoji} <b>Синхронизация с панелью</b>\n\n📊 Статус: <b>{status}</b>\n👥 Обработано пользователей: <b>{users_processed}</b>\n📋 Синхронизировано подписок: <b>{subs_synced}</b>\n🕐 Время: {timestamp}\n\n📝 Детали:\n{details}",
"log_suspicious_promo": "⚠️ <b>Подозрительная попытка ввода промокода</b>\n\n👤 Пользователь: {user_display}\n🆔 ID: <code>{user_id}</code>\n📝 Ввод: <pre>{suspicious_input}</pre>\n🕐 Время: {timestamp}",
@@ -335,10 +344,17 @@
"admin_bulk_promo_invalid_quantity": "❌ Количество должно быть от 1 до 1000",
"admin_bulk_promo_enter_validity_days": "⏰ Введите количество дней действия промокодов (1-365):",
"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_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_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 3 из 4:</b> Максимальные активации\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</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_discount": "🎟 <b>Создание промокода</b>\n\n<b>Шаг 4 из 4:</b> Срок действия\n\nКод: <b>{code}</b>\nСкидка: <b>{discount_percentage}%</b>\nМакс. активации: <b>{max_activations}</b>\n\nВыберите срок действия промокода:",
"admin_promo_code_already_exists": "❌ Промокод с таким кодом уже существует",
"admin_promo_unlimited_validity": "♾️ Неограниченно",
"admin_promo_enter_validity_days": "⏰ Введите количество дней действия промокода (1-365):",
@@ -373,7 +389,9 @@
"admin_promo_management_title": "🎟 <b>Управление промокодами</b>\n\nВыберите промокод для детального просмотра:",
"admin_promo_management_empty": "📭 Промокоды отсутствуют",
"admin_promo_card_title": "🎟 <b>Промокод: {code}</b>",
"admin_promo_card_type": "📌 Тип: {type}",
"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_validity": "⏰ Действует до: <b>{validity}</b>",
"admin_promo_card_status": "📊 Статус: <b>{status}</b>",