diff --git a/app/services/freekassa_service.py b/app/services/freekassa_service.py new file mode 100644 index 00000000..3afc917d --- /dev/null +++ b/app/services/freekassa_service.py @@ -0,0 +1,254 @@ +"""Сервис для работы с API Freekassa.""" + +import hashlib +import time +import logging +from typing import Optional, Dict, Any, Set + +import aiohttp + +from app.config import settings + +logger = logging.getLogger(__name__) + +# IP-адреса Freekassa для проверки webhook +FREEKASSA_IPS: Set[str] = { + "168.119.157.136", + "168.119.60.227", + "178.154.197.79", + "51.250.54.238", +} + +API_BASE_URL = "https://api.fk.life/v1" + + +class FreekassaService: + """Сервис для работы с API Freekassa.""" + + def __init__(self): + self._shop_id: Optional[int] = None + self._api_key: Optional[str] = None + self._secret1: Optional[str] = None + self._secret2: Optional[str] = None + + @property + def shop_id(self) -> int: + if self._shop_id is None: + self._shop_id = settings.FREEKASSA_SHOP_ID + return self._shop_id or 0 + + @property + def api_key(self) -> str: + if self._api_key is None: + self._api_key = settings.FREEKASSA_API_KEY + return self._api_key or "" + + @property + def secret1(self) -> str: + if self._secret1 is None: + self._secret1 = settings.FREEKASSA_SECRET_WORD_1 + return self._secret1 or "" + + @property + def secret2(self) -> str: + if self._secret2 is None: + self._secret2 = settings.FREEKASSA_SECRET_WORD_2 + return self._secret2 or "" + + def _generate_api_signature(self, params: Dict[str, Any]) -> str: + """ + Генерирует подпись для API запроса. + Сортировка по ключам, конкатенация значений через | + """ + sorted_keys = sorted(params.keys()) + values = [str(params[k]) for k in sorted_keys if params[k] is not None] + sign_string = "|".join(values) + return hashlib.md5(sign_string.encode()).hexdigest() + + def generate_form_signature( + self, amount: float, currency: str, order_id: str + ) -> str: + """ + Генерирует подпись для платежной формы. + Формат: MD5(shop_id:amount:secret1:currency:order_id) + """ + sign_string = f"{self.shop_id}:{amount}:{self.secret1}:{currency}:{order_id}" + return hashlib.md5(sign_string.encode()).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) + """ + expected_sign = hashlib.md5( + f"{shop_id}:{amount}:{self.secret2}:{order_id}".encode() + ).hexdigest() + return sign.lower() == expected_sign.lower() + + def verify_webhook_ip(self, ip: str) -> bool: + """Проверяет, что IP входит в разрешенный список Freekassa.""" + return ip in FREEKASSA_IPS + + def build_payment_url( + self, + order_id: str, + amount: float, + currency: str = "RUB", + email: Optional[str] = None, + phone: Optional[str] = None, + payment_system_id: Optional[int] = None, + lang: str = "ru", + ) -> str: + """ + Формирует URL для перенаправления на оплату. + """ + signature = self.generate_form_signature(amount, currency, order_id) + + params = { + "m": self.shop_id, + "oa": amount, + "currency": currency, + "o": order_id, + "s": signature, + "lang": lang, + } + + if email: + params["em"] = email + if phone: + params["phone"] = phone + if payment_system_id: + params["i"] = payment_system_id + + query = "&".join(f"{k}={v}" for k, v in params.items()) + return f"https://pay.freekassa.ru/?{query}" + + 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, + success_url: Optional[str] = None, + failure_url: Optional[str] = None, + notification_url: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Создает заказ через API Freekassa. + POST /orders/create + """ + params = { + "shopId": self.shop_id, + "nonce": int(time.time() * 1000), + "paymentId": order_id, + "i": payment_system_id or 1, + "email": email or "user@example.com", + "ip": ip or "127.0.0.1", + "amount": amount, + "currency": currency, + } + + if success_url: + params["success_url"] = success_url + if failure_url: + params["failure_url"] = failure_url + if notification_url: + params["notification_url"] = notification_url + + params["signature"] = self._generate_api_signature(params) + + 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: + data = await response.json() + + if response.status != 200 or data.get("type") == "error": + logger.error(f"Freekassa create_order error: {data}") + raise Exception( + f"Freekassa API error: {data.get('message', 'Unknown error')}" + ) + + return data + except aiohttp.ClientError as e: + logger.exception(f"Freekassa API connection error: {e}") + raise + + async def get_order_status(self, order_id: str) -> Dict[str, Any]: + """ + Получает статус заказа. + POST /orders + """ + params = { + "shopId": self.shop_id, + "nonce": int(time.time() * 1000), + "paymentId": order_id, + } + params["signature"] = self._generate_api_signature(params) + + 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: + return await response.json() + except aiohttp.ClientError as e: + logger.exception(f"Freekassa API connection error: {e}") + raise + + async def get_balance(self) -> Dict[str, Any]: + """Получает баланс магазина.""" + params = { + "shopId": self.shop_id, + "nonce": int(time.time() * 1000), + } + params["signature"] = self._generate_api_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"Freekassa API connection error: {e}") + raise + + async def get_payment_systems(self) -> Dict[str, Any]: + """Получает список доступных платежных систем.""" + params = { + "shopId": self.shop_id, + "nonce": int(time.time() * 1000), + } + params["signature"] = self._generate_api_signature(params) + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{API_BASE_URL}/currencies", + 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"Freekassa API connection error: {e}") + raise + + +# Singleton instance +freekassa_service = FreekassaService() diff --git a/app/services/payment/__init__.py b/app/services/payment/__init__.py index 9e50718b..1f1a53f1 100644 --- a/app/services/payment/__init__.py +++ b/app/services/payment/__init__.py @@ -15,6 +15,7 @@ from .pal24 import Pal24PaymentMixin from .platega import PlategaPaymentMixin from .wata import WataPaymentMixin from .cloudpayments import CloudPaymentsPaymentMixin +from .freekassa import FreekassaPaymentMixin __all__ = [ "PaymentCommonMixin", @@ -28,4 +29,5 @@ __all__ = [ "PlategaPaymentMixin", "WataPaymentMixin", "CloudPaymentsPaymentMixin", + "FreekassaPaymentMixin", ] diff --git a/app/services/payment/freekassa.py b/app/services/payment/freekassa.py new file mode 100644 index 00000000..b688a8aa --- /dev/null +++ b/app/services/payment/freekassa.py @@ -0,0 +1,516 @@ +"""Mixin для интеграции с Freekassa.""" + +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.freekassa_service import freekassa_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 FreekassaPaymentMixin: + """Mixin для работы с платежами Freekassa.""" + + async def create_freekassa_payment( + self, + db: AsyncSession, + *, + user_id: int, + amount_kopeks: int, + description: str = "Пополнение баланса", + email: Optional[str] = None, + language: str = "ru", + ) -> Optional[Dict[str, Any]]: + """ + Создает платеж Freekassa. + + Args: + db: Сессия БД + user_id: ID пользователя + amount_kopeks: Сумма в копейках + description: Описание платежа + email: Email пользователя + language: Язык интерфейса + + Returns: + Словарь с данными платежа или None при ошибке + """ + if not settings.is_freekassa_enabled(): + logger.error("Freekassa не настроен") + return None + + # Валидация лимитов + if amount_kopeks < settings.FREEKASSA_MIN_AMOUNT_KOPEKS: + logger.warning( + "Freekassa: сумма %s меньше минимальной %s", + amount_kopeks, + settings.FREEKASSA_MIN_AMOUNT_KOPEKS, + ) + return None + + if amount_kopeks > settings.FREEKASSA_MAX_AMOUNT_KOPEKS: + logger.warning( + "Freekassa: сумма %s больше максимальной %s", + amount_kopeks, + settings.FREEKASSA_MAX_AMOUNT_KOPEKS, + ) + return None + + # Генерируем уникальный order_id + order_id = f"fk_{user_id}_{uuid.uuid4().hex[:12]}" + amount_rubles = amount_kopeks / 100 + currency = settings.FREEKASSA_CURRENCY + + # Срок действия платежа + expires_at = datetime.utcnow() + timedelta( + seconds=settings.FREEKASSA_PAYMENT_TIMEOUT_SECONDS + ) + + # Метаданные + metadata = { + "user_id": user_id, + "amount_kopeks": amount_kopeks, + "description": description, + "language": language, + "type": "balance_topup", + } + + try: + # Генерируем URL для оплаты + payment_url = freekassa_service.build_payment_url( + order_id=order_id, + amount=amount_rubles, + currency=currency, + email=email, + lang=language, + ) + + # Импортируем CRUD модуль + freekassa_crud = import_module("app.database.crud.freekassa") + + # Сохраняем в БД + local_payment = await freekassa_crud.create_freekassa_payment( + db=db, + user_id=user_id, + order_id=order_id, + amount_kopeks=amount_kopeks, + currency=currency, + description=description, + payment_url=payment_url, + expires_at=expires_at, + metadata_json=json.dumps(metadata, ensure_ascii=False), + ) + + logger.info( + "Freekassa: создан платеж 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("Freekassa: ошибка создания платежа: %s", e) + return None + + async def process_freekassa_webhook( + self, + db: AsyncSession, + *, + merchant_id: int, + amount: float, + order_id: str, + sign: str, + intid: str, + cur_id: Optional[int] = None, + client_ip: str, + ) -> bool: + """ + Обрабатывает webhook от Freekassa. + + Args: + db: Сессия БД + merchant_id: ID магазина (MERCHANT_ID) + amount: Сумма платежа (AMOUNT) + order_id: Номер заказа (MERCHANT_ORDER_ID) + sign: Подпись (SIGN) + intid: ID транзакции Freekassa + cur_id: ID валюты/платежной системы (CUR_ID) + client_ip: IP клиента + + Returns: + True если платеж успешно обработан + """ + try: + # Проверка IP + if not freekassa_service.verify_webhook_ip(client_ip): + logger.warning("Freekassa webhook: недоверенный IP %s", client_ip) + return False + + # Проверка подписи + if not freekassa_service.verify_webhook_signature( + merchant_id, amount, order_id, sign + ): + logger.warning( + "Freekassa webhook: неверная подпись для order_id=%s", order_id + ) + return False + + # Импортируем CRUD модуль + freekassa_crud = import_module("app.database.crud.freekassa") + + # Получаем платеж из БД + payment = await freekassa_crud.get_freekassa_payment_by_order_id( + db, order_id + ) + if not payment: + logger.warning( + "Freekassa webhook: платеж не найден order_id=%s", order_id + ) + return False + + # Проверка дублирования + if payment.is_paid: + logger.info( + "Freekassa webhook: платеж уже обработан order_id=%s", order_id + ) + return True + + # Проверка суммы + expected_amount = payment.amount_kopeks / 100 + if abs(amount - expected_amount) > 0.01: + logger.warning( + "Freekassa 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 freekassa_crud.update_freekassa_payment_status( + db=db, + payment=payment, + status="success", + is_paid=True, + freekassa_order_id=intid, + payment_system_id=cur_id, + callback_payload=callback_payload, + ) + + # Финализируем платеж (начисляем баланс, создаем транзакцию) + return await self._finalize_freekassa_payment( + db, payment, intid=intid, trigger="webhook" + ) + + except Exception as e: + logger.exception("Freekassa webhook: ошибка обработки: %s", e) + return False + + async def _finalize_freekassa_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( + "Freekassa платеж %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 не найден для Freekassa платежа %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"Пополнение через Freekassa (#{intid or payment.order_id})", + payment_method=PaymentMethod.FREEKASSA, + external_id=str(intid) if intid else payment.order_id, + is_completed=True, + ) + + # Связываем платеж с транзакцией + freekassa_crud = import_module("app.database.crud.freekassa") + await freekassa_crud.update_freekassa_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( + "Ошибка обработки реферального пополнения Freekassa: %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( + "Ошибка отправки админ уведомления Freekassa: %s", error + ) + + # Отправка уведомления пользователю + if getattr(self, "bot", None): + try: + keyboard = await self.build_topup_success_keyboard(user) + display_name = settings.get_freekassa_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( + "Ошибка отправки уведомления пользователю Freekassa: %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 + + # Умная автоактивация если автопокупка не сработала + if not auto_purchase_success: + try: + await auto_activate_subscription_after_topup(db, user) + 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): + 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( + "✅ Обработан Freekassa платеж %s для пользователя %s (trigger=%s)", + payment.order_id, + payment.user_id, + trigger, + ) + + return True + + async def check_freekassa_payment_status( + self, + db: AsyncSession, + order_id: str, + ) -> Optional[Dict[str, Any]]: + """ + Проверяет статус платежа через API. + + Args: + db: Сессия БД + order_id: Номер заказа + + Returns: + Данные о статусе платежа + """ + try: + status_data = await freekassa_service.get_order_status(order_id) + return status_data + except Exception as e: + logger.exception("Freekassa: ошибка проверки статуса: %s", e) + return None + + async def get_freekassa_payment_status( + self, + db: AsyncSession, + local_payment_id: int, + ) -> Optional[Dict[str, Any]]: + """ + Проверяет статус платежа Freekassa по локальному ID. + + Freekassa не предоставляет API для проверки статуса платежа, + поэтому возвращаем текущее состояние из БД. + + Args: + db: Сессия БД + local_payment_id: Внутренний ID платежа + + Returns: + Dict с информацией о платеже или None если не найден + """ + freekassa_crud = import_module("app.database.crud.freekassa") + + payment = await freekassa_crud.get_freekassa_payment_by_id(db, local_payment_id) + if not payment: + logger.warning("Freekassa payment not found: id=%s", local_payment_id) + return None + + # Freekassa не имеет API для проверки статуса, + # информация приходит только через webhook + return { + "payment": payment, + "status": payment.status or "pending", + "is_paid": payment.is_paid, + } diff --git a/app/services/payment_service.py b/app/services/payment_service.py index e6559467..1f068a48 100644 --- a/app/services/payment_service.py +++ b/app/services/payment_service.py @@ -29,6 +29,7 @@ from app.services.payment import ( WataPaymentMixin, ) from app.services.payment.cloudpayments import CloudPaymentsPaymentMixin +from app.services.payment.freekassa import FreekassaPaymentMixin from app.services.yookassa_service import YooKassaService from app.services.wata_service import WataService from app.services.cloudpayments_service import CloudPaymentsService @@ -297,6 +298,7 @@ class PaymentService( PlategaPaymentMixin, WataPaymentMixin, CloudPaymentsPaymentMixin, + FreekassaPaymentMixin, ): """Основной интерфейс платежей, делегирующий работу специализированным mixin-ам.""" diff --git a/app/services/payment_verification_service.py b/app/services/payment_verification_service.py index ce901630..af36028e 100644 --- a/app/services/payment_verification_service.py +++ b/app/services/payment_verification_service.py @@ -19,6 +19,7 @@ from app.database.database import AsyncSessionLocal from app.database.models import ( CloudPaymentsPayment, CryptoBotPayment, + FreekassaPayment, HeleketPayment, MulenPayPayment, Pal24Payment, @@ -66,6 +67,7 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( PaymentMethod.CRYPTOBOT, PaymentMethod.PLATEGA, PaymentMethod.CLOUDPAYMENTS, + PaymentMethod.FREEKASSA, } ) @@ -79,6 +81,7 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset( PaymentMethod.CRYPTOBOT, PaymentMethod.PLATEGA, PaymentMethod.CLOUDPAYMENTS, + PaymentMethod.FREEKASSA, } ) @@ -100,6 +103,8 @@ def method_display_name(method: PaymentMethod) -> str: return "Heleket" if method == PaymentMethod.CLOUDPAYMENTS: return "CloudPayments" + if method == PaymentMethod.FREEKASSA: + return "Freekassa" if method == PaymentMethod.TELEGRAM_STARS: return "Telegram Stars" return method.value @@ -122,6 +127,8 @@ def _method_is_enabled(method: PaymentMethod) -> bool: return settings.is_heleket_enabled() if method == PaymentMethod.CLOUDPAYMENTS: return settings.is_cloudpayments_enabled() + if method == PaymentMethod.FREEKASSA: + return settings.is_freekassa_enabled() return False @@ -362,6 +369,13 @@ def _is_cloudpayments_pending(payment: CloudPaymentsPayment) -> bool: return status in {"pending", "authorized"} +def _is_freekassa_pending(payment: FreekassaPayment) -> bool: + if payment.is_paid: + return False + status = (payment.status or "").lower() + return status in {"pending", "created", "processing"} + + def _parse_cryptobot_amount_kopeks(payment: CryptoBotPayment) -> int: payload = payment.payload or "" match = re.search(r"_(\d+)$", payload) @@ -621,6 +635,31 @@ async def _fetch_cloudpayments_payments(db: AsyncSession, cutoff: datetime) -> L return records +async def _fetch_freekassa_payments(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: + stmt = ( + select(FreekassaPayment) + .options(selectinload(FreekassaPayment.user)) + .where(FreekassaPayment.created_at >= cutoff) + .order_by(desc(FreekassaPayment.created_at)) + ) + result = await db.execute(stmt) + records: List[PendingPayment] = [] + for payment in result.scalars().all(): + if not _is_freekassa_pending(payment): + continue + record = _build_record( + PaymentMethod.FREEKASSA, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + if record: + records.append(record) + return records + + async def _fetch_stars_transactions(db: AsyncSession, cutoff: datetime) -> List[PendingPayment]: stmt = ( select(Transaction) @@ -666,6 +705,7 @@ async def list_recent_pending_payments( await _fetch_heleket_payments(db, cutoff), await _fetch_cryptobot_payments(db, cutoff), await _fetch_cloudpayments_payments(db, cutoff), + await _fetch_freekassa_payments(db, cutoff), await _fetch_stars_transactions(db, cutoff), ) @@ -806,6 +846,20 @@ async def get_payment_record( is_paid=bool(payment.is_paid), ) + if method == PaymentMethod.FREEKASSA: + payment = await db.get(FreekassaPayment, local_payment_id) + if not payment: + return None + await db.refresh(payment, attribute_names=["user"]) + return _build_record( + method, + payment, + identifier=payment.order_id, + amount_kopeks=payment.amount_kopeks, + status=payment.status or "", + is_paid=bool(payment.is_paid), + ) + if method == PaymentMethod.TELEGRAM_STARS: transaction = await db.get(Transaction, local_payment_id) if not transaction: @@ -860,6 +914,9 @@ async def run_manual_check( elif method == PaymentMethod.CLOUDPAYMENTS: result = await payment_service.get_cloudpayments_payment_status(db, local_payment_id) payment = result.get("payment") if result else None + elif method == PaymentMethod.FREEKASSA: + result = await payment_service.get_freekassa_payment_status(db, local_payment_id) + payment = result.get("payment") if result else None else: logger.warning("Manual check requested for unsupported method %s", method) return None diff --git a/app/services/system_settings_service.py b/app/services/system_settings_service.py index ff2177a4..d7b8b787 100644 --- a/app/services/system_settings_service.py +++ b/app/services/system_settings_service.py @@ -84,6 +84,7 @@ class BotConfigurationService: "CRYPTOBOT": "🪙 CryptoBot", "HELEKET": "🪙 Heleket", "CLOUDPAYMENTS": "💳 CloudPayments", + "FREEKASSA": "💳 Freekassa", "YOOKASSA": "🟣 YooKassa", "PLATEGA": "💳 {platega_name}", "TRIBUTE": "🎁 Tribute", @@ -140,6 +141,7 @@ class BotConfigurationService: "CRYPTOBOT": "CryptoBot и криптоплатежи через Telegram.", "HELEKET": "Heleket: криптоплатежи, ключи мерчанта и вебхуки.", "CLOUDPAYMENTS": "CloudPayments: оплата банковскими картами, Public ID, API Secret и вебхуки.", + "FREEKASSA": "Freekassa: ID магазина, API ключ, секретные слова и вебхуки.", "PLATEGA": "{platega_name}: merchant ID, секрет, ссылки возврата и методы оплаты.", "MULENPAY": "Платежи {mulenpay_name} и параметры магазина.", "PAL24": "PAL24 / PayPalych подключения и лимиты.", @@ -313,6 +315,7 @@ class BotConfigurationService: "CRYPTOBOT_": "CRYPTOBOT", "HELEKET_": "HELEKET", "CLOUDPAYMENTS_": "CLOUDPAYMENTS", + "FREEKASSA_": "FREEKASSA", "PLATEGA_": "PLATEGA", "MULENPAY_": "MULENPAY", "PAL24_": "PAL24",