Merge pull request #2379 from Gy9vin/main

feat(payments): добавить KassaAI как отдельную платёжную систему
This commit is contained in:
Egor
2026-01-21 16:12:00 +03:00
committed by GitHub
14 changed files with 1675 additions and 0 deletions
+17
View File
@@ -581,6 +581,23 @@ FREEKASSA_PAYMENT_SYSTEM_ID=
# Использовать API для создания заказов (обязательно для NSPK СБП)
FREEKASSA_USE_API=false
# ===== KASSA AI (api.fk.life) =====
# Отдельная платёжная система, работает параллельно с Freekassa
KASSA_AI_ENABLED=false
KASSA_AI_SHOP_ID=
KASSA_AI_API_KEY=
# Секретное слово 2 (для webhook)
KASSA_AI_SECRET_WORD_2=
KASSA_AI_DISPLAY_NAME=KassaAI
KASSA_AI_CURRENCY=RUB
KASSA_AI_MIN_AMOUNT_KOPEKS=10000
KASSA_AI_MAX_AMOUNT_KOPEKS=100000000
KASSA_AI_WEBHOOK_PATH=/kassa-ai-webhook
KASSA_AI_WEBHOOK_HOST=0.0.0.0
KASSA_AI_WEBHOOK_PORT=8089
# Способ оплаты: 44 = СБП (QR), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID=44
# ===== WATA =====
WATA_ENABLED=false
WATA_BASE_URL=https://api.wata.pro
+30
View File
@@ -488,6 +488,21 @@ class Settings(BaseSettings):
# Публичный IP сервера для Freekassa API (если не задан - определяется автоматически)
SERVER_PUBLIC_IP: Optional[str] = None
# KassaAI (api.fk.life) - отдельная платёжка
KASSA_AI_ENABLED: bool = False
KASSA_AI_SHOP_ID: Optional[int] = None
KASSA_AI_API_KEY: Optional[str] = None
KASSA_AI_SECRET_WORD_2: Optional[str] = None # Для webhook
KASSA_AI_DISPLAY_NAME: str = "KassaAI"
KASSA_AI_CURRENCY: str = "RUB"
KASSA_AI_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб
KASSA_AI_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб
KASSA_AI_WEBHOOK_PATH: str = "/kassa-ai-webhook"
KASSA_AI_WEBHOOK_HOST: str = "0.0.0.0"
KASSA_AI_WEBHOOK_PORT: int = 8089
# Способ оплаты: 44 = СБП (QR код), 36 = Карты РФ, 43 = SberPay
KASSA_AI_PAYMENT_SYSTEM_ID: int = 44
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -1683,6 +1698,21 @@ class Settings(BaseSettings):
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
def is_kassa_ai_enabled(self) -> bool:
return (
self.KASSA_AI_ENABLED
and self.KASSA_AI_SHOP_ID is not None
and self.KASSA_AI_API_KEY is not None
and self.KASSA_AI_SECRET_WORD_2 is not None
)
def get_kassa_ai_display_name(self) -> str:
name = (self.KASSA_AI_DISPLAY_NAME or "").strip()
return name if name else "KassaAI"
def get_kassa_ai_display_name_html(self) -> str:
return html.escape(self.get_kassa_ai_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED
+161
View File
@@ -0,0 +1,161 @@
"""CRUD операции для платежей KassaAI."""
import json
import logging
from datetime import datetime
from typing import Optional, List
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import KassaAiPayment
logger = logging.getLogger(__name__)
async def create_kassa_ai_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = "RUB",
description: Optional[str] = None,
payment_url: Optional[str] = None,
payment_system_id: Optional[int] = None,
expires_at: Optional[datetime] = None,
metadata_json: Optional[str] = None,
) -> KassaAiPayment:
"""Создает запись о платеже KassaAI."""
payment = KassaAiPayment(
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=payment_system_id,
expires_at=expires_at,
metadata_json=json.loads(metadata_json) if metadata_json else None,
status="pending",
is_paid=False,
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан платеж KassaAI: order_id={order_id}, user_id={user_id}")
return payment
async def get_kassa_ai_payment_by_order_id(
db: AsyncSession, order_id: str
) -> Optional[KassaAiPayment]:
"""Получает платеж по order_id."""
result = await db.execute(
select(KassaAiPayment).where(KassaAiPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_external_order_id(
db: AsyncSession, kassa_ai_order_id: str
) -> Optional[KassaAiPayment]:
"""Получает платеж по ID от KassaAI (orderId)."""
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.kassa_ai_order_id == kassa_ai_order_id
)
)
return result.scalar_one_or_none()
async def get_kassa_ai_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[KassaAiPayment]:
"""Получает платеж по ID."""
result = await db.execute(
select(KassaAiPayment).where(KassaAiPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_kassa_ai_payment_status(
db: AsyncSession,
payment: KassaAiPayment,
*,
status: str,
is_paid: bool = False,
kassa_ai_order_id: Optional[str] = None,
payment_system_id: Optional[int] = None,
callback_payload: Optional[dict] = None,
transaction_id: Optional[int] = None,
) -> KassaAiPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if kassa_ai_order_id:
payment.kassa_ai_order_id = kassa_ai_order_id
if payment_system_id is not None:
payment.payment_system_id = payment_system_id
if callback_payload:
payment.callback_payload = callback_payload
if transaction_id:
payment.transaction_id = transaction_id
await db.commit()
await db.refresh(payment)
logger.info(
f"Обновлен статус платежа KassaAI: order_id={payment.order_id}, "
f"status={status}, is_paid={is_paid}"
)
return payment
async def get_pending_kassa_ai_payments(
db: AsyncSession, user_id: int
) -> List[KassaAiPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.user_id == user_id,
KassaAiPayment.status == "pending",
KassaAiPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_kassa_ai_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> List[KassaAiPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(KassaAiPayment)
.where(KassaAiPayment.user_id == user_id)
.order_by(KassaAiPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_kassa_ai_payments(
db: AsyncSession,
) -> List[KassaAiPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(KassaAiPayment).where(
KassaAiPayment.status == "pending",
KassaAiPayment.is_paid == False,
KassaAiPayment.expires_at < now,
)
)
return list(result.scalars().all())
+69
View File
@@ -108,6 +108,7 @@ class PaymentMethod(Enum):
PLATEGA = "platega"
CLOUDPAYMENTS = "cloudpayments"
FREEKASSA = "freekassa"
KASSA_AI = "kassa_ai"
MANUAL = "manual"
BALANCE = "balance"
@@ -649,6 +650,74 @@ class FreekassaPayment(Base):
)
class KassaAiPayment(Base):
"""Платежи через KassaAI (api.fk.life)."""
__tablename__ = "kassa_ai_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
# Идентификаторы
order_id = Column(String(64), unique=True, nullable=False, index=True) # Наш ID заказа
kassa_ai_order_id = Column(String(64), unique=True, nullable=True, index=True) # orderId от KassaAI
# Суммы
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(10), nullable=False, default="RUB")
description = Column(Text, nullable=True)
# Статусы
status = Column(String(32), nullable=False, default="pending") # pending, success, failed, expired
is_paid = Column(Boolean, default=False)
# Данные платежа
payment_url = Column(Text, nullable=True)
payment_system_id = Column(Integer, nullable=True) # ID платежной системы (44=СБП, 36=Карты, 43=SberPay)
# Метаданные
metadata_json = Column(JSON, nullable=True)
callback_payload = Column(JSON, nullable=True)
# Временные метки
paid_at = Column(DateTime, nullable=True)
expires_at = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
# Связь с транзакцией
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
# Relationships
user = relationship("User", backref="kassa_ai_payments")
transaction = relationship("Transaction", backref="kassa_ai_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == "pending"
@property
def is_success(self) -> bool:
return self.status == "success" and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ["failed", "expired"]
def __repr__(self) -> str: # pragma: no cover - debug helper
return (
"<KassaAiPayment(id={0}, order_id={1}, amount={2}₽, status={3})>".format(
self.id,
self.order_id,
self.amount_rubles,
self.status,
)
)
class PromoGroup(Base):
__tablename__ = "promo_groups"
+119
View File
@@ -1401,6 +1401,118 @@ async def create_freekassa_payments_table():
return False
async def create_kassa_ai_payments_table():
"""Создаёт таблицу kassa_ai_payments для платежей через KassaAI."""
table_exists = await check_table_exists('kassa_ai_payments')
if table_exists:
logger.info("Таблица kassa_ai_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE kassa_ai_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT 0,
payment_url TEXT NULL,
payment_system_id INTEGER NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
transaction_id INTEGER NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE kassa_ai_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
order_id VARCHAR(64) NOT NULL UNIQUE,
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT FALSE,
payment_url TEXT NULL,
payment_system_id INTEGER NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at TIMESTAMP NULL,
expires_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
transaction_id INTEGER NULL REFERENCES transactions(id)
);
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE kassa_ai_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
kassa_ai_order_id VARCHAR(64) NULL UNIQUE,
amount_kopeks INT NOT NULL,
currency VARCHAR(10) NOT NULL DEFAULT 'RUB',
description TEXT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
is_paid BOOLEAN NOT NULL DEFAULT 0,
payment_url TEXT NULL,
payment_system_id INT NULL,
metadata_json JSON NULL,
callback_payload JSON NULL,
paid_at DATETIME NULL,
expires_at DATETIME NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
transaction_id INT NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_kassa_ai_user_id ON kassa_ai_payments(user_id);
CREATE UNIQUE INDEX idx_kassa_ai_order_id ON kassa_ai_payments(order_id);
CREATE UNIQUE INDEX idx_kassa_ai_kai_order_id ON kassa_ai_payments(kassa_ai_order_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы kassa_ai_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица kassa_ai_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы kassa_ai_payments: {e}")
return False
async def create_discount_offers_table():
table_exists = await check_table_exists('discount_offers')
if table_exists:
@@ -6333,6 +6445,13 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Проблемы с таблицей Freekassa payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ KASSA_AI ===")
kassa_ai_created = await create_kassa_ai_payments_table()
if kassa_ai_created:
logger.info("✅ Таблица KassaAI payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей KassaAI payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
discount_created = await create_discount_offers_table()
if discount_created:
+391
View File
@@ -0,0 +1,391 @@
"""Handler for KassaAI balance top-up."""
import logging
from aiogram import types
from aiogram.fsm.context import FSMContext
from aiogram.types import InlineKeyboardMarkup, InlineKeyboardButton
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import User
from app.keyboards.inline import get_back_keyboard
from app.localization.texts import get_texts
from app.services.payment_service import PaymentService
from app.states import BalanceStates
from app.utils.decorators import error_handler
logger = logging.getLogger(__name__)
async def _create_kassa_ai_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating KassaAI payment and sending response.
Args:
message_or_callback: Either a Message or CallbackQuery object
db_user: User object
db: Database session
amount_kopeks: Amount in kopeks
edit_message: Whether to edit existing message or send new one
"""
texts = get_texts(db_user.language)
amount_rub = amount_kopeks / 100
# Create payment
payment_service = PaymentService()
description = settings.PAYMENT_BALANCE_TEMPLATE.format(
service_name=settings.PAYMENT_SERVICE_NAME,
description="Пополнение баланса",
)
result = await payment_service.create_kassa_ai_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=description,
email=getattr(db_user, "email", None),
language=db_user.language,
)
if not result:
error_text = texts.t(
"PAYMENT_CREATE_ERROR",
"Не удалось создать платёж. Попробуйте позже.",
)
if edit_message:
await message_or_callback.edit_text(
error_text,
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML",
)
else:
await message_or_callback.answer(
error_text,
parse_mode="HTML",
)
return
payment_url = result.get("payment_url")
display_name = settings.get_kassa_ai_display_name()
# Create keyboard with payment button
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t(
"PAY_BUTTON",
"💳 Оплатить {amount}",
).format(amount=f"{amount_rub:.0f}"),
url=payment_url,
)
],
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
],
]
)
response_text = texts.t(
"KASSA_AI_PAYMENT_CREATED",
"💳 <b>Оплата через {name}</b>\n\n"
"Сумма: <b>{amount}₽</b>\n\n"
"Нажмите кнопку ниже для оплаты.\n"
"После успешной оплаты баланс будет пополнен автоматически.",
).format(name=display_name, amount=f"{amount_rub:.2f}")
if edit_message:
await message_or_callback.edit_text(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
else:
await message_or_callback.answer(
response_text,
reply_markup=keyboard,
parse_mode="HTML",
)
logger.info(
"KassaAI payment created: user=%s, amount=%s",
db_user.telegram_id,
amount_rub,
)
@error_handler
async def process_kassa_ai_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext,
):
"""
Process payment amount directly (called from quick_amount handlers).
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await message.answer(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
await state.clear()
return
# Validate amount
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await message.answer(
texts.t(
"PAYMENT_AMOUNT_TOO_LOW",
"Минимальная сумма пополнения: {min_amount}",
).format(min_amount=min_amount // 100),
parse_mode="HTML",
)
return
if amount_kopeks > max_amount:
await message.answer(
texts.t(
"PAYMENT_AMOUNT_TOO_HIGH",
"Максимальная сумма пополнения: {max_amount}",
).format(max_amount=max_amount // 100),
parse_mode="HTML",
)
return
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=False,
)
@error_handler
async def start_kassa_ai_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start KassaAI top-up process - ask for amount.
"""
texts = get_texts(db_user.language)
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="kassa_ai")
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_kassa_ai_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
]
]
)
await callback.message.edit_text(
texts.t(
"KASSA_AI_ENTER_AMOUNT",
"💳 <b>Пополнение через {name}</b>\n\n"
"Введите сумму пополнения в рублях.\n\n"
"Минимум: {min_amount}\n"
"Максимум: {max_amount}",
).format(
name=display_name,
min_amount=min_amount,
max_amount=f"{max_amount:,}".replace(",", " "),
),
parse_mode="HTML",
reply_markup=keyboard,
)
@error_handler
async def process_kassa_ai_custom_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for KassaAI payment.
"""
data = await state.get_data()
if data.get("payment_method") != "kassa_ai":
return
texts = get_texts(db_user.language)
try:
amount_text = message.text.replace(",", ".").replace(" ", "").strip()
amount_rubles = float(amount_text)
amount_kopeks = int(amount_rubles * 100)
except (ValueError, TypeError):
await message.answer(
texts.t(
"PAYMENT_INVALID_AMOUNT",
"Введите корректную сумму числом.",
),
parse_mode="HTML",
)
return
await process_kassa_ai_payment_amount(
message=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_kassa_ai_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for KassaAI payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_kassa_ai_enabled():
await callback.answer(
texts.t("KASSA_AI_NOT_AVAILABLE", "KassaAI временно недоступен"),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|kassa_ai|{amount_kopeks}
try:
parts = callback.data.split("|")
if len(parts) >= 3:
amount_kopeks = int(parts[2])
else:
await callback.answer("Invalid callback data", show_alert=True)
return
except (ValueError, IndexError):
await callback.answer("Invalid amount", show_alert=True)
return
# Проверка ограничения на пополнение
if getattr(db_user, "restriction_topup", False):
reason = (
getattr(db_user, "restriction_reason", None)
or "Действие ограничено администратором"
)
support_url = settings.get_support_contact_url()
keyboard = []
if support_url:
keyboard.append(
[InlineKeyboardButton(text="🆘 Обжаловать", url=support_url)]
)
keyboard.append(
[InlineKeyboardButton(text=texts.BACK, callback_data="menu_balance")]
)
await callback.message.edit_text(
f"🚫 <b>Пополнение ограничено</b>\n\n{reason}",
parse_mode="HTML",
reply_markup=InlineKeyboardMarkup(inline_keyboard=keyboard),
)
return
# Validate amount
min_amount = settings.KASSA_AI_MIN_AMOUNT_KOPEKS
max_amount = settings.KASSA_AI_MAX_AMOUNT_KOPEKS
if amount_kopeks < min_amount:
await callback.answer(
texts.t("AMOUNT_TOO_LOW_SHORT", "Сумма слишком мала"),
show_alert=True,
)
return
if amount_kopeks > max_amount:
await callback.answer(
texts.t("AMOUNT_TOO_HIGH_SHORT", "Сумма слишком велика"),
show_alert=True,
)
return
await callback.answer()
await state.clear()
await _create_kassa_ai_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)
+16
View File
@@ -118,6 +118,12 @@ async def route_payment_by_method(
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "kassa_ai":
from .kassa_ai import process_kassa_ai_payment_amount
async with AsyncSessionLocal() as db:
await process_kassa_ai_payment_amount(message, db_user, db, amount_kopeks, state)
return True
return False
@@ -990,6 +996,16 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("topup_amount|freekassa|")
)
from .kassa_ai import start_kassa_ai_topup, process_kassa_ai_quick_amount
dp.callback_query.register(
start_kassa_ai_topup,
F.data == "topup_kassa_ai"
)
dp.callback_query.register(
process_kassa_ai_quick_amount,
F.data.startswith("topup_amount|kassa_ai|")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,
+10
View File
@@ -1471,6 +1471,16 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
has_direct_payment_methods = True
if settings.is_kassa_ai_enabled():
kassa_ai_name = settings.get_kassa_ai_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_KASSA_AI", f"💳 {kassa_ai_name}"),
callback_data=_build_callback("kassa_ai")
)
])
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(
+293
View File
@@ -0,0 +1,293 @@
"""Сервис для работы с API KassaAI (api.fk.life)."""
import hashlib
import hmac
import time
import logging
import asyncio
from typing import Optional, Dict, Any, Set
import aiohttp
from app.config import settings
logger = logging.getLogger(__name__)
# Кэш для публичного IP
_cached_public_ip: Optional[str] = None
_ip_fetch_lock = asyncio.Lock()
API_BASE_URL = "https://api.fk.life/v1"
# Сервисы для определения публичного IP
IP_SERVICES = [
"https://api.ipify.org",
"https://ifconfig.me/ip",
"https://icanhazip.com",
"https://ipinfo.io/ip",
]
async def get_public_ip() -> str:
"""
Получает публичный IP сервера.
1. Проверяет переменную окружения SERVER_PUBLIC_IP
2. Если нет - запрашивает через внешние сервисы и кэширует
"""
global _cached_public_ip
env_ip = getattr(settings, 'SERVER_PUBLIC_IP', None)
if env_ip:
return env_ip
if _cached_public_ip:
return _cached_public_ip
async with _ip_fetch_lock:
if _cached_public_ip:
return _cached_public_ip
async with aiohttp.ClientSession() as session:
for service_url in IP_SERVICES:
try:
async with session.get(
service_url,
timeout=aiohttp.ClientTimeout(total=5)
) as response:
if response.status == 200:
ip = (await response.text()).strip()
if ip and len(ip.split('.')) == 4:
_cached_public_ip = ip
logger.info(f"KassaAI: определён публичный IP сервера: {ip}")
return ip
except Exception as e:
logger.debug(f"KassaAI: не удалось получить IP от {service_url}: {e}")
continue
fallback_ip = "127.0.0.1"
logger.warning(f"KassaAI: не удалось определить публичный IP, используем fallback: {fallback_ip}")
_cached_public_ip = fallback_ip
return fallback_ip
class KassaAiService:
"""Сервис для работы с API KassaAI."""
def __init__(self):
self._shop_id: Optional[int] = None
self._api_key: Optional[str] = None
self._secret2: Optional[str] = None
@property
def shop_id(self) -> int:
if self._shop_id is None:
self._shop_id = settings.KASSA_AI_SHOP_ID
return self._shop_id or 0
@property
def api_key(self) -> str:
if self._api_key is None:
self._api_key = settings.KASSA_AI_API_KEY
return self._api_key or ""
@property
def secret2(self) -> str:
if self._secret2 is None:
self._secret2 = settings.KASSA_AI_SECRET_WORD_2
return self._secret2 or ""
def _generate_hmac_signature(self, params: Dict[str, Any]) -> str:
"""
Генерирует подпись для API запроса (HMAC-SHA256).
Сортирует ключи, соединяет значения через |
"""
sign_data = {k: v for k, v in params.items() if k != "signature"}
sorted_keys = sorted(sign_data.keys())
msg = "|".join(str(sign_data[k]) for k in sorted_keys)
return hmac.new(
self.api_key.encode("utf-8"),
msg.encode("utf-8"),
hashlib.sha256
).hexdigest()
def verify_webhook_signature(
self, shop_id: int, amount: float, order_id: str, sign: str
) -> bool:
"""
Проверяет подпись webhook уведомления.
Формат: MD5(shop_id:amount:secret2:order_id)
"""
try:
# Приводим amount к строке без лишних нулей
if isinstance(amount, float) and amount.is_integer():
amount_str = str(int(amount))
else:
amount_str = str(amount)
sign_str = f"{shop_id}:{amount_str}:{self.secret2}:{order_id}"
expected_sign = hashlib.md5(sign_str.encode('utf-8')).hexdigest()
return expected_sign.lower() == sign.lower()
except Exception as e:
logger.error(f"KassaAI webhook verify error: {e}")
return False
async def create_order(
self,
order_id: str,
amount: float,
currency: str = "RUB",
email: Optional[str] = None,
ip: Optional[str] = None,
payment_system_id: Optional[int] = None,
) -> Dict[str, Any]:
"""
Создает заказ через API KassaAI.
POST /orders/create
payment_system_id:
- 44 = СБП (QR код)
- 36 = Банковские карты РФ
- 43 = SberPay
"""
# Приводим amount к int, если это целое число
final_amount = int(amount) if float(amount).is_integer() else amount
# Payment system из настроек или default (44 = СБП)
ps_id = payment_system_id or settings.KASSA_AI_PAYMENT_SYSTEM_ID or 44
# Email: используем telegram-формат если не указан
target_email = email or f"user_{order_id}@telegram.org"
# Определяем публичный IP сервера
server_ip = ip or await get_public_ip()
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
"i": ps_id,
"email": target_email,
"ip": server_ip,
"amount": final_amount,
"currency": currency,
}
# Генерируем подпись HMAC-SHA256
params["signature"] = self._generate_hmac_signature(params)
logger.info(f"KassaAI API create_order: shop_id={self.shop_id}, order_id={order_id}, amount={final_amount}, ps_id={ps_id}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders/create",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.info(f"KassaAI API response: {text}")
data = await response.json()
# Проверяем на ошибку
if data.get("type") == "error":
error_msg = data.get("error") or data.get("message") or "Unknown error"
logger.error(f"KassaAI create_order error: {error_msg}")
raise Exception(f"KassaAI API error: {error_msg}")
if data.get("type") == "success":
return {
"location": data.get("location"),
"orderId": data.get("orderId"),
"paymentId": data.get("paymentId"),
}
# Неизвестный формат ответа
logger.error(f"KassaAI unexpected response: {data}")
raise Exception(f"KassaAI unexpected response format")
except aiohttp.ClientError as e:
logger.exception(f"KassaAI 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("KassaAI API did not return payment URL (location)")
return location
async def get_order_status(self, order_id: str) -> Dict[str, Any]:
"""
Получает статус заказа.
POST /orders
"""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
"paymentId": str(order_id),
}
params["signature"] = self._generate_hmac_signature(params)
logger.debug(f"KassaAI get_order_status: order_id={order_id}")
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/orders",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
text = await response.text()
logger.debug(f"KassaAI get_order_status response: {text}")
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"KassaAI API connection error: {e}")
raise
async def get_balance(self) -> Dict[str, Any]:
"""Получает баланс магазина."""
params = {
"shopId": self.shop_id,
"nonce": int(time.time_ns()),
}
params["signature"] = self._generate_hmac_signature(params)
try:
async with aiohttp.ClientSession() as session:
async with session.post(
f"{API_BASE_URL}/balance",
json=params,
headers={"Content-Type": "application/json"},
timeout=aiohttp.ClientTimeout(total=30),
) as response:
return await response.json()
except aiohttp.ClientError as e:
logger.exception(f"KassaAI API connection error: {e}")
raise
# Singleton instance
kassa_ai_service = KassaAiService()
+2
View File
@@ -16,6 +16,7 @@ from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
from .cloudpayments import CloudPaymentsPaymentMixin
from .freekassa import FreekassaPaymentMixin
from .kassa_ai import KassaAiPaymentMixin
__all__ = [
"PaymentCommonMixin",
@@ -30,4 +31,5 @@ __all__ = [
"WataPaymentMixin",
"CloudPaymentsPaymentMixin",
"FreekassaPaymentMixin",
"KassaAiPaymentMixin",
]
+490
View File
@@ -0,0 +1,490 @@
"""Mixin для интеграции с KassaAI (api.fk.life)."""
from __future__ import annotations
import json
import uuid
import logging
from datetime import datetime, timedelta
from importlib import import_module
from typing import Any, Dict, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.kassa_ai_service import kassa_ai_service
from app.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
from app.utils.user_utils import format_referrer_info
from app.utils.payment_logger import payment_logger as logger
class KassaAiPaymentMixin:
"""Mixin для работы с платежами KassaAI."""
async def create_kassa_ai_payment(
self,
db: AsyncSession,
*,
user_id: int,
amount_kopeks: int,
description: str = "Пополнение баланса",
email: Optional[str] = None,
language: str = "ru",
) -> Optional[Dict[str, Any]]:
"""
Создает платеж KassaAI.
Args:
db: Сессия БД
user_id: ID пользователя
amount_kopeks: Сумма в копейках
description: Описание платежа
email: Email пользователя
language: Язык интерфейса
Returns:
Словарь с данными платежа или None при ошибке
"""
if not settings.is_kassa_ai_enabled():
logger.error("KassaAI не настроен")
return None
# Валидация лимитов
if amount_kopeks < settings.KASSA_AI_MIN_AMOUNT_KOPEKS:
logger.warning(
"KassaAI: сумма %s меньше минимальной %s",
amount_kopeks,
settings.KASSA_AI_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.KASSA_AI_MAX_AMOUNT_KOPEKS:
logger.warning(
"KassaAI: сумма %s больше максимальной %s",
amount_kopeks,
settings.KASSA_AI_MAX_AMOUNT_KOPEKS,
)
return None
# Генерируем уникальный order_id
order_id = f"kai_{user_id}_{uuid.uuid4().hex[:12]}"
amount_rubles = amount_kopeks / 100
currency = settings.KASSA_AI_CURRENCY
# Срок действия платежа (1 час по умолчанию)
expires_at = datetime.utcnow() + timedelta(hours=1)
# Метаданные
metadata = {
"user_id": user_id,
"amount_kopeks": amount_kopeks,
"description": description,
"language": language,
"type": "balance_topup",
}
try:
# Используем API для создания заказа
result = await kassa_ai_service.create_order(
order_id=order_id,
amount=amount_rubles,
currency=currency,
email=email,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
)
payment_url = result.get("location")
if not payment_url:
logger.error("KassaAI API не вернул URL платежа")
return None
logger.info(
"KassaAI API: создан заказ order_id=%s, url=%s",
order_id,
payment_url,
)
# Импортируем CRUD модуль
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
# Сохраняем в БД
local_payment = await kassa_ai_crud.create_kassa_ai_payment(
db=db,
user_id=user_id,
order_id=order_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
payment_url=payment_url,
payment_system_id=settings.KASSA_AI_PAYMENT_SYSTEM_ID,
expires_at=expires_at,
metadata_json=json.dumps(metadata, ensure_ascii=False),
)
logger.info(
"KassaAI: создан платеж order_id=%s, user_id=%s, amount=%s %s",
order_id,
user_id,
amount_rubles,
currency,
)
return {
"order_id": order_id,
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"currency": currency,
"payment_url": payment_url,
"expires_at": expires_at.isoformat(),
"local_payment_id": local_payment.id,
}
except Exception as e:
logger.exception("KassaAI: ошибка создания платежа: %s", e)
return None
async def process_kassa_ai_webhook(
self,
db: AsyncSession,
*,
merchant_id: int,
amount: float,
order_id: str,
sign: str,
intid: str,
cur_id: Optional[int] = None,
) -> bool:
"""
Обрабатывает webhook от KassaAI.
Args:
db: Сессия БД
merchant_id: ID магазина (MERCHANT_ID)
amount: Сумма платежа (AMOUNT)
order_id: Номер заказа (MERCHANT_ORDER_ID)
sign: Подпись (SIGN)
intid: ID транзакции KassaAI
cur_id: ID валюты/платежной системы (CUR_ID)
Returns:
True если платеж успешно обработан
"""
try:
# Проверка подписи
if not kassa_ai_service.verify_webhook_signature(
merchant_id, amount, order_id, sign
):
logger.warning(
"KassaAI webhook: неверная подпись для order_id=%s", order_id
)
return False
# Импортируем CRUD модуль
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
# Получаем платеж из БД
payment = await kassa_ai_crud.get_kassa_ai_payment_by_order_id(
db, order_id
)
if not payment:
logger.warning(
"KassaAI webhook: платеж не найден order_id=%s", order_id
)
return False
# Проверка дублирования
if payment.is_paid:
logger.info(
"KassaAI webhook: платеж уже обработан order_id=%s", order_id
)
return True
# Проверка суммы
expected_amount = payment.amount_kopeks / 100
if abs(amount - expected_amount) > 0.01:
logger.warning(
"KassaAI webhook: несоответствие суммы ожидалось=%s, получено=%s",
expected_amount,
amount,
)
return False
# Обновляем статус платежа
callback_payload = {
"merchant_id": merchant_id,
"amount": amount,
"order_id": order_id,
"intid": intid,
"cur_id": cur_id,
}
payment = await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status="success",
is_paid=True,
kassa_ai_order_id=intid,
payment_system_id=cur_id,
callback_payload=callback_payload,
)
# Финализируем платеж (начисляем баланс, создаем транзакцию)
return await self._finalize_kassa_ai_payment(
db, payment, intid=intid, trigger="webhook"
)
except Exception as e:
logger.exception("KassaAI webhook: ошибка обработки: %s", e)
return False
async def _finalize_kassa_ai_payment(
self,
db: AsyncSession,
payment: Any,
*,
intid: Optional[str],
trigger: str,
) -> bool:
"""Создаёт транзакцию, начисляет баланс и отправляет уведомления."""
payment_module = import_module("app.services.payment_service")
if payment.transaction_id:
logger.info(
"KassaAI платеж %s уже привязан к транзакции (trigger=%s)",
payment.order_id,
trigger,
)
return True
# Получаем пользователя
user = await payment_module.get_user_by_id(db, payment.user_id)
if not user:
logger.error(
"Пользователь %s не найден для KassaAI платежа %s (trigger=%s)",
payment.user_id,
payment.order_id,
trigger,
)
return False
# Создаем транзакцию
transaction = await payment_module.create_transaction(
db,
user_id=payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через KassaAI (#{intid or payment.order_id})",
payment_method=PaymentMethod.KASSA_AI,
external_id=str(intid) if intid else payment.order_id,
is_completed=True,
)
# Связываем платеж с транзакцией
kassa_ai_crud = import_module("app.database.crud.kassa_ai")
await kassa_ai_crud.update_kassa_ai_payment_status(
db=db,
payment=payment,
status=payment.status,
transaction_id=transaction.id,
)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Начисляем баланс
user.balance_kopeks += payment.amount_kopeks
user.updated_at = datetime.utcnow()
promo_group = user.get_primary_promo_group()
subscription = getattr(user, "subscription", None)
referrer_info = format_referrer_info(user)
topup_status = "Первое пополнение" if was_first_topup else "Пополнение"
await db.commit()
# Обработка реферального пополнения
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db, user.id, payment.amount_kopeks, getattr(self, "bot", None)
)
except Exception as error:
logger.error(
"Ошибка обработки реферального пополнения KassaAI: %s", error
)
if was_first_topup and not user.has_made_first_topup:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
await db.refresh(payment)
# Отправка уведомления админам
if getattr(self, "bot", None):
try:
from app.services.admin_notification_service import (
AdminNotificationService,
)
notification_service = AdminNotificationService(self.bot)
await notification_service.send_balance_topup_notification(
user,
transaction,
old_balance,
topup_status=topup_status,
referrer_info=referrer_info,
subscription=subscription,
promo_group=promo_group,
db=db,
)
except Exception as error:
logger.error(
"Ошибка отправки админ уведомления KassaAI: %s", error
)
# Отправка уведомления пользователю
if getattr(self, "bot", None):
try:
keyboard = await self.build_topup_success_keyboard(user)
display_name = settings.get_kassa_ai_display_name()
await self.bot.send_message(
user.telegram_id,
(
"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"💳 Способ: {display_name}\n"
f"🆔 Транзакция: {transaction.id}\n\n"
"Баланс пополнен автоматически!"
),
parse_mode="HTML",
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка отправки уведомления пользователю KassaAI: %s", error
)
# Автопокупка подписки
try:
from app.services.user_cart_service import user_cart_service
from aiogram import types
has_saved_cart = await user_cart_service.has_user_cart(user.id)
auto_purchase_success = False
if has_saved_cart:
try:
auto_purchase_success = await auto_purchase_saved_cart_after_topup(
db,
user,
bot=getattr(self, "bot", None),
)
except Exception as auto_error:
logger.error(
"Ошибка автоматической покупки подписки для пользователя %s: %s",
user.id,
auto_error,
exc_info=True,
)
if auto_purchase_success:
has_saved_cart = False
# Умная автоактивация если автопокупка не сработала
activation_notification_sent = False
if not auto_purchase_success:
try:
_, activation_notification_sent = await auto_activate_subscription_after_topup(
db, user, bot=getattr(self, "bot", None), topup_amount=payment.amount_kopeks
)
except Exception as auto_activate_error:
logger.error(
"Ошибка умной автоактивации для пользователя %s: %s",
user.id,
auto_activate_error,
exc_info=True,
)
# Отправляем уведомление только если его ещё не отправили
if has_saved_cart and getattr(self, "bot", None) and not activation_notification_sent:
from app.localization.texts import get_texts
texts = get_texts(user.language)
cart_message = texts.t(
"BALANCE_TOPUP_CART_REMINDER",
"У вас есть незавершенное оформление подписки. Вернуться?",
)
keyboard = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text=texts.t(
"BALANCE_TOPUP_CART_BUTTON",
"🛒 Продолжить оформление",
),
callback_data="return_to_saved_cart",
)
],
[
types.InlineKeyboardButton(
text="🏠 Главное меню",
callback_data="back_to_menu",
)
],
]
)
await self.bot.send_message(
chat_id=user.telegram_id,
text=(
"✅ Баланс пополнен на "
f"{settings.format_price(payment.amount_kopeks)}!\n\n"
f"{cart_message}"
),
reply_markup=keyboard,
)
except Exception as error:
logger.error(
"Ошибка при работе с сохраненной корзиной для пользователя %s: %s",
user.id,
error,
exc_info=True,
)
logger.info(
"✅ Обработан KassaAI платеж %s для пользователя %s (trigger=%s)",
payment.order_id,
payment.user_id,
trigger,
)
return True
async def check_kassa_ai_payment_status(
self,
db: AsyncSession,
order_id: str,
) -> Optional[Dict[str, Any]]:
"""
Проверяет статус платежа через API.
Args:
db: Сессия БД
order_id: Номер заказа
Returns:
Данные о статусе платежа
"""
try:
status_data = await kassa_ai_service.get_order_status(order_id)
return status_data
except Exception as e:
logger.exception("KassaAI: ошибка проверки статуса: %s", e)
return None
+2
View File
@@ -30,6 +30,7 @@ from app.services.payment import (
)
from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin
from app.services.payment.freekassa import FreekassaPaymentMixin
from app.services.payment.kassa_ai import KassaAiPaymentMixin
from app.services.yookassa_service import YooKassaService
from app.services.wata_service import WataService
from app.services.cloudpayments_service import CloudPaymentsService
@@ -299,6 +300,7 @@ class PaymentService(
WataPaymentMixin,
CloudPaymentsPaymentMixin,
FreekassaPaymentMixin,
KassaAiPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""
+3
View File
@@ -85,6 +85,7 @@ class BotConfigurationService:
"HELEKET": "🪙 Heleket",
"CLOUDPAYMENTS": "💳 CloudPayments",
"FREEKASSA": "💳 Freekassa",
"KASSA_AI": "💳 KassaAI",
"YOOKASSA": "🟣 YooKassa",
"PLATEGA": "💳 {platega_name}",
"TRIBUTE": "🎁 Tribute",
@@ -143,6 +144,7 @@ class BotConfigurationService:
"HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.",
"CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.",
"FREEKASSA": "Freekassa: ID магазина, API ключ, секретные слова и вебхуки.",
"KASSA_AI": "KassaAI: отдельная платёжка api.fk.life с СБП, картами и SberPay.",
"PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.",
"MULENPAY": "Платежи {mulenpay_name} и параметры магазина.",
"PAL24": "PAL24 / PayPalych подключения и лимиты.",
@@ -337,6 +339,7 @@ class BotConfigurationService:
"HELEKET_": "HELEKET",
"CLOUDPAYMENTS_": "CLOUDPAYMENTS",
"FREEKASSA_": "FREEKASSA",
"KASSA_AI_": "KASSA_AI",
"PLATEGA_": "PLATEGA",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",
+72
View File
@@ -892,6 +892,77 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
routes_registered = True
# KassaAI webhook
if settings.is_kassa_ai_enabled():
@router.get(settings.KASSA_AI_WEBHOOK_PATH)
async def kassa_ai_health() -> JSONResponse:
return JSONResponse(
{
"status": "ok",
"service": "kassa_ai_webhook",
"enabled": settings.is_kassa_ai_enabled(),
}
)
@router.post(settings.KASSA_AI_WEBHOOK_PATH)
async def kassa_ai_webhook(request: Request) -> Response:
# Получаем данные формы
try:
form_data = await request.form()
except Exception:
logger.error("KassaAI webhook: не удалось прочитать данные формы")
return Response("Error reading form data", status_code=status.HTTP_400_BAD_REQUEST)
# Извлекаем параметры (те же что и у Freekassa)
merchant_id = form_data.get("MERCHANT_ID")
amount = form_data.get("AMOUNT")
order_id = form_data.get("MERCHANT_ORDER_ID")
sign = form_data.get("SIGN")
intid = form_data.get("intid")
cur_id = form_data.get("CUR_ID")
if not all([merchant_id, amount, order_id, sign, intid]):
logger.warning("KassaAI webhook: отсутствуют обязательные параметры")
return Response("Missing parameters", status_code=status.HTTP_400_BAD_REQUEST)
try:
merchant_id_int = int(merchant_id)
amount_float = float(amount)
cur_id_int = int(cur_id) if cur_id else None
except (ValueError, TypeError) as e:
logger.error("KassaAI webhook: некорректные параметры - %s", e)
return Response("Invalid parameters", status_code=status.HTTP_400_BAD_REQUEST)
# Обрабатываем webhook
db_generator = get_db()
try:
db = await db_generator.__anext__()
except StopAsyncIteration:
return Response("DB Error", status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
try:
success = await payment_service.process_kassa_ai_webhook(
db,
merchant_id=merchant_id_int,
amount=amount_float,
order_id=order_id,
sign=sign,
intid=intid,
cur_id=cur_id_int,
)
finally:
try:
await db_generator.__anext__()
except StopAsyncIteration:
pass
if success:
return Response("YES", status_code=status.HTTP_200_OK)
return Response("Error", status_code=status.HTTP_400_BAD_REQUEST)
routes_registered = True
if routes_registered:
@router.get("/health/payment-webhooks")
async def payment_webhooks_health() -> JSONResponse:
@@ -908,6 +979,7 @@ def create_payment_router(bot: Bot, payment_service: PaymentService) -> APIRoute
"platega_enabled": settings.is_platega_enabled(),
"cloudpayments_enabled": settings.is_cloudpayments_enabled(),
"freekassa_enabled": settings.is_freekassa_enabled(),
"kassa_ai_enabled": settings.is_kassa_ai_enabled(),
}
)