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 {