diff --git a/database/coupons.py b/database/coupons.py
index 323b68e5..681a78b3 100644
--- a/database/coupons.py
+++ b/database/coupons.py
@@ -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
diff --git a/database/models.py b/database/models.py
index 023ba6ff..c1340e16 100644
--- a/database/models.py
+++ b/database/models.py
@@ -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"
diff --git a/handlers/admin/coupons/coupons_handler.py b/handlers/admin/coupons/coupons_handler.py
index adc607f4..a1adf7d6 100644
--- a/handlers/admin/coupons/coupons_handler.py
+++ b/handlers/admin/coupons/coupons_handler.py
@@ -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 = (
"🎫 Введите данные для создания купона в формате:\n\n"
"📝 код 💰 сумма 🔢 лимит\n\n"
- "Пример: 'COUPON1 50 5' 👈\n\n"
+ "Пример: 'COUPON1 50 5'\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 = (
+ "🎫 Введите данные для создания купона в формате:\n\n"
+ "📝 код ⏳ дни 🔢 лимит\n\n"
+ "Пример: 'DAYS10 10 50'\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 = (
"🎫 Введите данные для создания купона в формате:\n\n"
- "📝 код ⏳ дни 🔢 лимит\n\n"
- "Пример: 'DAYS10 10 50' 👈\n\n"
+ "📝 код 📉 процент 🔢 лимит\n\n"
+ "Пример: 'SALE20 20 10'\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_days_data)
+ 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 = (
- "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n"
+ "❌ Некорректный формат!\n"
"🏷️ код 💰 сумма 🔢 лимит\n"
- "Пример: 'COUPON1 50 5' 👈"
+ "Пример: 'COUPON1 50 5'"
)
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 = "⚠️ Проверьте правильность введенных данных!\n💱 Сумма должна быть числом, а лимит — целым числом."
+ text = "⚠️ Проверьте данные!\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"✅ Купон с кодом {coupon_code} успешно создан!\n"
+ f"✅ Купон {coupon_code} создан!\n"
f"💰 Сумма: {coupon_amount} рублей\n"
- f"🔢 Лимит использования: {usage_limit} раз\n"
+ f"🔢 Лимит: {usage_limit} раз\n"
f"🎯 Доступ: {audience_txt}\n"
f"🔗 Ссылка: {coupon_link}\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 = (
- "❌ Некорректный формат! 📝 Пожалуйста, введите данные в формате:\n"
- "🏷️ код ⏳ дни 🔢 лимит\n"
- "Пример: 'DAYS10 10 50' 👈"
+ "❌ Некорректный формат!\n🏷️ код ⏳ дни 🔢 лимит\nПример: 'DAYS10 10 50'"
)
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 = "⚠️ Проверьте правильность введенных данных!\n💱 Дни должны быть числом, а лимит — целым числом."
+ text = "⚠️ Проверьте данные!\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"✅ Купон с кодом {coupon_code} успешно создан!\n"
+ f"✅ Купон {coupon_code} создан!\n"
f"⏳ {format_days(days)}\n"
- f"🔢 Лимит использования: {usage_limit} раз\n"
+ f"🔢 Лимит: {usage_limit} раз\n"
f"🎯 Доступ: {audience_txt}\n"
f"🔗 Ссылка: {coupon_link}\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 = (
+ "❌ Некорректный формат!\n"
+ "🏷️ код 📉 процент 🔢 лимит\n"
+ "Пример: 'SALE20 20 10'"
+ )
+ 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 = "⚠️ Проверьте данные!\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"✅ Купон {coupon_code} создан!\n"
+ f"📉 Скидка: {percent}%\n"
+ f"🔢 Лимит: {usage_limit} раз\n"
+ f"🎯 Доступ: {audience_txt}\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
+ 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']}"
- description = (
- f"Получи {coupon['amount']} рублей!"
- if coupon["amount"] > 0
- else f"Продли подписку на {format_days(coupon['days'])}!"
- )
- message_text = (
- f"🎫 Купон: {coupon['code']}\n"
- f"{'💰 Бонус: ' + str(coupon['amount']) + ' рублей' if coupon['amount'] > 0 else '⏳ Продление: ' + format_days(coupon['days'])}\n"
- f"👇 Нажми, чтобы активировать!"
- )
+
+ 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"🎫 Купон: {coupon['code']}\n"
+ f"⏳ Продление: {format_days(days_int)}\n"
+ f"👇 Нажми, чтобы активировать!"
+ )
+ elif int(amount_value) > 0:
+ amount_int = int(amount_value)
+ description = f"Получи {amount_int} рублей!"
+ message_text = (
+ f"🎫 Купон: {coupon['code']}\n💰 Бонус: {amount_int} рублей\n👇 Нажми, чтобы активировать!"
+ )
+ else:
+ description = "Купон"
+ message_text = f"🎫 Купон: {coupon['code']}\n👇 Нажми, чтобы активировать!"
builder = InlineKeyboardBuilder()
builder.button(text="Активировать купон", url=coupon_link)
diff --git a/handlers/admin/coupons/keyboard.py b/handlers/admin/coupons/keyboard.py
index dc6b9743..21322b8c 100644
--- a/handlers/admin/coupons/keyboard.py
+++ b/handlers/admin/coupons/keyboard.py
@@ -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"💰 Сумма: {coupon['amount']} рублей"
- if coupon["amount"] > 0
- else f"⏳ {format_days(coupon['days'])}"
+ text = "📜 Список купонов\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"📉 Скидка: {int(percent_value)}%"
+ elif days_value is not None and int(days_value) > 0:
+ value_line = f"⏳ Продление: {format_days(int(days_value))}"
+ elif int(amount_value) > 0:
+ value_line = f"💰 Баланс: {int(amount_value)} ₽"
+ else:
+ value_line = "—"
+
+ text += (
+ f"
"
+ f"{i}. {coupon['code']}\n"
+ f"{value_line}\n"
+ f"🔢 Лимит: {coupon['usage_limit']} | "
+ f"✅ Использовано: {coupon['usage_count']}\n"
+ f"https://t.me/{username_bot}?start=coupons_{coupon['code']}"
+ f"\n\n"
)
- coupon_list += (
- f"🏷️ Код: {coupon['code']}\n"
- f"{value_text}\n"
- f"🔢 Лимит использования: {coupon['usage_limit']} раз\n"
- f"✅ Использовано: {coupon['usage_count']} раз\n"
- f"🔗 Ссылка: https://t.me/{username_bot}?start=coupons_{coupon['code']}\n\n"
- )
- return coupon_list
+
+ return text
diff --git a/handlers/admin/users/users_tariffs.py b/handlers/admin/users/users_tariffs.py
index b735ec26..c53f31e3 100644
--- a/handlers/admin/users/users_tariffs.py
+++ b/handlers/admin/users/users_tariffs.py
@@ -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=(
- "🧩 Выбор конфигурации тарифа\n\n"
- f"📦 Тариф: {tariff.get('name', '—')}\n"
- f"📱 Устройства: {devices_label}\n"
- f"📊 Трафик: {traffic_label}\n\n"
- "Выберите параметры и нажмите «✅ Применить»."
- ),
- reply_markup=builder.as_markup(),
+ text = (
+ "🧩 Выбор конфигурации тарифа\n\n"
+ f"📦 Тариф: {tariff.get('name', '—')}\n"
+ f"📱 Устройства: {devices_label}\n"
+ f"📊 Трафик: {traffic_label}\n\n"
+ "Выберите параметры и нажмите «✅ Применить»."
)
+
+ 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=(
- "🧩 Выбор конфигурации тарифа\n\n"
- f"📦 Тариф: {tariff.get('name', '—')}\n"
- f"📱 Устройства: {devices_label}\n"
- f"📊 Трафик: {traffic_label}\n\n"
- "Выберите параметры и нажмите «✅ Применить»."
- ),
- reply_markup=builder.as_markup(),
+ text = (
+ "🧩 Выбор конфигурации тарифа\n\n"
+ f"📦 Тариф: {tariff.get('name', '—')}\n"
+ f"📱 Устройства: {devices_label}\n"
+ f"📊 Трафик: {traffic_label}\n\n"
+ "Выберите параметры и нажмите «✅ Применить»."
)
+
+ 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()
diff --git a/handlers/keys/key_create.py b/handlers/keys/key_create.py
index edbdbad7..4e7539e5 100644
--- a/handlers/keys/key_create.py
+++ b/handlers/keys/key_create.py
@@ -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(
diff --git a/handlers/keys/key_view.py b/handlers/keys/key_view.py
index eeed0ccc..1eda0788 100644
--- a/handlers/keys/key_view.py
+++ b/handlers/keys/key_view.py
@@ -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
diff --git a/handlers/payments/fast_payment_flow.py b/handlers/payments/fast_payment_flow.py
index 561909f2..4e36f87b 100644
--- a/handlers/payments/fast_payment_flow.py
+++ b/handlers/payments/fast_payment_flow.py
@@ -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(
diff --git a/img/tariffs.jpg b/img/tariffs.jpg
new file mode 100644
index 00000000..17bedc90
Binary files /dev/null and b/img/tariffs.jpg differ
diff --git a/utils/versioning.py b/utils/versioning.py
index 7c0be933..54731f28 100644
--- a/utils/versioning.py
+++ b/utils/versioning.py
@@ -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()}"