diff --git a/README.md b/README.md index 43d2bf45..4bd9fe92 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,26 @@ # 🚀 Remnawave Bedolaga Bot +> **🆕 Новый веб-кабинет (Cabinet WebApp)** +> +> Вышла новая версия личного кабинета пользователя — веб-интерфейс для управления подписками! +> +> **Переменные окружения для Cabinet:** +> | Переменная | Описание | По умолчанию | +> |------------|----------|--------------| +> | `CABINET_ENABLED` | Включить личный кабинет | `false` | +> | `CABINET_JWT_SECRET` | Секретный ключ для JWT токенов | `BOT_TOKEN` | +> | `CABINET_ACCESS_TOKEN_EXPIRE_MINUTES` | Время жизни access token (минуты) | `15` | +> | `CABINET_REFRESH_TOKEN_EXPIRE_DAYS` | Время жизни refresh token (дни) | `7` | +> | `CABINET_ALLOWED_ORIGINS` | Разрешённые origins для CORS | — | +> | `CABINET_EMAIL_VERIFICATION_ENABLED` | Включить верификацию email | `false` | +> | `CABINET_EMAIL_VERIFICATION_EXPIRE_HOURS` | Время жизни токена верификации email (часы) | `24` | +> | `CABINET_PASSWORD_RESET_EXPIRE_HOURS` | Время жизни токена сброса пароля (часы) | `1` | +> +> Для работы email-уведомлений настройте SMTP-переменные: `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM_EMAIL`, `SMTP_FROM_NAME`, `SMTP_USE_TLS`. +
{promo.code}\n"
@@ -99,6 +100,12 @@ async def show_promocodes_list(
elif promo.type == PromoCodeType.PROMO_GROUP.value:
if promo.promo_group:
text += f"🏷️ Промогруппа: {promo.promo_group.name}\n"
+ elif promo.type == PromoCodeType.DISCOUNT.value:
+ discount_hours = promo.subscription_days
+ if discount_hours > 0:
+ text += f"💸 Скидка: {promo.balance_bonus_kopeks}% ({discount_hours} ч.)\n"
+ else:
+ text += f"💸 Скидка: {promo.balance_bonus_kopeks}% (до покупки)\n"
if promo.valid_until:
text += f"⏰ До: {format_datetime(promo.valid_until)}\n"
@@ -164,7 +171,8 @@ async def show_promocode_management(
"balance": "💰",
"subscription_days": "📅",
"trial_subscription": "🎁",
- "promo_group": "🏷️"
+ "promo_group": "🏷️",
+ "discount": "💸"
}.get(promo.type, "🎫")
text = f"""
@@ -184,6 +192,12 @@ async def show_promocode_management(
text += f"🏷️ Промогруппа: {promo.promo_group.name} (приоритет: {promo.promo_group.priority})\n"
elif promo.promo_group_id:
text += f"🏷️ Промогруппа ID: {promo.promo_group_id} (не найдена)\n"
+ elif promo.type == PromoCodeType.DISCOUNT.value:
+ discount_hours = promo.subscription_days
+ if discount_hours > 0:
+ text += f"💸 Скидка: {promo.balance_bonus_kopeks}% (срок: {discount_hours} ч.)\n"
+ else:
+ text += f"💸 Скидка: {promo.balance_bonus_kopeks}% (до первой покупки)\n"
if promo.valid_until:
text += f"⏰ Действует до: {format_datetime(promo.valid_until)}\n"
@@ -496,7 +510,8 @@ async def select_promocode_type(
"balance": "💰 Пополнение баланса",
"days": "📅 Дни подписки",
"trial": "🎁 Тестовая подписка",
- "group": "🏷️ Промогруппа"
+ "group": "🏷️ Промогруппа",
+ "discount": "💸 Одноразовая скидка"
}
await state.update_data(promocode_type=promo_type)
@@ -556,6 +571,12 @@ async def process_promocode_code(
f"Введите количество дней тестовой подписки:"
)
await state.set_state(AdminStates.setting_promocode_value)
+ elif promo_type == "discount":
+ await message.answer(
+ f"💸 Промокод: {code}\n\n"
+ f"Введите процент скидки (1-100):"
+ )
+ await state.set_state(AdminStates.setting_promocode_value)
elif promo_type == "group":
# Show promo group selection
groups_with_counts = await get_promo_groups_with_counts(db, limit=50)
@@ -654,6 +675,9 @@ async def process_promocode_value(
elif promo_type in ["days", "trial"] and (value < 1 or value > 3650):
await message.answer("❌ Количество дней должно быть от 1 до 3650")
return
+ elif promo_type == "discount" and (value < 1 or value > 100):
+ await message.answer("❌ Процент скидки должен быть от 1 до 100")
+ return
await state.update_data(promocode_value=value)
@@ -821,7 +845,7 @@ async def process_promocode_expiry(
if expiry_days < 0 or expiry_days > 3650:
await message.answer("❌ Срок действия должен быть от 0 до 3650 дней")
return
-
+
code = data.get('promocode_code')
promo_type = data.get('promocode_type')
value = data.get('promocode_value', 0)
@@ -829,6 +853,17 @@ async def process_promocode_expiry(
promo_group_id = data.get('promo_group_id')
promo_group_name = data.get('promo_group_name')
+ # Для DISCOUNT типа нужно дополнительно спросить срок действия скидки в часах
+ if promo_type == "discount":
+ await state.update_data(promocode_expiry_days=expiry_days)
+ await message.answer(
+ f"⏰ Промокод: {code}\n\n"
+ f"Введите срок действия скидки в часах (0-8760):\n"
+ f"0 = бессрочно до первой покупки"
+ )
+ await state.set_state(AdminStates.setting_discount_hours)
+ return
+
valid_until = None
if expiry_days > 0:
valid_until = datetime.utcnow() + timedelta(days=expiry_days)
@@ -892,6 +927,80 @@ async def process_promocode_expiry(
await message.answer("❌ Введите корректное число дней")
+@admin_required
+@error_handler
+async def process_discount_hours(
+ message: types.Message,
+ db_user: User,
+ state: FSMContext,
+ db: AsyncSession
+):
+ """Обработчик ввода срока действия скидки в часах для DISCOUNT промокода."""
+ data = await state.get_data()
+
+ try:
+ discount_hours = int(message.text.strip())
+
+ if discount_hours < 0 or discount_hours > 8760:
+ await message.answer("❌ Срок действия скидки должен быть от 0 до 8760 часов")
+ return
+
+ code = data.get('promocode_code')
+ value = data.get('promocode_value', 0) # Процент скидки
+ max_uses = data.get('promocode_max_uses', 1)
+ expiry_days = data.get('promocode_expiry_days', 0)
+
+ valid_until = None
+ if expiry_days > 0:
+ valid_until = datetime.utcnow() + timedelta(days=expiry_days)
+
+ # Создаем DISCOUNT промокод
+ # balance_bonus_kopeks = процент скидки (НЕ копейки!)
+ # subscription_days = срок действия скидки в часах (НЕ дни!)
+ promocode = await create_promocode(
+ db=db,
+ code=code,
+ type=PromoCodeType.DISCOUNT,
+ balance_bonus_kopeks=value, # Процент (1-100)
+ subscription_days=discount_hours, # Часы (0-8760)
+ max_uses=max_uses,
+ valid_until=valid_until,
+ created_by=db_user.id,
+ promo_group_id=None
+ )
+
+ summary_text = f"""
+✅ Промокод создан!
+
+🎫 Код: {promocode.code}
+📝 Тип: Одноразовая скидка
+💸 Скидка: {promocode.balance_bonus_kopeks}%
+"""
+
+ if discount_hours > 0:
+ summary_text += f"⏰ Срок скидки: {discount_hours} ч.\n"
+ else:
+ summary_text += f"⏰ Срок скидки: до первой покупки\n"
+
+ summary_text += f"📊 Использований: {promocode.max_uses}\n"
+
+ if promocode.valid_until:
+ summary_text += f"⏳ Промокод действует до: {format_datetime(promocode.valid_until)}\n"
+
+ await message.answer(
+ summary_text,
+ reply_markup=types.InlineKeyboardMarkup(inline_keyboard=[
+ [types.InlineKeyboardButton(text="🎫 К промокодам", callback_data="admin_promocodes")]
+ ])
+ )
+
+ await state.clear()
+ logger.info(f"Создан DISCOUNT промокод {code} ({value}%, {discount_hours}ч) администратором {db_user.telegram_id}")
+
+ except ValueError:
+ await message.answer("❌ Введите корректное число часов")
+
+
async def handle_edit_expiry(
message: types.Message,
db_user: User,
@@ -1182,4 +1291,5 @@ def register_handlers(dp: Dispatcher):
dp.message.register(process_promocode_value, AdminStates.setting_promocode_value)
dp.message.register(process_promocode_uses, AdminStates.setting_promocode_uses)
dp.message.register(process_promocode_expiry, AdminStates.setting_promocode_expiry)
+ dp.message.register(process_discount_hours, AdminStates.setting_discount_hours)
diff --git a/app/handlers/promocode.py b/app/handlers/promocode.py
index 17170d1e..afcfd686 100644
--- a/app/handlers/promocode.py
+++ b/app/handlers/promocode.py
@@ -137,6 +137,10 @@ async def process_promocode(
"PROMOCODE_NOT_FIRST_PURCHASE",
"❌ Этот промокод доступен только для первой покупки"
),
+ "active_discount_exists": texts.t(
+ "PROMOCODE_ACTIVE_DISCOUNT_EXISTS",
+ "❌ У вас уже есть активная скидка. Используйте её перед активацией новой."
+ ),
"server_error": texts.ERROR
}
diff --git a/app/keyboards/admin.py b/app/keyboards/admin.py
index 65e21404..33650263 100644
--- a/app/keyboards/admin.py
+++ b/app/keyboards/admin.py
@@ -1311,6 +1311,12 @@ def get_promocode_type_keyboard(language: str = "ru") -> InlineKeyboardMarkup:
callback_data="promo_type_group"
)
],
+ [
+ InlineKeyboardButton(
+ text=_t(texts, "ADMIN_PROMOCODE_TYPE_DISCOUNT", "💸 Одноразовая скидка"),
+ callback_data="promo_type_discount"
+ )
+ ],
[
InlineKeyboardButton(text=texts.BACK, callback_data="admin_promocodes")
]
diff --git a/app/localization/locales/en.json b/app/localization/locales/en.json
index 6d394742..1723337e 100644
--- a/app/localization/locales/en.json
+++ b/app/localization/locales/en.json
@@ -407,6 +407,7 @@
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Subscription days",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Trial",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Promo Group",
+ "ADMIN_PROMOCODE_TYPE_DISCOUNT": "💸 One-time Discount",
"ADMIN_PROMO_GROUPS": "💳 Promo groups",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (default)",
"ADMIN_PROMO_GROUPS_EMPTY": "No promo groups found.",
@@ -1225,6 +1226,7 @@
"PROMOCODE_INVALID": "❌ Invalid promo code",
"PROMOCODE_SUCCESS": "🎉 Promo code applied!",
"PROMOCODE_USED": "ℹ️ Promo code has already been used",
+ "PROMOCODE_ACTIVE_DISCOUNT_EXISTS": "❌ You already have an active discount. Use it before activating a new one.",
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Current level: {name}",
"PROMO_GROUPS_INFO_EMPTY": "Auto-assigned promo groups are not configured yet.",
"PROMO_GROUPS_INFO_HEADER": "🎯 Promo groups",
diff --git a/app/localization/locales/ru.json b/app/localization/locales/ru.json
index e74c16c7..f68eee82 100644
--- a/app/localization/locales/ru.json
+++ b/app/localization/locales/ru.json
@@ -410,6 +410,7 @@
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дни подписки",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Триал",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогруппа",
+ "ADMIN_PROMOCODE_TYPE_DISCOUNT": "💸 Одноразовая скидка",
"ADMIN_PROMO_GROUPS": "💳 Промогруппы",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базовая)",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогруппы не найдены.",
@@ -1242,6 +1243,7 @@
"PROMOCODE_INVALID": "❌ Неверный промокод",
"PROMOCODE_SUCCESS": "🎉 Промокод активирован! {description}",
"PROMOCODE_USED": "❌ Промокод уже использован",
+ "PROMOCODE_ACTIVE_DISCOUNT_EXISTS": "❌ У вас уже есть активная скидка. Используйте её перед активацией новой.",
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Текущий уровень: {name}",
"PROMO_GROUPS_INFO_EMPTY": "Промогруппы с автовыдачей ещё не настроены.",
"PROMO_GROUPS_INFO_HEADER": "🎯 Скидки за траты",
diff --git a/app/localization/locales/ua.json b/app/localization/locales/ua.json
index eb8db8d6..c051e8d6 100644
--- a/app/localization/locales/ua.json
+++ b/app/localization/locales/ua.json
@@ -337,6 +337,7 @@
"ADMIN_PROMOCODE_TYPE_DAYS": "📅 Дні підписки",
"ADMIN_PROMOCODE_TYPE_TRIAL": "🎁 Тріал",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP": "🏷️ Промогрупа",
+ "ADMIN_PROMOCODE_TYPE_DISCOUNT": "💸 Одноразова знижка",
"ADMIN_PROMO_GROUPS": "💳 Промогрупи",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL": " (базова)",
"ADMIN_PROMO_GROUPS_EMPTY": "Промогрупи не знайдено.",
@@ -1163,6 +1164,7 @@
"PROMOCODE_INVALID": "❌ Невірний промокод",
"PROMOCODE_SUCCESS": "🎉 Промокод активовано! {description}",
"PROMOCODE_USED": "❌ Промокод вже використано",
+ "PROMOCODE_ACTIVE_DISCOUNT_EXISTS": "❌ У вас вже є активна знижка. Використайте її перед активацією нової.",
"PROMO_GROUPS_INFO_CURRENT_LEVEL": "🏆 Поточний рівень: {name}",
"PROMO_GROUPS_INFO_EMPTY": "Промогрупи з автовидачею ще не налаштовані.",
"PROMO_GROUPS_INFO_HEADER": "🎯 Знижки за витрати",
diff --git a/app/localization/locales/zh.json b/app/localization/locales/zh.json
index 9b792ce7..ea825a45 100644
--- a/app/localization/locales/zh.json
+++ b/app/localization/locales/zh.json
@@ -336,6 +336,7 @@
"ADMIN_PROMOCODE_TYPE_DAYS":"📅订阅天数",
"ADMIN_PROMOCODE_TYPE_TRIAL":"🎁试用",
"ADMIN_PROMOCODE_TYPE_PROMO_GROUP":"🏷️促销组",
+"ADMIN_PROMOCODE_TYPE_DISCOUNT":"💸一次性折扣",
"ADMIN_PROMO_GROUPS":"💳促销组",
"ADMIN_PROMO_GROUPS_DEFAULT_LABEL":"(基础)",
"ADMIN_PROMO_GROUPS_EMPTY":"未找到促销组。",
@@ -1161,6 +1162,7 @@
"PROMOCODE_INVALID":"❌优惠码无效",
"PROMOCODE_SUCCESS":"🎉优惠码已激活!{description}",
"PROMOCODE_USED":"❌优惠码已被使用",
+"PROMOCODE_ACTIVE_DISCOUNT_EXISTS":"❌您已有活动折扣。请先使用后再激活新折扣。",
"PROMO_GROUPS_INFO_CURRENT_LEVEL":"🏆当前等级:{name}",
"PROMO_GROUPS_INFO_EMPTY":"尚未设置带自动分配的促销组。",
"PROMO_GROUPS_INFO_HEADER":"🎯消费折扣",
diff --git a/app/services/promocode_service.py b/app/services/promocode_service.py
index 0e57adf4..85b15875 100644
--- a/app/services/promocode_service.py
+++ b/app/services/promocode_service.py
@@ -59,7 +59,12 @@ class PromoCodeService:
balance_before_kopeks = user.balance_kopeks
- result_description = await self._apply_promocode_effects(db, user, promocode)
+ try:
+ result_description = await self._apply_promocode_effects(db, user, promocode)
+ except ValueError as e:
+ if str(e) == "active_discount_exists":
+ return {"success": False, "error": "active_discount_exists"}
+ raise
balance_after_kopeks = user.balance_kopeks
if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
@@ -141,9 +146,65 @@ class PromoCodeService:
return {"success": False, "error": "server_error"}
async def _apply_promocode_effects(self, db: AsyncSession, user: User, promocode: PromoCode) -> str:
+ """
+ Применяет эффекты промокода к пользователю.
+
+ Args:
+ db: Сессия базы данных
+ user: Пользователь
+ promocode: Промокод
+
+ Returns:
+ Описание примененных эффектов
+
+ Raises:
+ ValueError: Если у пользователя уже есть активная скидка (для DISCOUNT типа)
+ """
effects = []
-
- if promocode.balance_bonus_kopeks > 0:
+
+ # Обработка DISCOUNT типа (одноразовая скидка)
+ if promocode.type == PromoCodeType.DISCOUNT.value:
+ from datetime import datetime, timedelta
+
+ # Проверка на наличие активной скидки
+ current_discount = getattr(user, 'promo_offer_discount_percent', 0) or 0
+ expires_at = getattr(user, 'promo_offer_discount_expires_at', None)
+
+ # Если есть активная скидка (процент > 0 и срок не истек)
+ if current_discount > 0:
+ if expires_at is None or expires_at > datetime.utcnow():
+ logger.warning(
+ f"⚠️ Пользователь {user.telegram_id} попытался активировать промокод {promocode.code}, "
+ f"но у него уже есть активная скидка {current_discount}% до {expires_at}"
+ )
+ raise ValueError("active_discount_exists")
+
+ # balance_bonus_kopeks хранит процент скидки (1-100)
+ discount_percent = promocode.balance_bonus_kopeks
+ # subscription_days хранит срок действия скидки в часах (0 = бессрочно до первой покупки)
+ discount_hours = promocode.subscription_days
+
+ # Устанавливаем процент скидки
+ user.promo_offer_discount_percent = discount_percent
+ user.promo_offer_discount_source = f"promocode:{promocode.code}"
+
+ # Устанавливаем срок действия скидки
+ if discount_hours > 0:
+ user.promo_offer_discount_expires_at = datetime.utcnow() + timedelta(hours=discount_hours)
+ effects.append(f"💸 Получена скидка {discount_percent}% (действует {discount_hours} ч.)")
+ else:
+ # 0 часов = бессрочно до первой покупки
+ user.promo_offer_discount_expires_at = None
+ effects.append(f"💸 Получена скидка {discount_percent}% до первой покупки")
+
+ await db.flush()
+
+ logger.info(
+ f"✅ Пользователю {user.telegram_id} назначена скидка {discount_percent}% "
+ f"(срок: {discount_hours} ч.) по промокоду {promocode.code}"
+ )
+
+ if promocode.type == PromoCodeType.BALANCE.value and promocode.balance_bonus_kopeks > 0:
await add_user_balance(
db, user, promocode.balance_bonus_kopeks,
f"Бонус по промокоду {promocode.code}"
@@ -151,8 +212,8 @@ class PromoCodeService:
balance_bonus_rubles = promocode.balance_bonus_kopeks / 100
effects.append(f"💰 Баланс пополнен на {balance_bonus_rubles}₽")
-
- if promocode.subscription_days > 0:
+
+ if promocode.type == PromoCodeType.SUBSCRIPTION_DAYS.value and promocode.subscription_days > 0:
from app.config import settings
subscription = await get_subscription_by_user_id(db, user.id)
diff --git a/app/states.py b/app/states.py
index b338f836..e847e6b4 100644
--- a/app/states.py
+++ b/app/states.py
@@ -57,6 +57,7 @@ class AdminStates(StatesGroup):
setting_promocode_value = State()
setting_promocode_uses = State()
setting_promocode_expiry = State()
+ setting_discount_hours = State() # Для DISCOUNT: ввод срока действия скидки в часах
selecting_promo_group = State()
creating_campaign_name = State()