diff --git a/.env.example b/.env.example index 0ffbda5c..0a8b3ea7 100644 --- a/.env.example +++ b/.env.example @@ -478,6 +478,10 @@ FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600 FREEKASSA_WEBHOOK_PATH=/freekassa-webhook FREEKASSA_WEBHOOK_HOST=0.0.0.0 FREEKASSA_WEBHOOK_PORT=8088 +# Способ оплаты: пусто = форма выбора, 42 = обычный СБП, 44 = NSPK СБП +FREEKASSA_PAYMENT_SYSTEM_ID= +# Использовать API для создания заказов (обязательно для NSPK СБП) +FREEKASSA_USE_API=false # ===== ИНТЕРФЕЙС И UX ===== diff --git a/app/config.py b/app/config.py index 5a8221c6..29c1972a 100644 --- a/app/config.py +++ b/app/config.py @@ -423,6 +423,10 @@ class Settings(BaseSettings): FREEKASSA_WEBHOOK_PATH: str = "/freekassa-webhook" FREEKASSA_WEBHOOK_HOST: str = "0.0.0.0" FREEKASSA_WEBHOOK_PORT: int = 8088 + # Способ оплаты: None = форма выбора, 42 = обычный СБП, 44 = NSPK СБП + FREEKASSA_PAYMENT_SYSTEM_ID: Optional[int] = None + # Использовать API для создания заказов (нужно для NSPK СБП) + FREEKASSA_USE_API: bool = False MAIN_MENU_MODE: str = "default" CONNECT_BUTTON_MODE: str = "guide" diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index efe663bc..10dc78a5 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -402,8 +402,9 @@ async def extend_subscription( logger.info(f"🌍 Обновлены сквады: {old_squads} → {connected_squads}") # В режиме fixed_with_topup при продлении сбрасываем трафик до фиксированного лимита - # Только если не передан traffic_limit_gb (т.е. не режим тарифов) - if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0: + # Только если не передан traffic_limit_gb И у подписки нет тарифа (классический режим) + # Если у подписки есть tariff_id - трафик определяется тарифом, не сбрасываем + if traffic_limit_gb is None and settings.is_traffic_fixed() and days > 0 and subscription.tariff_id is None: fixed_limit = settings.get_fixed_traffic_limit() old_limit = subscription.traffic_limit_gb if subscription.traffic_limit_gb != fixed_limit or (subscription.purchased_traffic_gb or 0) > 0: diff --git a/app/handlers/subscription/tariff_purchase.py b/app/handlers/subscription/tariff_purchase.py index 28844fa3..4b27debd 100644 --- a/app/handlers/subscription/tariff_purchase.py +++ b/app/handlers/subscription/tariff_purchase.py @@ -33,9 +33,12 @@ def _format_traffic(gb: int) -> str: return f"{gb} ГБ" -def _format_price_kopeks(kopeks: int) -> str: +def _format_price_kopeks(kopeks: int, compact: bool = False) -> str: """Форматирует цену из копеек в рубли.""" rubles = kopeks / 100 + if compact: + # Компактный формат - округляем до рублей + return f"{int(round(rubles))}₽" if rubles == int(rubles): return f"{int(rubles)} ₽" return f"{rubles:.2f} ₽" @@ -77,33 +80,63 @@ def _get_user_period_discount(db_user: User, period_days: int) -> int: return personal_discount +def format_tariffs_list_text( + tariffs: List[Tariff], + db_user: Optional[User] = None, + has_period_discounts: bool = False, +) -> str: + """Форматирует текст со списком тарифов для отображения.""" + lines = ["📦 Выберите тариф"] + + if has_period_discounts: + lines.append("🎁 Скидки по периодам") + + lines.append("") + + for tariff in tariffs: + # Трафик компактно + traffic_gb = tariff.traffic_limit_gb + traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + + # Цена + prices = tariff.period_prices or {} + price_text = "" + discount_icon = "" + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + # Компактный формат: Название — 250ГБ/10📱 от 179₽🔥 + lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") + + # Описание тарифа если есть + if tariff.description: + lines.append(f"{tariff.description}") + + lines.append("") + + return "\n".join(lines) + + def get_tariffs_keyboard( tariffs: List[Tariff], language: str, - discount_percent: int = 0, ) -> InlineKeyboardMarkup: - """Создает клавиатуру выбора тарифов.""" + """Создает компактную клавиатуру выбора тарифов (только названия).""" texts = get_texts(language) buttons = [] for tariff in tariffs: - # Берем минимальную цену для отображения - prices = tariff.period_prices or {} - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - if discount_percent > 0: - min_price = _apply_promo_discount(min_price, discount_percent) - price_text = f"от {_format_price_kopeks(min_price)}" - else: - price_text = "" - - traffic = _format_traffic(tariff.traffic_limit_gb) - - button_text = f"📦 {tariff.name} • {traffic} • {tariff.device_limit} уст. {price_text}" buttons.append([ InlineKeyboardButton( - text=button_text, + text=f"📦 {tariff.name}", callback_data=f"tariff_select:{tariff.id}" ) ]) @@ -137,7 +170,7 @@ def get_tariff_periods_keyboard( if discount_percent > 0: original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" else: price_text = _format_price_kopeks(price) @@ -265,14 +298,12 @@ async def show_tariffs_list( if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: has_period_discounts = True - discount_hint = "" - if has_period_discounts: - discount_hint = "\n\n🎁 Скидки зависят от выбранного периода" + # Формируем текст со списком тарифов и их характеристиками + tariffs_text = format_tariffs_list_text(tariffs, db_user, has_period_discounts) await callback.message.edit_text( - f"📦 Выберите тариф{discount_hint}\n\n" - "Выберите подходящий тариф из списка:", - reply_markup=get_tariffs_keyboard(tariffs, db_user.language, discount_percent=0), + tariffs_text, + reply_markup=get_tariffs_keyboard(tariffs, db_user.language), parse_mode="HTML" ) @@ -540,7 +571,7 @@ def get_tariff_extend_keyboard( if discount_percent > 0: original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" else: price_text = _format_price_kopeks(price) @@ -823,34 +854,73 @@ async def confirm_tariff_extend( # ==================== Переключение тарифов ==================== +def format_tariff_switch_list_text( + tariffs: List[Tariff], + current_tariff_id: Optional[int], + current_tariff_name: str, + db_user: Optional[User] = None, + has_period_discounts: bool = False, +) -> str: + """Форматирует текст со списком тарифов для переключения.""" + lines = [ + "📦 Смена тарифа", + f"📌 Текущий: {current_tariff_name}", + ] + + if has_period_discounts: + lines.append("🎁 Скидки по периодам") + + lines.append("") + lines.append("⚠️ Оплачивается полная стоимость.") + lines.append("") + + for tariff in tariffs: + if tariff.id == current_tariff_id: + continue + + traffic_gb = tariff.traffic_limit_gb + traffic = "∞" if traffic_gb == 0 else f"{traffic_gb}ГБ" + + prices = tariff.period_prices or {} + price_text = "" + discount_icon = "" + if prices: + min_period = min(prices.keys(), key=int) + min_price = prices[min_period] + discount_percent = 0 + if db_user: + discount_percent = _get_user_period_discount(db_user, int(min_period)) + if discount_percent > 0: + min_price = _apply_promo_discount(min_price, discount_percent) + discount_icon = "🔥" + price_text = f"от {_format_price_kopeks(min_price, compact=True)}{discount_icon}" + + lines.append(f"{tariff.name} — {traffic}/{tariff.device_limit}📱 {price_text}") + + if tariff.description: + lines.append(f"{tariff.description}") + + lines.append("") + + return "\n".join(lines) + + def get_tariff_switch_keyboard( tariffs: List[Tariff], current_tariff_id: Optional[int], language: str, ) -> InlineKeyboardMarkup: - """Создает клавиатуру выбора тарифа для переключения.""" + """Создает компактную клавиатуру выбора тарифа для переключения.""" texts = get_texts(language) buttons = [] for tariff in tariffs: - # Пропускаем текущий тариф if tariff.id == current_tariff_id: continue - prices = tariff.period_prices or {} - if prices: - min_period = min(prices.keys(), key=int) - min_price = prices[min_period] - price_text = f"от {_format_price_kopeks(min_price)}" - else: - price_text = "" - - traffic = _format_traffic(tariff.traffic_limit_gb) - - button_text = f"📦 {tariff.name} • {traffic} • {tariff.device_limit} уст. {price_text}" buttons.append([ InlineKeyboardButton( - text=button_text, + text=f"📦 {tariff.name}", callback_data=f"tariff_sw_select:{tariff.id}" ) ]) @@ -884,7 +954,7 @@ def get_tariff_switch_periods_keyboard( if discount_percent > 0: original_price = price price = _apply_promo_discount(price, discount_percent) - price_text = f"{_format_price_kopeks(price)} (было {_format_price_kopeks(original_price)}, -{discount_percent}%)" + price_text = f"{_format_price_kopeks(price)} 🔥−{discount_percent}%" else: price_text = _format_price_kopeks(price) @@ -1002,16 +1072,13 @@ async def show_tariff_switch_list( if period_discounts and isinstance(period_discounts, dict) and len(period_discounts) > 0: has_period_discounts = True - discount_hint = "" - if has_period_discounts: - discount_hint = "\n🎁 Скидки зависят от выбранного периода" + # Формируем текст со списком тарифов + switch_text = format_tariff_switch_list_text( + tariffs, current_tariff_id, current_tariff_name, db_user, has_period_discounts + ) await callback.message.edit_text( - f"📦 Смена тарифа{discount_hint}\n\n" - f"📌 Ваш текущий тариф: {current_tariff_name}\n\n" - "⚠️ При смене тарифа оплачивается полная стоимость нового тарифа.\n" - "Остаток времени текущей подписки будет сохранён.\n\n" - "Выберите новый тариф:", + switch_text, reply_markup=get_tariff_switch_keyboard(tariffs, current_tariff_id, db_user.language), parse_mode="HTML" ) diff --git a/app/services/campaign_service.py b/app/services/campaign_service.py index c645b0fd..15e84d40 100644 --- a/app/services/campaign_service.py +++ b/app/services/campaign_service.py @@ -7,7 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.database.crud.campaign import record_campaign_registration from app.database.crud.subscription import ( - create_trial_subscription, + create_paid_subscription, get_subscription_by_user_id, ) from app.database.crud.user import add_user_balance @@ -141,7 +141,7 @@ class AdvertisingCampaignService: squad_uuid = squads[0] if squads else None - new_subscription = await create_trial_subscription( + new_subscription = await create_paid_subscription( db=db, user_id=user.id, duration_days=duration_days, @@ -150,7 +150,6 @@ class AdvertisingCampaignService: connected_squads=squads, update_server_counters=True, is_trial=True, - squad_uuid=squad_uuid, ) try: diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py index 3afc917d..6a94d91b 100644 --- a/app/services/freekassa_service.py +++ b/app/services/freekassa_service.py @@ -1,6 +1,7 @@ """Сервис для работы с API Freekassa.""" import hashlib +import hmac import time import logging from typing import Optional, Dict, Any, Set @@ -55,15 +56,31 @@ class FreekassaService: self._secret2 = settings.FREEKASSA_SECRET_WORD_2 return self._secret2 or "" + def _generate_api_signature_hmac(self, params: Dict[str, Any]) -> str: + """ + Генерирует подпись для API запроса (HMAC-SHA256). + Используется для API методов (создание заказа и т.д.) + """ + # Исключаем signature из параметров и сортируем по ключу + sign_data = {k: v for k, v in params.items() if k != "signature"} + sorted_items = sorted(sign_data.items()) + + # Формируем строку: значения через | + msg = "|".join(str(v) for _, v in sorted_items) + + # HMAC-SHA256 + return hmac.new( + self.api_key.encode("utf-8"), + msg.encode("utf-8"), + hashlib.sha256 + ).hexdigest() + def _generate_api_signature(self, params: Dict[str, Any]) -> str: """ Генерирует подпись для API запроса. - Сортировка по ключам, конкатенация значений через | + Для новых API методов используется HMAC-SHA256. """ - sorted_keys = sorted(params.keys()) - values = [str(params[k]) for k in sorted_keys if params[k] is not None] - sign_string = "|".join(values) - return hashlib.md5(sign_string.encode()).hexdigest() + return self._generate_api_signature_hmac(params) def generate_form_signature( self, amount: float, currency: str, order_id: str @@ -72,7 +89,9 @@ class FreekassaService: Генерирует подпись для платежной формы. Формат: MD5(shop_id:amount:secret1:currency:order_id) """ - sign_string = f"{self.shop_id}:{amount}:{self.secret1}:{currency}:{order_id}" + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount + sign_string = f"{self.shop_id}:{final_amount}:{self.secret1}:{currency}:{order_id}" return hashlib.md5(sign_string.encode()).hexdigest() def verify_webhook_signature( @@ -82,8 +101,10 @@ class FreekassaService: Проверяет подпись webhook уведомления. Формат: MD5(shop_id:amount:secret2:order_id) """ + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount expected_sign = hashlib.md5( - f"{shop_id}:{amount}:{self.secret2}:{order_id}".encode() + f"{shop_id}:{final_amount}:{self.secret2}:{order_id}".encode() ).hexdigest() return sign.lower() == expected_sign.lower() @@ -102,13 +123,16 @@ class FreekassaService: lang: str = "ru", ) -> str: """ - Формирует URL для перенаправления на оплату. + Формирует URL для перенаправления на оплату (форма выбора). + Используется когда FREEKASSA_USE_API = False. """ - signature = self.generate_form_signature(amount, currency, order_id) + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount + signature = self.generate_form_signature(final_amount, currency, order_id) params = { "m": self.shop_id, - "oa": amount, + "oa": final_amount, "currency": currency, "o": order_id, "s": signature, @@ -119,8 +143,11 @@ class FreekassaService: params["em"] = email if phone: params["phone"] = phone - if payment_system_id: - params["i"] = payment_system_id + + # Используем payment_system_id из настроек, если не передан явно + ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID + if ps_id: + params["i"] = ps_id query = "&".join(f"{k}={v}" for k, v in params.items()) return f"https://pay.freekassa.ru/?{query}" @@ -140,27 +167,32 @@ class FreekassaService: """ Создает заказ через API Freekassa. POST /orders/create + + Используется для NSPK СБП (payment_system_id=44) и других методов. + Возвращает словарь с 'location' (ссылка на оплату). """ + # Приводим amount к int, если это целое число + final_amount = int(amount) if float(amount).is_integer() else amount + + # Используем payment_system_id из настроек, если не передан явно + ps_id = payment_system_id or settings.FREEKASSA_PAYMENT_SYSTEM_ID or 1 + params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), - "paymentId": order_id, - "i": payment_system_id or 1, + "nonce": int(time.time_ns()), # Наносекунды для уникальности + "paymentId": str(order_id), + "i": ps_id, "email": email or "user@example.com", "ip": ip or "127.0.0.1", - "amount": amount, + "amount": final_amount, "currency": currency, } - if success_url: - params["success_url"] = success_url - if failure_url: - params["failure_url"] = failure_url - if notification_url: - params["notification_url"] = notification_url - + # Генерируем подпись HMAC-SHA256 params["signature"] = self._generate_api_signature(params) + logger.info(f"Freekassa API create_order params: {params}") + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -169,6 +201,9 @@ class FreekassaService: headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30), ) as response: + text = await response.text() + logger.info(f"Freekassa API response: {text}") + data = await response.json() if response.status != 200 or data.get("type") == "error": @@ -182,6 +217,32 @@ class FreekassaService: logger.exception(f"Freekassa API connection error: {e}") raise + async def create_order_and_get_url( + self, + order_id: str, + amount: float, + currency: str = "RUB", + email: Optional[str] = None, + ip: Optional[str] = None, + payment_system_id: Optional[int] = None, + ) -> str: + """ + Создает заказ через API и возвращает URL для оплаты. + Удобный метод для получения только ссылки. + """ + result = await self.create_order( + order_id=order_id, + amount=amount, + currency=currency, + email=email, + ip=ip, + payment_system_id=payment_system_id, + ) + location = result.get("location") + if not location: + raise Exception("Freekassa API did not return payment URL (location)") + return location + async def get_order_status(self, order_id: str) -> Dict[str, Any]: """ Получает статус заказа. @@ -189,11 +250,13 @@ class FreekassaService: """ params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), - "paymentId": order_id, + "nonce": int(time.time_ns()), + "paymentId": str(order_id), } params["signature"] = self._generate_api_signature(params) + logger.info(f"Freekassa get_order_status params: {params}") + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -202,6 +265,8 @@ class FreekassaService: headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=30), ) as response: + text = await response.text() + logger.info(f"Freekassa get_order_status response: {text}") return await response.json() except aiohttp.ClientError as e: logger.exception(f"Freekassa API connection error: {e}") @@ -211,7 +276,7 @@ class FreekassaService: """Получает баланс магазина.""" params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), + "nonce": int(time.time_ns()), } params["signature"] = self._generate_api_signature(params) @@ -232,7 +297,7 @@ class FreekassaService: """Получает список доступных платежных систем.""" params = { "shopId": self.shop_id, - "nonce": int(time.time() * 1000), + "nonce": int(time.time_ns()), } params["signature"] = self._generate_api_signature(params) diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py index b688a8aa..5f510ccd 100644 --- a/app/services/payment/freekassa.py +++ b/app/services/payment/freekassa.py @@ -90,14 +90,30 @@ class FreekassaPaymentMixin: } try: - # Генерируем URL для оплаты - payment_url = freekassa_service.build_payment_url( - order_id=order_id, - amount=amount_rubles, - currency=currency, - email=email, - lang=language, - ) + # Выбираем способ создания платежа: API или форма + if settings.FREEKASSA_USE_API: + # Используем API для создания заказа (нужно для NSPK СБП) + payment_url = await freekassa_service.create_order_and_get_url( + order_id=order_id, + amount=amount_rubles, + currency=currency, + email=email, + payment_system_id=settings.FREEKASSA_PAYMENT_SYSTEM_ID, + ) + logger.info( + "Freekassa API: создан заказ order_id=%s, url=%s", + order_id, + payment_url, + ) + else: + # Генерируем URL для формы оплаты (стандартный способ) + payment_url = freekassa_service.build_payment_url( + order_id=order_id, + amount=amount_rubles, + currency=currency, + email=email, + lang=language, + ) # Импортируем CRUD модуль freekassa_crud = import_module("app.database.crud.freekassa") @@ -116,11 +132,12 @@ class FreekassaPaymentMixin: ) logger.info( - "Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s", + "Freekassa: создан платеж order_id=%s, user_id=%s, amount=%s %s, use_api=%s", order_id, user_id, amount_rubles, currency, + settings.FREEKASSA_USE_API, ) return {