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()}"