interest coupons/ picture for rates/ small improvements
This commit is contained in:
+44
-3
@@ -11,10 +11,13 @@ from logger import logger
|
||||
async def create_coupon(
|
||||
session: AsyncSession,
|
||||
code: str,
|
||||
amount: int,
|
||||
amount: int | None,
|
||||
usage_limit: int,
|
||||
days: int = None,
|
||||
days: int | None = None,
|
||||
new_users_only: bool = False,
|
||||
percent: int | None = None,
|
||||
max_discount_amount: int | None = None,
|
||||
min_order_amount: int | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
exists = await session.scalar(select(Coupon.id).where(Coupon.code == code))
|
||||
@@ -22,15 +25,33 @@ async def create_coupon(
|
||||
logger.warning(f"[Coupon] ⚠️ Купон с кодом {code} уже существует.")
|
||||
return False
|
||||
|
||||
if percent is not None:
|
||||
try:
|
||||
percent_value = int(percent)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(f"[Coupon] ⚠️ Некорректный процент для купона {code}.")
|
||||
return False
|
||||
|
||||
if percent_value <= 0 or percent_value > 100:
|
||||
logger.warning(f"[Coupon] ⚠️ процент должен быть в диапазоне 1..100 для купона {code}.")
|
||||
return False
|
||||
|
||||
if (amount or 0) > 0 or (days or 0) > 0:
|
||||
logger.warning(f"[Coupon] ⚠️ Купон {code} не может одновременно иметь percent и amount/days.")
|
||||
return False
|
||||
|
||||
await session.execute(
|
||||
insert(Coupon).values(
|
||||
code=code,
|
||||
amount=amount,
|
||||
amount=int(amount) if amount is not None else 0,
|
||||
usage_limit=usage_limit,
|
||||
usage_count=0,
|
||||
is_used=False,
|
||||
days=days,
|
||||
new_users_only=new_users_only,
|
||||
percent=percent,
|
||||
max_discount_amount=max_discount_amount,
|
||||
min_order_amount=min_order_amount,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
@@ -115,3 +136,23 @@ async def update_coupon_usage_count(session: AsyncSession, coupon_id: int):
|
||||
except SQLAlchemyError as e:
|
||||
logger.error(f"❌ Ошибка при обновлении купона {coupon_id}: {e}")
|
||||
await session.rollback()
|
||||
|
||||
|
||||
def apply_percent_coupon(price_rub: int, coupon: Coupon) -> tuple[int, int]:
|
||||
percent = coupon.percent
|
||||
if percent is None:
|
||||
return price_rub, 0
|
||||
|
||||
if coupon.min_order_amount is not None and price_rub < int(coupon.min_order_amount):
|
||||
return price_rub, 0
|
||||
|
||||
discount = (price_rub * int(percent)) // 100
|
||||
|
||||
if coupon.max_discount_amount is not None:
|
||||
discount = min(discount, int(coupon.max_discount_amount))
|
||||
|
||||
final_price = price_rub - discount
|
||||
if final_price < 0:
|
||||
final_price = 0
|
||||
|
||||
return final_price, discount
|
||||
|
||||
+5
-1
@@ -16,7 +16,7 @@ from sqlalchemy import (
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
text
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, declarative_base, mapped_column, relationship
|
||||
@@ -186,6 +186,10 @@ class Coupon(DictLikeMixin, Base):
|
||||
days = Column(Integer, nullable=True)
|
||||
new_users_only = Column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
percent = Column(Integer, nullable=True)
|
||||
max_discount_amount = Column(Integer, nullable=True)
|
||||
min_order_amount = Column(Integer, nullable=True)
|
||||
|
||||
|
||||
class CouponUsage(DictLikeMixin, Base):
|
||||
__tablename__ = "coupon_usages"
|
||||
|
||||
@@ -28,7 +28,6 @@ from .keyboard import (
|
||||
format_coupons_list,
|
||||
)
|
||||
|
||||
|
||||
router = Router()
|
||||
|
||||
|
||||
@@ -37,6 +36,7 @@ class AdminCouponsState(StatesGroup):
|
||||
waiting_for_coupon_audience = State()
|
||||
waiting_for_balance_data = State()
|
||||
waiting_for_days_data = State()
|
||||
waiting_for_percent_data = State()
|
||||
|
||||
|
||||
@router.callback_query(
|
||||
@@ -56,6 +56,7 @@ async def handle_coupons_create(callback_query: CallbackQuery, state: FSMContext
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text="💰 Баланс", callback_data="coupon_type_balance")
|
||||
kb.button(text="⏳ Время", callback_data="coupon_type_days")
|
||||
kb.button(text="📉 Процент", callback_data="coupon_type_percent")
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
@@ -87,11 +88,16 @@ async def handle_days_coupon_selection(callback_query: CallbackQuery, state: FSM
|
||||
await show_coupon_audience_step(callback_query, state, "days")
|
||||
|
||||
|
||||
@router.callback_query(F.data == "coupon_type_percent", IsAdminFilter())
|
||||
async def handle_percent_coupon_selection(callback_query: CallbackQuery, state: FSMContext):
|
||||
await show_coupon_audience_step(callback_query, state, "percent")
|
||||
|
||||
|
||||
@router.callback_query(F.data.in_(("coupon_audience_all", "coupon_audience_new")), IsAdminFilter())
|
||||
async def handle_coupon_audience(callback_query: CallbackQuery, state: FSMContext):
|
||||
data = await state.get_data()
|
||||
coupon_type = data.get("coupon_type")
|
||||
if coupon_type not in ("balance", "days"):
|
||||
if coupon_type not in ("balance", "days", "percent"):
|
||||
await callback_query.answer("Ошибка: тип купона не найден", show_alert=True)
|
||||
return
|
||||
|
||||
@@ -105,19 +111,30 @@ async def handle_coupon_audience(callback_query: CallbackQuery, state: FSMContex
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 💰 <i>сумма</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈\n\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b>\n\n"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_balance_data)
|
||||
return
|
||||
|
||||
if coupon_type == "days":
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> ⏳ <i>дни</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈\n\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b>\n\n"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_days_data)
|
||||
return
|
||||
|
||||
text = (
|
||||
"🎫 <b>Введите данные для создания купона в формате:</b>\n\n"
|
||||
"📝 <i>код</i> 📉 <i>процент</i> 🔢 <i>лимит</i>\n\n"
|
||||
"Пример: <b>'SALE20 20 10'</b>\n"
|
||||
"Где 20 — это скидка 20%\n\n"
|
||||
)
|
||||
await callback_query.message.edit_text(text=text, reply_markup=kb.as_markup())
|
||||
await state.set_state(AdminCouponsState.waiting_for_percent_data)
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_balance_data, IsAdminFilter())
|
||||
@@ -131,9 +148,9 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"❌ <b>Некорректный формат!</b>\n"
|
||||
"🏷️ <b>код</b> 💰 <b>сумма</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'COUPON1 50 5'</b> 👈"
|
||||
"Пример: <b>'COUPON1 50 5'</b>"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
@@ -143,9 +160,11 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
coupon_amount = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if coupon_amount <= 0:
|
||||
raise ValueError("Сумма должна быть больше 0")
|
||||
raise ValueError
|
||||
if usage_limit <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Сумма должна быть числом, а лимит — целым числом."
|
||||
text = "⚠️ <b>Проверьте данные!</b>\nСумма и лимит должны быть целыми числами больше 0."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
@@ -160,6 +179,7 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
usage_limit,
|
||||
days=None,
|
||||
new_users_only=new_users_only,
|
||||
percent=None,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
@@ -169,9 +189,9 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
|
||||
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"✅ Купон <b>{coupon_code}</b> создан!\n"
|
||||
f"💰 Сумма: <b>{coupon_amount} рублей</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
|
||||
f"🎯 Доступ: <b>{audience_txt}</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
@@ -184,7 +204,6 @@ async def handle_balance_coupon_input(message: Message, state: FSMContext, sessi
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
@@ -201,9 +220,7 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b> 📝 Пожалуйста, введите данные в формате:\n"
|
||||
"🏷️ <b>код</b> ⏳ <i>дни</i> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'DAYS10 10 50'</b> 👈"
|
||||
"❌ <b>Некорректный формат!</b>\n🏷️ <b>код</b> ⏳ <b>дни</b> 🔢 <b>лимит</b>\nПример: <b>'DAYS10 10 50'</b>"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
@@ -213,9 +230,11 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
days = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if days <= 0:
|
||||
raise ValueError("Количество дней должно быть больше 0")
|
||||
raise ValueError
|
||||
if usage_limit <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте правильность введенных данных!</b>\n💱 Дни должны быть числом, а лимит — целым числом."
|
||||
text = "⚠️ <b>Проверьте данные!</b>\nДни и лимит должны быть целыми числами больше 0."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
@@ -230,6 +249,7 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
usage_limit,
|
||||
days=days,
|
||||
new_users_only=new_users_only,
|
||||
percent=None,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
@@ -239,9 +259,9 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
|
||||
|
||||
text = (
|
||||
f"✅ Купон с кодом <b>{coupon_code}</b> успешно создан!\n"
|
||||
f"✅ Купон <b>{coupon_code}</b> создан!\n"
|
||||
f"⏳ <b>{format_days(days)}</b>\n"
|
||||
f"🔢 Лимит использования: <b>{usage_limit} раз</b>\n"
|
||||
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
|
||||
f"🎯 Доступ: <b>{audience_txt}</b>\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>{coupon_link}</code>\n"
|
||||
)
|
||||
@@ -254,7 +274,74 @@ async def handle_days_coupon_input(message: Message, state: FSMContext, session:
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
|
||||
|
||||
@router.message(AdminCouponsState.waiting_for_percent_data, IsAdminFilter())
|
||||
async def handle_percent_coupon_input(message: Message, state: FSMContext, session: Any):
|
||||
text = message.text.strip()
|
||||
parts = text.split()
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
if len(parts) != 3:
|
||||
text = (
|
||||
"❌ <b>Некорректный формат!</b>\n"
|
||||
"🏷️ <b>код</b> 📉 <b>процент</b> 🔢 <b>лимит</b>\n"
|
||||
"Пример: <b>'SALE20 20 10'</b>"
|
||||
)
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
coupon_code = parts[0]
|
||||
percent = int(parts[1])
|
||||
usage_limit = int(parts[2])
|
||||
if percent <= 0 or percent > 100:
|
||||
raise ValueError
|
||||
if usage_limit <= 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
text = "⚠️ <b>Проверьте данные!</b>\nПроцент должен быть 1..100, лимит — целое число больше 0."
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
try:
|
||||
data = await state.get_data()
|
||||
new_users_only = bool(data.get("new_users_only"))
|
||||
|
||||
ok = await create_coupon(
|
||||
session,
|
||||
coupon_code,
|
||||
0,
|
||||
usage_limit,
|
||||
days=None,
|
||||
new_users_only=new_users_only,
|
||||
percent=percent,
|
||||
)
|
||||
if not ok:
|
||||
await message.answer("❌ Купон с таким кодом уже существует.", reply_markup=kb.as_markup())
|
||||
return
|
||||
|
||||
audience_txt = "🆕 Только новым" if new_users_only else "👤 Всем"
|
||||
|
||||
text = (
|
||||
f"✅ Купон <b>{coupon_code}</b> создан!\n"
|
||||
f"📉 Скидка: <b>{percent}%</b>\n"
|
||||
f"🔢 Лимит: <b>{usage_limit} раз</b>\n"
|
||||
f"🎯 Доступ: <b>{audience_txt}</b>\n"
|
||||
)
|
||||
|
||||
kb = InlineKeyboardBuilder()
|
||||
kb.button(text=BACK, callback_data=AdminPanelCallback(action="coupons").pack())
|
||||
kb.adjust(1)
|
||||
|
||||
await message.answer(text=text, reply_markup=kb.as_markup())
|
||||
await state.clear()
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка при создании купона: {e}")
|
||||
await message.answer("❌ Произошла ошибка при создании купона.", reply_markup=kb.as_markup())
|
||||
@@ -350,7 +437,6 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
return
|
||||
|
||||
coupon_code = inline_query.query.split("coupon_")[1]
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
|
||||
coupons = await get_all_coupons(session, page=1, per_page=10)
|
||||
coupon = next((c for c in coupons["coupons"] if c["code"] == coupon_code), None)
|
||||
@@ -364,17 +450,39 @@ async def inline_coupon_handler(inline_query: InlineQuery, session: Any):
|
||||
)
|
||||
return
|
||||
|
||||
title = f"Купон {coupon['code']}"
|
||||
description = (
|
||||
f"Получи {coupon['amount']} рублей!"
|
||||
if coupon["amount"] > 0
|
||||
else f"Продли подписку на {format_days(coupon['days'])}!"
|
||||
percent_value = coupon.get("percent")
|
||||
if percent_value is not None and int(percent_value) > 0:
|
||||
await inline_query.answer(
|
||||
results=[],
|
||||
switch_pm_text="Процентные купоны не публикуются ссылкой",
|
||||
switch_pm_parameter="coupons",
|
||||
cache_time=1,
|
||||
)
|
||||
return
|
||||
|
||||
coupon_link = f"https://t.me/{USERNAME_BOT}?start=coupons_{coupon_code}"
|
||||
title = f"Купон {coupon['code']}"
|
||||
|
||||
days_value = coupon.get("days")
|
||||
amount_value = coupon.get("amount") or 0
|
||||
|
||||
if days_value is not None and int(days_value) > 0:
|
||||
days_int = int(days_value)
|
||||
description = f"Продли подписку на {format_days(days_int)}!"
|
||||
message_text = (
|
||||
f"🎫 <b>Купон:</b> {coupon['code']}\n"
|
||||
f"{'💰 <b>Бонус:</b> ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ <b>Продление:</b> ' + format_days(coupon['days'])}\n"
|
||||
f"⏳ <b>Продление:</b> {format_days(days_int)}\n"
|
||||
f"👇 Нажми, чтобы активировать!"
|
||||
)
|
||||
elif int(amount_value) > 0:
|
||||
amount_int = int(amount_value)
|
||||
description = f"Получи {amount_int} рублей!"
|
||||
message_text = (
|
||||
f"🎫 <b>Купон:</b> {coupon['code']}\n💰 <b>Бонус:</b> {amount_int} рублей\n👇 Нажми, чтобы активировать!"
|
||||
)
|
||||
else:
|
||||
description = "Купон"
|
||||
message_text = f"🎫 <b>Купон:</b> {coupon['code']}\n👇 Нажми, чтобы активировать!"
|
||||
|
||||
builder = InlineKeyboardBuilder()
|
||||
builder.button(text="Активировать купон", url=coupon_link)
|
||||
|
||||
@@ -67,18 +67,30 @@ def build_coupons_list_kb(coupons: list, current_page: int, total_pages: int) ->
|
||||
|
||||
|
||||
def format_coupons_list(coupons: list, username_bot: str) -> str:
|
||||
coupon_list = "📜 Список всех купонов:\n\n"
|
||||
for coupon in coupons:
|
||||
value_text = (
|
||||
f"💰 <b>Сумма:</b> {coupon['amount']} рублей"
|
||||
if coupon["amount"] > 0
|
||||
else f"⏳ <b>{format_days(coupon['days'])}</b>"
|
||||
text = "📜 <b>Список купонов</b>\n\n"
|
||||
|
||||
for i, coupon in enumerate(coupons, start=1):
|
||||
percent_value = coupon.get("percent")
|
||||
days_value = coupon.get("days")
|
||||
amount_value = coupon.get("amount") or 0
|
||||
|
||||
if percent_value is not None and int(percent_value) > 0:
|
||||
value_line = f"📉 <b>Скидка:</b> {int(percent_value)}%"
|
||||
elif days_value is not None and int(days_value) > 0:
|
||||
value_line = f"⏳ <b>Продление:</b> {format_days(int(days_value))}"
|
||||
elif int(amount_value) > 0:
|
||||
value_line = f"💰 <b>Баланс:</b> {int(amount_value)} ₽"
|
||||
else:
|
||||
value_line = "—"
|
||||
|
||||
text += (
|
||||
f"<blockquote>"
|
||||
f"<b>{i}. {coupon['code']}</b>\n"
|
||||
f"{value_line}\n"
|
||||
f"🔢 <b>Лимит:</b> {coupon['usage_limit']} | "
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']}\n"
|
||||
f"<code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>"
|
||||
f"</blockquote>\n\n"
|
||||
)
|
||||
coupon_list += (
|
||||
f"🏷️ <b>Код:</b> {coupon['code']}\n"
|
||||
f"{value_text}\n"
|
||||
f"🔢 <b>Лимит использования:</b> {coupon['usage_limit']} раз\n"
|
||||
f"✅ <b>Использовано:</b> {coupon['usage_count']} раз\n"
|
||||
f"🔗 <b>Ссылка:</b> <code>https://t.me/{username_bot}?start=coupons_{coupon['code']}</code>\n\n"
|
||||
)
|
||||
return coupon_list
|
||||
|
||||
return text
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from datetime import datetime
|
||||
from aiogram.exceptions import TelegramBadRequest
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
@@ -464,16 +465,20 @@ async def handle_cfg_renew_devices(callback_query: CallbackQuery, state: FSMCont
|
||||
else (f"{int(selected_traffic_gb)} ГБ" if selected_traffic_gb is not None else "—")
|
||||
)
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text = (
|
||||
"🧩 <b>Выбор конфигурации тарифа</b>\n\n"
|
||||
f"📦 <b>Тариф:</b> {tariff.get('name', '—')}\n"
|
||||
f"📱 <b>Устройства:</b> {devices_label}\n"
|
||||
f"📊 <b>Трафик:</b> {traffic_label}\n\n"
|
||||
"Выберите параметры и нажмите «✅ Применить»."
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=builder.as_markup())
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" not in str(e):
|
||||
raise
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@@ -577,16 +582,20 @@ async def handle_cfg_renew_traffic(callback_query: CallbackQuery, state: FSMCont
|
||||
)
|
||||
traffic_label = "Безлимит трафика" if selected_traffic_gb <= 0 else f"{selected_traffic_gb} ГБ"
|
||||
|
||||
await callback_query.message.edit_text(
|
||||
text = (
|
||||
"🧩 <b>Выбор конфигурации тарифа</b>\n\n"
|
||||
f"📦 <b>Тариф:</b> {tariff.get('name', '—')}\n"
|
||||
f"📱 <b>Устройства:</b> {devices_label}\n"
|
||||
f"📊 <b>Трафик:</b> {traffic_label}\n\n"
|
||||
"Выберите параметры и нажмите «✅ Применить»."
|
||||
),
|
||||
reply_markup=builder.as_markup(),
|
||||
)
|
||||
|
||||
try:
|
||||
await callback_query.message.edit_text(text=text, reply_markup=builder.as_markup())
|
||||
except TelegramBadRequest as e:
|
||||
if "message is not modified" not in str(e):
|
||||
raise
|
||||
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -296,6 +297,9 @@ async def handle_key_creation(
|
||||
target_message=target_message,
|
||||
text=SELECT_TARIFF_PLAN_MSG + discount_message,
|
||||
reply_markup=builder.as_markup(),
|
||||
media_path=os.path.join("img", "tariffs.jpg"),
|
||||
disable_web_page_preview=False,
|
||||
force_text=True,
|
||||
)
|
||||
|
||||
await state.update_data(
|
||||
|
||||
@@ -168,6 +168,7 @@ async def build_keys_response(records: list[Key] | None, session: AsyncSession,
|
||||
if getattr(record, "tariff_id", None):
|
||||
try:
|
||||
from handlers.tariffs.tariff_display import resolve_vless_enabled
|
||||
|
||||
is_vless = await resolve_vless_enabled(session, int(record.tariff_id))
|
||||
except Exception:
|
||||
is_vless = False
|
||||
|
||||
@@ -1,14 +1,25 @@
|
||||
from typing import Any
|
||||
from math import ceil
|
||||
|
||||
from aiogram import F, Router
|
||||
from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton
|
||||
from aiogram.fsm.state import State, StatesGroup
|
||||
from aiogram.types import CallbackQuery, InlineKeyboardButton, Message
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from sqlalchemy import select
|
||||
|
||||
from config import USE_NEW_PAYMENT_FLOW, TRIBUTE_LINK
|
||||
from core.bootstrap import PAYMENTS_CONFIG
|
||||
from core.settings.money_config import get_currency_mode
|
||||
|
||||
from database import (
|
||||
check_coupon_usage,
|
||||
create_coupon_usage,
|
||||
get_balance,
|
||||
get_coupon_by_code,
|
||||
update_coupon_usage_count,
|
||||
)
|
||||
from database.coupons import apply_percent_coupon
|
||||
from database.models import CouponUsage
|
||||
from database.temporary_data import create_temporary_data
|
||||
from handlers import buttons as btn
|
||||
from handlers.payments.currency_flow import (
|
||||
@@ -17,7 +28,7 @@ from handlers.payments.currency_flow import (
|
||||
currency_label,
|
||||
)
|
||||
from handlers.payments.providers import get_providers_with_hooks
|
||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, FAST_PAY_CHOOSE_PROVIDER
|
||||
from handlers.texts import FAST_PAY_CHOOSE_CURRENCY, FAST_PAY_CHOOSE_PROVIDER, FASTFLOW_COUPON_APPLIED_TEMPLATE
|
||||
from handlers.utils import edit_or_send_message
|
||||
from logger import logger
|
||||
|
||||
@@ -25,6 +36,10 @@ from logger import logger
|
||||
router = Router()
|
||||
|
||||
|
||||
class FastFlowCouponState(StatesGroup):
|
||||
waiting_for_coupon_code = State()
|
||||
|
||||
|
||||
async def get_payment_providers_config() -> dict[str, bool]:
|
||||
config = PAYMENTS_CONFIG or {}
|
||||
return dict(config)
|
||||
@@ -130,10 +145,30 @@ async def try_fast_payment_flow(
|
||||
if not providers and not show_tribute:
|
||||
return False
|
||||
|
||||
keyboard = build_currency_choice_kb(
|
||||
show_stars=show_stars,
|
||||
show_tribute=show_tribute,
|
||||
)
|
||||
keyboard_original = build_currency_choice_kb(show_stars=show_stars, show_tribute=show_tribute)
|
||||
|
||||
rows = keyboard_original.export()
|
||||
profile_row = None
|
||||
kept_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
for row in rows:
|
||||
if any(getattr(button, "callback_data", None) == "profile" for button in row):
|
||||
if profile_row is None:
|
||||
profile_row = row
|
||||
continue
|
||||
kept_rows.append(row)
|
||||
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
for row in kept_rows:
|
||||
keyboard.row(*row)
|
||||
|
||||
keyboard.row(InlineKeyboardButton(text=btn.COUPON, callback_data="fastflow_coupon"))
|
||||
|
||||
if profile_row:
|
||||
keyboard.row(*profile_row)
|
||||
else:
|
||||
keyboard.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
lead_text = await shortfall_lead_text(
|
||||
session,
|
||||
tg_id,
|
||||
@@ -161,6 +196,7 @@ async def try_fast_payment_flow(
|
||||
currency = cfg.get("currency")
|
||||
if currency:
|
||||
await state.update_data(chosen_currency=currency)
|
||||
await state.update_data(temp_key=temp_key, temp_payload=temp_payload, required_amount=required_amount)
|
||||
if await _run_provider_flow(single_provider, callback_query, session, state, required_amount):
|
||||
return True
|
||||
return False
|
||||
@@ -188,6 +224,7 @@ async def try_fast_payment_flow(
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.row(InlineKeyboardButton(text=btn.COUPON, callback_data="fastflow_coupon"))
|
||||
keyboard.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
lead_text = await shortfall_lead_text(
|
||||
@@ -209,6 +246,288 @@ async def try_fast_payment_flow(
|
||||
return True
|
||||
|
||||
|
||||
@router.callback_query(F.data == "fastflow_coupon_back")
|
||||
async def fastflow_coupon_back(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
amount_not_found_text = "Сумма не найдена"
|
||||
|
||||
data = await state.get_data()
|
||||
temp_key = data.get("temp_key")
|
||||
temp_payload = data.get("temp_payload")
|
||||
required_amount = data.get("required_amount")
|
||||
|
||||
await state.set_state(None)
|
||||
|
||||
if not temp_key or not isinstance(temp_payload, dict) or required_amount is None:
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=amount_not_found_text,
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
.as_markup(),
|
||||
)
|
||||
await callback_query.answer()
|
||||
return
|
||||
|
||||
await try_fast_payment_flow(
|
||||
callback_query,
|
||||
session,
|
||||
state,
|
||||
tg_id=callback_query.from_user.id,
|
||||
temp_key=str(temp_key),
|
||||
temp_payload=dict(temp_payload),
|
||||
required_amount=int(required_amount),
|
||||
)
|
||||
await callback_query.answer()
|
||||
|
||||
|
||||
@router.callback_query(F.data == "fastflow_coupon")
|
||||
async def fastflow_coupon(callback_query: CallbackQuery, state: FSMContext):
|
||||
input_text = "Введите купон:"
|
||||
amount_not_found_text = "Сумма не найдена"
|
||||
|
||||
data = await state.get_data()
|
||||
if data.get("required_amount") is None:
|
||||
await callback_query.answer(amount_not_found_text, show_alert=True)
|
||||
return
|
||||
|
||||
await state.set_state(FastFlowCouponState.waiting_for_coupon_code)
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text=input_text,
|
||||
reply_markup=InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=btn.BACK, callback_data="fastflow_coupon_back"))
|
||||
.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.message(FastFlowCouponState.waiting_for_coupon_code)
|
||||
async def fastflow_apply_coupon(message: Message, state: FSMContext, session: Any):
|
||||
input_text = "Введите купон:"
|
||||
amount_not_found_text = "Сумма не найдена"
|
||||
not_found_text = "Купон не найден"
|
||||
exhausted_text = "Купон исчерпан"
|
||||
already_used_text = "Вы уже использовали этот купон"
|
||||
new_users_only_text = "Купон доступен только новым пользователям"
|
||||
not_applicable_text = "Купон не применим к текущей сумме"
|
||||
no_methods_text = "Нет доступных способов оплаты"
|
||||
|
||||
back_markup = (
|
||||
InlineKeyboardBuilder()
|
||||
.row(InlineKeyboardButton(text=btn.BACK, callback_data="fastflow_coupon_back"))
|
||||
.as_markup()
|
||||
)
|
||||
|
||||
code = (message.text or "").strip()
|
||||
if not code:
|
||||
await message.answer(input_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
data = await state.get_data()
|
||||
required_amount = data.get("required_amount")
|
||||
if required_amount is None:
|
||||
await state.set_state(None)
|
||||
await message.answer(amount_not_found_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
temp_key = data.get("temp_key")
|
||||
temp_payload = data.get("temp_payload")
|
||||
if not temp_key or not isinstance(temp_payload, dict):
|
||||
await state.set_state(None)
|
||||
await message.answer(amount_not_found_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
coupon = await get_coupon_by_code(session, code)
|
||||
if not coupon:
|
||||
await message.answer(not_found_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
if bool(getattr(coupon, "is_used", False)):
|
||||
await message.answer(exhausted_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
usage_count = getattr(coupon, "usage_count", None)
|
||||
usage_limit = getattr(coupon, "usage_limit", None)
|
||||
if usage_count is not None and usage_limit is not None and int(usage_count) >= int(usage_limit):
|
||||
await message.answer(exhausted_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
if await check_coupon_usage(session, coupon.id, message.from_user.id):
|
||||
await message.answer(already_used_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
if bool(getattr(coupon, "new_users_only", False)):
|
||||
used_any = await session.scalar(
|
||||
select(CouponUsage.id).where(CouponUsage.user_id == message.from_user.id).limit(1)
|
||||
)
|
||||
if used_any:
|
||||
await message.answer(new_users_only_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
balance_now = await get_balance(session, message.from_user.id)
|
||||
|
||||
base_price_raw = temp_payload.get("selected_price_rub")
|
||||
if base_price_raw is None:
|
||||
base_price_raw = temp_payload.get("cost")
|
||||
if base_price_raw is None:
|
||||
try:
|
||||
base_price_raw = int(float(balance_now) + float(required_amount))
|
||||
except Exception:
|
||||
base_price_raw = required_amount
|
||||
|
||||
try:
|
||||
base_price = int(base_price_raw)
|
||||
except (TypeError, ValueError):
|
||||
base_price = int(required_amount)
|
||||
|
||||
new_price, discount = apply_percent_coupon(int(base_price), coupon)
|
||||
if int(discount) <= 0:
|
||||
await message.answer(not_applicable_text, reply_markup=back_markup)
|
||||
return
|
||||
|
||||
required_amount_new = int(max(0, ceil(float(new_price) - float(balance_now))))
|
||||
|
||||
await create_coupon_usage(session, coupon.id, message.from_user.id)
|
||||
await update_coupon_usage_count(session, coupon.id)
|
||||
|
||||
percent_value = int(getattr(coupon, "percent", 0) or 0)
|
||||
|
||||
temp_payload_updated = dict(temp_payload)
|
||||
temp_payload_updated["required_amount"] = int(required_amount_new)
|
||||
if "selected_price_rub" in temp_payload_updated:
|
||||
temp_payload_updated["selected_price_rub"] = int(new_price)
|
||||
if "cost" in temp_payload_updated:
|
||||
temp_payload_updated["cost"] = int(new_price)
|
||||
|
||||
await create_temporary_data(session, message.from_user.id, str(temp_key), temp_payload_updated)
|
||||
|
||||
await state.update_data(
|
||||
required_amount=int(required_amount_new),
|
||||
temp_payload=temp_payload_updated,
|
||||
applied_coupon={
|
||||
"code": code,
|
||||
"percent": percent_value,
|
||||
"discount": int(discount),
|
||||
"old_price": int(base_price),
|
||||
"new_price": int(new_price),
|
||||
},
|
||||
)
|
||||
await state.set_state(None)
|
||||
|
||||
payment_config = await get_payment_providers_config()
|
||||
providers_map = await get_providers_with_hooks(payment_config)
|
||||
|
||||
configured = [str(p) for p in (USE_NEW_PAYMENT_FLOW or [])]
|
||||
configured_upper = [p.upper() for p in configured]
|
||||
configured_set = set(configured_upper)
|
||||
|
||||
providers: list[str] = []
|
||||
for p_up in configured_upper:
|
||||
cfg = providers_map.get(p_up) or {}
|
||||
if cfg.get("fast") and cfg.get("enabled", True):
|
||||
providers.append(p_up)
|
||||
|
||||
mode, one_screen = get_currency_mode()
|
||||
multicurrency_mode = mode == "RUB+USD"
|
||||
|
||||
if not multicurrency_mode:
|
||||
allowed_currency = "RUB" if mode == "RUB" else "USD"
|
||||
filtered: list[str] = []
|
||||
for p_up in providers:
|
||||
cfg = providers_map.get(p_up) or {}
|
||||
curr = str(cfg.get("currency") or "").upper()
|
||||
if curr in (allowed_currency, "RUB+USD"):
|
||||
filtered.append(p_up)
|
||||
providers = filtered
|
||||
|
||||
tribute_cfg = providers_map.get("TRIBUTE") or {}
|
||||
tribute_link = (TRIBUTE_LINK or "").strip()
|
||||
tribute_enabled = "TRIBUTE" in configured_set and tribute_cfg.get("enabled", True) and bool(tribute_link)
|
||||
|
||||
stars_cfg = providers_map.get("STARS") or {}
|
||||
stars_enabled_for_fast = "STARS" in configured_set and stars_cfg.get("fast") and stars_cfg.get("enabled", True)
|
||||
|
||||
lead_text = await shortfall_lead_text(
|
||||
session,
|
||||
message.from_user.id,
|
||||
int(required_amount_new),
|
||||
getattr(message.from_user, "language_code", None),
|
||||
)
|
||||
|
||||
coupon_text = FASTFLOW_COUPON_APPLIED_TEMPLATE.format(
|
||||
code=code,
|
||||
percent=percent_value,
|
||||
old_price=int(base_price),
|
||||
discount=int(discount),
|
||||
new_price=int(new_price),
|
||||
)
|
||||
|
||||
if multicurrency_mode and not one_screen:
|
||||
keyboard_original = build_currency_choice_kb(show_stars=stars_enabled_for_fast, show_tribute=tribute_enabled)
|
||||
|
||||
rows = keyboard_original.export()
|
||||
profile_row = None
|
||||
kept_rows: list[list[InlineKeyboardButton]] = []
|
||||
|
||||
for row in rows:
|
||||
if any(getattr(button, "callback_data", None) == "profile" for button in row):
|
||||
if profile_row is None:
|
||||
profile_row = row
|
||||
continue
|
||||
kept_rows.append(row)
|
||||
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
for row in kept_rows:
|
||||
keyboard.row(*row)
|
||||
|
||||
keyboard.row(InlineKeyboardButton(text=btn.COUPON_RESTART, callback_data="fastflow_coupon"))
|
||||
|
||||
if profile_row:
|
||||
keyboard.row(*profile_row)
|
||||
else:
|
||||
keyboard.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await message.answer(
|
||||
f"{lead_text}\n\n{coupon_text}\n\n{FAST_PAY_CHOOSE_CURRENCY}",
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
return
|
||||
|
||||
if not providers and not tribute_enabled:
|
||||
await message.answer(no_methods_text)
|
||||
return
|
||||
|
||||
keyboard = InlineKeyboardBuilder()
|
||||
for provider_upper in providers:
|
||||
button_text = getattr(btn, provider_upper, provider_upper)
|
||||
if one_screen:
|
||||
cfg = providers_map.get(provider_upper) or {}
|
||||
curr = cfg.get("currency")
|
||||
if curr and curr != "RUB+USD":
|
||||
button_text = f"{button_text} ({currency_label(curr)})"
|
||||
keyboard.row(
|
||||
InlineKeyboardButton(
|
||||
text=button_text,
|
||||
callback_data=f"choose_payment_provider|{provider_upper}",
|
||||
)
|
||||
)
|
||||
|
||||
if tribute_enabled:
|
||||
keyboard.row(
|
||||
InlineKeyboardButton(
|
||||
text=getattr(btn, "TRIBUTE", "TRIBUTE"),
|
||||
url=tribute_link,
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.row(InlineKeyboardButton(text=btn.COUPON_RESTART, callback_data="fastflow_coupon"))
|
||||
keyboard.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
await message.answer(
|
||||
f"{lead_text}\n\n{coupon_text}\n\n{FAST_PAY_CHOOSE_PROVIDER}",
|
||||
reply_markup=keyboard.as_markup(),
|
||||
)
|
||||
|
||||
|
||||
@router.callback_query(F.data.startswith("choose_payment_currency|"))
|
||||
async def choose_payment_currency(callback_query: CallbackQuery, state: FSMContext, session: Any):
|
||||
payment_config = await get_payment_providers_config()
|
||||
@@ -232,7 +551,7 @@ async def choose_payment_currency(callback_query: CallbackQuery, state: FSMConte
|
||||
await state.update_data(chosen_currency=currency)
|
||||
|
||||
if not filtered:
|
||||
keyboard = InlineKeyboardBuilder().row(InlineKeyboardButton(text="← Назад", callback_data="profile"))
|
||||
keyboard = InlineKeyboardBuilder().row(InlineKeyboardButton(text=btn.BACK, callback_data="profile"))
|
||||
await edit_or_send_message(
|
||||
target_message=callback_query.message,
|
||||
text="Для выбранной валюты нет доступных касс. Выберите другую валюту или вернитесь в меню.",
|
||||
@@ -263,6 +582,7 @@ async def choose_payment_currency(callback_query: CallbackQuery, state: FSMConte
|
||||
)
|
||||
)
|
||||
|
||||
keyboard.row(InlineKeyboardButton(text=btn.COUPON, callback_data="fastflow_coupon"))
|
||||
keyboard.row(InlineKeyboardButton(text=btn.MAIN_MENU, callback_data="profile"))
|
||||
|
||||
lead_text = await shortfall_lead_text(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
+1
-1
@@ -92,4 +92,4 @@ def get_git_commit_number() -> str:
|
||||
|
||||
|
||||
def get_version() -> str:
|
||||
return f"v.5.1-b23012603 {get_git_commit_number()}"
|
||||
return f"v.5.1-preRelease {get_git_commit_number()}"
|
||||
|
||||
Reference in New Issue
Block a user