diff --git a/.env.example b/.env.example index 83e497d0..f56eefb6 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/config.py b/app/config.py index b20bbbb2..a9f53ab9 100644 --- a/app/config.py +++ b/app/config.py @@ -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 diff --git a/app/database/crud/kassa_ai.py b/app/database/crud/kassa_ai.py new file mode 100644 index 00000000..610c39a0 --- /dev/null +++ b/app/database/crud/kassa_ai.py @@ -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()) diff --git a/app/database/models.py b/app/database/models.py index 05f98a1d..b9892107 100644 --- a/app/database/models.py +++ b/app/database/models.py @@ -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 ( + "".format( + self.id, + self.order_id, + self.amount_rubles, + self.status, + ) + ) + + class PromoGroup(Base): __tablename__ = "promo_groups" diff --git a/app/database/universal_migration.py b/app/database/universal_migration.py index c49be5f4..c175cb9a 100644 --- a/app/database/universal_migration.py +++ b/app/database/universal_migration.py @@ -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: diff --git a/app/handlers/balance/kassa_ai.py b/app/handlers/balance/kassa_ai.py new file mode 100644 index 00000000..75c71a83 --- /dev/null +++ b/app/handlers/balance/kassa_ai.py @@ -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", + "💳 Оплата через {name}\n\n" + "Сумма: {amount}₽\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"🚫 Пополнение ограничено\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"🚫 Пополнение ограничено\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", + "💳 Пополнение через {name}\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"🚫 Пополнение ограничено\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, + ) diff --git a/app/handlers/balance/main.py b/app/handlers/balance/main.py index 73dcb5e1..b5c34df2 100644 --- a/app/handlers/balance/main.py +++ b/app/handlers/balance/main.py @@ -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, diff --git a/app/keyboards/inline.py b/app/keyboards/inline.py index 1934f4fc..f791c722 100644 --- a/app/keyboards/inline.py +++ b/app/keyboards/inline.py @@ -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( diff --git a/app/services/kassa_ai_service.py b/app/services/kassa_ai_service.py new file mode 100644 index 00000000..689d9e4c --- /dev/null +++ b/app/services/kassa_ai_service.py @@ -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() diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index 1f1a53f1..ce0aa62f 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -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", ] diff --git a/app/services/payment/kassa_ai.py b/app/services/payment/kassa_ai.py new file mode 100644 index 00000000..a0c66e13 --- /dev/null +++ b/app/services/payment/kassa_ai.py @@ -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, + ( + "✅ Пополнение успешно!\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 diff --git a/app/services/payment_service.py b/app/services/payment_service.py index 1f068a48..a98b7282 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -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-ам.""" diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index af4c1583..4fe87bc2 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -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", diff --git a/app/webserver/payments.py b/app/webserver/payments.py index fd10c53f..589e5d57 100644 --- a/app/webserver/payments.py +++ b/app/webserver/payments.py @@ -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(), } )