Revert "Frekassa"

This commit is contained in:
Egor
2026-01-06 22:40:35 +03:00
committed by GitHub
parent fb25032284
commit aeaaa54920
18 changed files with 11 additions and 1902 deletions

View File

@@ -451,23 +451,6 @@ PLATEGA_WEBHOOK_PATH=/platega-webhook
PLATEGA_WEBHOOK_HOST=0.0.0.0
PLATEGA_WEBHOOK_PORT=8086
# ===== FREEKASSA =====
FREEKASSA_ENABLED=false
FREEKASSA_SHOP_ID=
FREEKASSA_API_KEY=
# Секретное слово 1 (для формы оплаты)
FREEKASSA_SECRET_WORD_1=
# Секретное слово 2 (для webhook)
FREEKASSA_SECRET_WORD_2=
FREEKASSA_DISPLAY_NAME=Freekassa
FREEKASSA_CURRENCY=RUB
FREEKASSA_MIN_AMOUNT_KOPEKS=10000
FREEKASSA_MAX_AMOUNT_KOPEKS=100000000
FREEKASSA_PAYMENT_TIMEOUT_SECONDS=3600
FREEKASSA_WEBHOOK_PATH=/freekassa-webhook
FREEKASSA_WEBHOOK_HOST=0.0.0.0
FREEKASSA_WEBHOOK_PORT=8088
# ===== ИНТЕРФЕЙС И UX =====
# Включить логотип для всех сообщений (true - с изображением, false - только текст)

View File

@@ -399,21 +399,6 @@ class Settings(BaseSettings):
CLOUDPAYMENTS_REQUIRE_EMAIL: bool = False
CLOUDPAYMENTS_TEST_MODE: bool = False
# Freekassa
FREEKASSA_ENABLED: bool = False
FREEKASSA_SHOP_ID: Optional[int] = None
FREEKASSA_API_KEY: Optional[str] = None
FREEKASSA_SECRET_WORD_1: Optional[str] = None # Для формы оплаты
FREEKASSA_SECRET_WORD_2: Optional[str] = None # Для webhook
FREEKASSA_DISPLAY_NAME: str = "Freekassa"
FREEKASSA_CURRENCY: str = "RUB"
FREEKASSA_MIN_AMOUNT_KOPEKS: int = 10000 # 100 руб
FREEKASSA_MAX_AMOUNT_KOPEKS: int = 100000000 # 1 000 000 руб
FREEKASSA_PAYMENT_TIMEOUT_SECONDS: int = 3600
FREEKASSA_WEBHOOK_PATH: str = "/freekassa-webhook"
FREEKASSA_WEBHOOK_HOST: str = "0.0.0.0"
FREEKASSA_WEBHOOK_PORT: int = 8088
MAIN_MENU_MODE: str = "default"
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -1429,22 +1414,6 @@ class Settings(BaseSettings):
and self.CLOUDPAYMENTS_API_SECRET is not None
)
def is_freekassa_enabled(self) -> bool:
return (
self.FREEKASSA_ENABLED
and self.FREEKASSA_SHOP_ID is not None
and self.FREEKASSA_API_KEY is not None
and self.FREEKASSA_SECRET_WORD_1 is not None
and self.FREEKASSA_SECRET_WORD_2 is not None
)
def get_freekassa_display_name(self) -> str:
name = (self.FREEKASSA_DISPLAY_NAME or "").strip()
return name if name else "Freekassa"
def get_freekassa_display_name_html(self) -> str:
return html.escape(self.get_freekassa_display_name())
def is_payment_verification_auto_check_enabled(self) -> bool:
return self.PAYMENT_VERIFICATION_AUTO_CHECK_ENABLED

View File

@@ -1,159 +0,0 @@
"""CRUD операции для платежей Freekassa."""
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 FreekassaPayment
logger = logging.getLogger(__name__)
async def create_freekassa_payment(
db: AsyncSession,
*,
user_id: int,
order_id: str,
amount_kopeks: int,
currency: str = "RUB",
description: Optional[str] = None,
payment_url: Optional[str] = None,
expires_at: Optional[datetime] = None,
metadata_json: Optional[str] = None,
) -> FreekassaPayment:
"""Создает запись о платеже Freekassa."""
payment = FreekassaPayment(
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.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"Создан платеж Freekassa: order_id={order_id}, user_id={user_id}")
return payment
async def get_freekassa_payment_by_order_id(
db: AsyncSession, order_id: str
) -> Optional[FreekassaPayment]:
"""Получает платеж по order_id."""
result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.order_id == order_id)
)
return result.scalar_one_or_none()
async def get_freekassa_payment_by_fk_order_id(
db: AsyncSession, freekassa_order_id: str
) -> Optional[FreekassaPayment]:
"""Получает платеж по ID от Freekassa (intid)."""
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.freekassa_order_id == freekassa_order_id
)
)
return result.scalar_one_or_none()
async def get_freekassa_payment_by_id(
db: AsyncSession, payment_id: int
) -> Optional[FreekassaPayment]:
"""Получает платеж по ID."""
result = await db.execute(
select(FreekassaPayment).where(FreekassaPayment.id == payment_id)
)
return result.scalar_one_or_none()
async def update_freekassa_payment_status(
db: AsyncSession,
payment: FreekassaPayment,
*,
status: str,
is_paid: bool = False,
freekassa_order_id: Optional[str] = None,
payment_system_id: Optional[int] = None,
callback_payload: Optional[dict] = None,
transaction_id: Optional[int] = None,
) -> FreekassaPayment:
"""Обновляет статус платежа."""
payment.status = status
payment.is_paid = is_paid
payment.updated_at = datetime.utcnow()
if is_paid:
payment.paid_at = datetime.utcnow()
if freekassa_order_id:
payment.freekassa_order_id = freekassa_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"Обновлен статус платежа Freekassa: order_id={payment.order_id}, "
f"status={status}, is_paid={is_paid}"
)
return payment
async def get_pending_freekassa_payments(
db: AsyncSession, user_id: int
) -> List[FreekassaPayment]:
"""Получает незавершенные платежи пользователя."""
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.user_id == user_id,
FreekassaPayment.status == "pending",
FreekassaPayment.is_paid == False,
)
)
return list(result.scalars().all())
async def get_user_freekassa_payments(
db: AsyncSession,
user_id: int,
limit: int = 10,
offset: int = 0,
) -> List[FreekassaPayment]:
"""Получает платежи пользователя с пагинацией."""
result = await db.execute(
select(FreekassaPayment)
.where(FreekassaPayment.user_id == user_id)
.order_by(FreekassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return list(result.scalars().all())
async def get_expired_pending_payments(
db: AsyncSession,
) -> List[FreekassaPayment]:
"""Получает просроченные платежи в статусе pending."""
now = datetime.utcnow()
result = await db.execute(
select(FreekassaPayment).where(
FreekassaPayment.status == "pending",
FreekassaPayment.is_paid == False,
FreekassaPayment.expires_at < now,
)
)
return list(result.scalars().all())

View File

@@ -87,7 +87,6 @@ class PaymentMethod(Enum):
WATA = "wata"
PLATEGA = "platega"
CLOUDPAYMENTS = "cloudpayments"
FREEKASSA = "freekassa"
MANUAL = "manual"
@@ -546,73 +545,6 @@ class CloudPaymentsPayment(Base):
)
class FreekassaPayment(Base):
__tablename__ = "freekassa_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 заказа
freekassa_order_id = Column(String(64), unique=True, nullable=True, index=True) # intid от Freekassa
# Суммы
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 платежной системы FK
# Метаданные
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="freekassa_payments")
transaction = relationship("Transaction", backref="freekassa_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 (
"<FreekassaPayment(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"

View File

@@ -1289,118 +1289,6 @@ async def ensure_wata_payment_schema() -> bool:
return False
async def create_freekassa_payments_table():
"""Создаёт таблицу freekassa_payments для платежей через Freekassa."""
table_exists = await check_table_exists('freekassa_payments')
if table_exists:
logger.info("Таблица freekassa_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE freekassa_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
freekassa_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_freekassa_user_id ON freekassa_payments(user_id);
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE freekassa_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id),
order_id VARCHAR(64) NOT NULL UNIQUE,
freekassa_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_freekassa_user_id ON freekassa_payments(user_id);
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE freekassa_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
order_id VARCHAR(64) NOT NULL UNIQUE,
freekassa_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_freekassa_user_id ON freekassa_payments(user_id);
CREATE UNIQUE INDEX idx_freekassa_order_id ON freekassa_payments(order_id);
CREATE UNIQUE INDEX idx_freekassa_fk_order_id ON freekassa_payments(freekassa_order_id);
"""
else:
logger.error(f"Неподдерживаемый тип БД для таблицы freekassa_payments: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица freekassa_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы freekassa_payments: {e}")
return False
async def create_discount_offers_table():
table_exists = await check_table_exists('discount_offers')
if table_exists:
@@ -5192,13 +5080,6 @@ async def run_universal_migration():
else:
logger.warning("⚠️ Не удалось обновить схему Wata payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ FREEKASSA ===")
freekassa_created = await create_freekassa_payments_table()
if freekassa_created:
logger.info("✅ Таблица Freekassa payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей Freekassa payments")
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ DISCOUNT_OFFERS ===")
discount_created = await create_discount_offers_table()
if discount_created:

View File

@@ -36,10 +36,7 @@ class WebhookServer:
if settings.is_cryptobot_enabled():
self.app.router.add_post(settings.CRYPTOBOT_WEBHOOK_PATH, self._cryptobot_webhook_handler)
if settings.is_freekassa_enabled():
self.app.router.add_post(settings.FREEKASSA_WEBHOOK_PATH, self._freekassa_webhook_handler)
self.app.router.add_get('/health', self._health_check)
self.app.router.add_options(settings.TRIBUTE_WEBHOOK_PATH, self._options_handler)
@@ -47,9 +44,7 @@ class WebhookServer:
self.app.router.add_options(settings.MULENPAY_WEBHOOK_PATH, self._options_handler)
if settings.is_cryptobot_enabled():
self.app.router.add_options(settings.CRYPTOBOT_WEBHOOK_PATH, self._options_handler)
if settings.is_freekassa_enabled():
self.app.router.add_options(settings.FREEKASSA_WEBHOOK_PATH, self._options_handler)
logger.info(f"Webhook сервер настроен:")
logger.info(f" - Tribute webhook: POST {settings.TRIBUTE_WEBHOOK_PATH}")
if settings.is_mulenpay_enabled():
@@ -61,8 +56,6 @@ class WebhookServer:
)
if settings.is_cryptobot_enabled():
logger.info(f" - CryptoBot webhook: POST {settings.CRYPTOBOT_WEBHOOK_PATH}")
if settings.is_freekassa_enabled():
logger.info(f" - Freekassa webhook: POST {settings.FREEKASSA_WEBHOOK_PATH}")
logger.info(f" - Health check: GET /health")
return self.app
@@ -453,81 +446,7 @@ class WebhookServer:
"service": "payment-webhooks",
"tribute_enabled": settings.TRIBUTE_ENABLED,
"cryptobot_enabled": settings.is_cryptobot_enabled(),
"freekassa_enabled": settings.is_freekassa_enabled(),
"port": settings.TRIBUTE_WEBHOOK_PORT,
"tribute_path": settings.TRIBUTE_WEBHOOK_PATH,
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None,
"freekassa_path": settings.FREEKASSA_WEBHOOK_PATH if settings.is_freekassa_enabled() else None,
"cryptobot_path": settings.CRYPTOBOT_WEBHOOK_PATH if settings.is_cryptobot_enabled() else None
})
async def _freekassa_webhook_handler(self, request: web.Request) -> web.Response:
"""
Обработчик webhook от Freekassa.
Freekassa отправляет POST запрос с form-data:
- MERCHANT_ID: ID магазина
- AMOUNT: Сумма платежа
- MERCHANT_ORDER_ID: Наш order_id
- SIGN: Подпись MD5(shop_id:amount:secret2:order_id)
- intid: ID транзакции Freekassa
- CUR_ID: ID валюты/платежной системы
"""
try:
logger.info(f"Получен Freekassa webhook: {request.method} {request.path}")
# Получаем IP клиента
client_ip = request.headers.get("X-Forwarded-For", "").split(",")[0].strip()
if not client_ip:
client_ip = request.remote or "unknown"
logger.info(f"Freekassa webhook IP: {client_ip}")
# Freekassa отправляет form-data
try:
form_data = await request.post()
except Exception as e:
logger.error(f"Ошибка парсинга Freekassa form-data: {e}")
return web.Response(text="NO", status=400)
logger.info(f"Freekassa webhook data: {dict(form_data)}")
# Извлекаем параметры
merchant_id = int(form_data.get("MERCHANT_ID", 0))
amount = float(form_data.get("AMOUNT", 0))
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 order_id or not sign:
logger.warning("Freekassa webhook: отсутствуют обязательные параметры")
return web.Response(text="NO", status=400)
# Обрабатываем платеж через PaymentService
from app.services.payment_service import PaymentService
from app.database.database import AsyncSessionLocal
payment_service = PaymentService(self.bot)
async with AsyncSessionLocal() as db:
success = await payment_service.process_freekassa_webhook(
db=db,
merchant_id=merchant_id,
amount=amount,
order_id=order_id,
sign=sign,
intid=intid,
cur_id=int(cur_id) if cur_id else None,
client_ip=client_ip,
)
if success:
logger.info(f"Freekassa webhook обработан успешно: order_id={order_id}")
# Freekassa ожидает YES в ответе
return web.Response(text="YES", status=200)
else:
logger.error(f"Ошибка обработки Freekassa webhook: order_id={order_id}")
return web.Response(text="NO", status=400)
except Exception as e:
logger.error(f"Критическая ошибка обработки Freekassa webhook: {e}", exc_info=True)
return web.Response(text="NO", status=500)

View File

@@ -61,7 +61,7 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
},
"payments": {
"title": "💳 Платежные системы",
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, Freekassa, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"description": "YooKassa, CryptoBot, Heleket, CloudPayments, MulenPay, PAL24, Wata, Platega, Tribute и Telegram Stars.",
"icon": "💳",
"categories": (
"PAYMENT",
@@ -70,7 +70,6 @@ CATEGORY_GROUP_METADATA: Dict[str, Dict[str, object]] = {
"CRYPTOBOT",
"HELEKET",
"CLOUDPAYMENTS",
"FREEKASSA",
"MULENPAY",
"PAL24",
"WATA",
@@ -258,7 +257,6 @@ def _get_group_status(group_key: str) -> Tuple[str, str]:
"CryptoBot": settings.is_cryptobot_enabled(),
"Platega": settings.is_platega_enabled(),
"CloudPayments": settings.is_cloudpayments_enabled(),
"Freekassa": settings.is_freekassa_enabled(),
"MulenPay": settings.is_mulenpay_enabled(),
"PAL24": settings.is_pal24_enabled(),
"Tribute": settings.TRIBUTE_ENABLED,
@@ -1336,9 +1334,6 @@ def _build_settings_keyboard(
elif category_key == "CRYPTOBOT":
label = texts.t("PAYMENT_CRYPTOBOT", "🪙 Криптовалюта (CryptoBot)")
test_payment_buttons.append([_test_button(f"{label} · тест", "cryptobot")])
elif category_key == "FREEKASSA":
label = texts.t("PAYMENT_FREEKASSA", "💳 Freekassa")
test_payment_buttons.append([_test_button(f"{label} · тест", "freekassa")])
if test_payment_buttons:
rows.extend(test_payment_buttons)
@@ -2336,47 +2331,6 @@ async def test_payment_provider(
await _refresh_markup()
return
if method == "freekassa":
if not settings.is_freekassa_enabled():
await callback.answer("❌ Freekassa отключена", show_alert=True)
return
amount_kopeks = settings.FREEKASSA_MIN_AMOUNT_KOPEKS
payment_result = await payment_service.create_freekassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description="Тестовый платеж Freekassa (админ)",
email=getattr(db_user, "email", None),
language=db_user.language or settings.DEFAULT_LANGUAGE,
)
if not payment_result or not payment_result.get("payment_url"):
await callback.answer("Не удалось создать тестовый платеж Freekassa", show_alert=True)
await _refresh_markup()
return
payment_url = payment_result["payment_url"]
message_text = (
"🧪 <b>Тестовый платеж Freekassa</b>\n\n"
f"💰 Сумма: {texts.format_price(amount_kopeks)}\n"
f"🆔 Order ID: {payment_result['order_id']}"
)
reply_markup = types.InlineKeyboardMarkup(
inline_keyboard=[
[
types.InlineKeyboardButton(
text="💳 Перейти к оплате",
url=payment_url,
)
]
]
)
await callback.message.answer(message_text, reply_markup=reply_markup, parse_mode="HTML")
await callback.answer("✅ Ссылка на платеж Freekassa отправлена", show_alert=True)
await _refresh_markup()
return
await callback.answer("❌ Неизвестный способ тестирования платежа", show_alert=True)
await _refresh_markup()

View File

@@ -1,391 +0,0 @@
"""Handler for Freekassa 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_freekassa_payment_and_respond(
message_or_callback,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
edit_message: bool = False,
):
"""
Common logic for creating Freekassa 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_freekassa_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_freekassa_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(
"FREEKASSA_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(
"Freekassa payment created: user=%s, amount=%s",
db_user.telegram_id,
amount_rub,
)
@error_handler
async def process_freekassa_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.FREEKASSA_MIN_AMOUNT_KOPEKS
max_amount = settings.FREEKASSA_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_freekassa_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_freekassa_topup(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Start Freekassa 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="freekassa")
min_amount = settings.FREEKASSA_MIN_AMOUNT_KOPEKS // 100
max_amount = settings.FREEKASSA_MAX_AMOUNT_KOPEKS // 100
display_name = settings.get_freekassa_display_name()
keyboard = InlineKeyboardMarkup(
inline_keyboard=[
[
InlineKeyboardButton(
text=texts.t("BACK_BUTTON", "◀️ Назад"),
callback_data="menu_balance",
)
]
]
)
await callback.message.edit_text(
texts.t(
"FREEKASSA_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_freekassa_custom_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process custom amount input for Freekassa payment.
"""
data = await state.get_data()
if data.get("payment_method") != "freekassa":
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_freekassa_payment_amount(
message=message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
state=state,
)
@error_handler
async def process_freekassa_quick_amount(
callback: types.CallbackQuery,
db_user: User,
db: AsyncSession,
state: FSMContext,
):
"""
Process quick amount selection for Freekassa payment.
Called when user clicks a predefined amount button.
"""
texts = get_texts(db_user.language)
if not settings.is_freekassa_enabled():
await callback.answer(
texts.t("FREEKASSA_NOT_AVAILABLE", "Freekassa временно недоступен"),
show_alert=True,
)
return
# Extract amount from callback data: topup_amount|freekassa|{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.FREEKASSA_MIN_AMOUNT_KOPEKS
max_amount = settings.FREEKASSA_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_freekassa_payment_and_respond(
message_or_callback=callback.message,
db_user=db_user,
db=db,
amount_kopeks=amount_kopeks,
edit_message=True,
)

View File

@@ -111,12 +111,6 @@ async def route_payment_by_method(
await process_cloudpayments_payment_amount(message, db_user, db, amount_kopeks, state)
return True
if payment_method == "freekassa":
from .freekassa import process_freekassa_payment_amount
async with AsyncSessionLocal() as db:
await process_freekassa_payment_amount(message, db_user, db, amount_kopeks, state)
return True
return False
@@ -928,16 +922,6 @@ def register_balance_handlers(dp: Dispatcher):
F.data.startswith("topup_amount|cloudpayments|")
)
from .freekassa import start_freekassa_topup, process_freekassa_quick_amount
dp.callback_query.register(
start_freekassa_topup,
F.data == "topup_freekassa"
)
dp.callback_query.register(
process_freekassa_quick_amount,
F.data.startswith("topup_amount|freekassa|")
)
from .mulenpay import check_mulenpay_payment_status
dp.callback_query.register(
check_mulenpay_payment_status,

View File

@@ -1429,16 +1429,6 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = DEFAULT_LAN
])
has_direct_payment_methods = True
if settings.is_freekassa_enabled():
freekassa_name = settings.get_freekassa_display_name()
keyboard.append([
InlineKeyboardButton(
text=texts.t("PAYMENT_FREEKASSA", f"💳 {freekassa_name}"),
callback_data=_build_callback("freekassa")
)
])
has_direct_payment_methods = True
if settings.is_support_topup_enabled():
keyboard.append([
InlineKeyboardButton(

View File

@@ -1,254 +0,0 @@
"""Сервис для работы с 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()

View File

@@ -15,7 +15,6 @@ from .pal24 import Pal24PaymentMixin
from .platega import PlategaPaymentMixin
from .wata import WataPaymentMixin
from .cloudpayments import CloudPaymentsPaymentMixin
from .freekassa import FreekassaPaymentMixin
__all__ = [
"PaymentCommonMixin",
@@ -29,5 +28,4 @@ __all__ = [
"PlategaPaymentMixin",
"WataPaymentMixin",
"CloudPaymentsPaymentMixin",
"FreekassaPaymentMixin",
]

View File

@@ -1,516 +0,0 @@
"""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,
(
"✅ <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(
"Ошибка отправки уведомления пользователю 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,
}

View File

@@ -29,7 +29,6 @@ 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
@@ -298,7 +297,6 @@ class PaymentService(
PlategaPaymentMixin,
WataPaymentMixin,
CloudPaymentsPaymentMixin,
FreekassaPaymentMixin,
):
"""Основной интерфейс платежей, делегирующий работу специализированным mixin-ам."""

View File

@@ -19,7 +19,6 @@ from app.database.database import AsyncSessionLocal
from app.database.models import (
CloudPaymentsPayment,
CryptoBotPayment,
FreekassaPayment,
HeleketPayment,
MulenPayPayment,
Pal24Payment,
@@ -67,7 +66,6 @@ SUPPORTED_MANUAL_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
}
)
@@ -81,7 +79,6 @@ SUPPORTED_AUTO_CHECK_METHODS: frozenset[PaymentMethod] = frozenset(
PaymentMethod.CRYPTOBOT,
PaymentMethod.PLATEGA,
PaymentMethod.CLOUDPAYMENTS,
PaymentMethod.FREEKASSA,
}
)
@@ -103,8 +100,6 @@ 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
@@ -127,8 +122,6 @@ 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
@@ -369,13 +362,6 @@ 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)
@@ -635,31 +621,6 @@ 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)
@@ -705,7 +666,6 @@ 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),
)
@@ -846,20 +806,6 @@ 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:
@@ -914,9 +860,6 @@ 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

View File

@@ -84,7 +84,6 @@ class BotConfigurationService:
"CRYPTOBOT": "🪙 CryptoBot",
"HELEKET": "🪙 Heleket",
"CLOUDPAYMENTS": "💳 CloudPayments",
"FREEKASSA": "💳 Freekassa",
"YOOKASSA": "🟣 YooKassa",
"PLATEGA": "💳 {platega_name}",
"TRIBUTE": "🎁 Tribute",
@@ -141,7 +140,6 @@ 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 подключения и лимиты.",
@@ -315,7 +313,6 @@ class BotConfigurationService:
"CRYPTOBOT_": "CRYPTOBOT",
"HELEKET_": "HELEKET",
"CLOUDPAYMENTS_": "CLOUDPAYMENTS",
"FREEKASSA_": "FREEKASSA",
"PLATEGA_": "PLATEGA",
"MULENPAY_": "MULENPAY",
"PAL24_": "PAL24",

View File

@@ -887,19 +887,6 @@ async def get_payment_methods(
)
)
if settings.is_freekassa_enabled():
methods.append(
MiniAppPaymentMethod(
id="freekassa",
icon="💳",
requires_amount=True,
currency="RUB",
min_amount_kopeks=settings.FREEKASSA_MIN_AMOUNT_KOPEKS,
max_amount_kopeks=settings.FREEKASSA_MAX_AMOUNT_KOPEKS,
integration_type=MiniAppPaymentIntegrationType.REDIRECT,
)
)
if settings.TRIBUTE_ENABLED:
methods.append(
MiniAppPaymentMethod(
@@ -916,14 +903,13 @@ async def get_payment_methods(
"yookassa_sbp": 2,
"yookassa": 3,
"cloudpayments": 4,
"freekassa": 5,
"mulenpay": 6,
"pal24": 7,
"platega": 8,
"wata": 9,
"cryptobot": 10,
"heleket": 11,
"tribute": 12,
"mulenpay": 5,
"pal24": 6,
"platega": 7,
"wata": 8,
"cryptobot": 9,
"heleket": 10,
"tribute": 11,
}
methods.sort(key=lambda item: order_map.get(item.id, 99))
@@ -1397,47 +1383,6 @@ async def create_payment_link(
},
)
if method == "freekassa":
if not settings.is_freekassa_enabled():
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
if amount_kopeks is None or amount_kopeks <= 0:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
if amount_kopeks < settings.FREEKASSA_MIN_AMOUNT_KOPEKS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Amount is below minimum ({settings.FREEKASSA_MIN_AMOUNT_KOPEKS / 100:.2f} RUB)",
)
if amount_kopeks > settings.FREEKASSA_MAX_AMOUNT_KOPEKS:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=f"Amount exceeds maximum ({settings.FREEKASSA_MAX_AMOUNT_KOPEKS / 100:.2f} RUB)",
)
payment_service = PaymentService()
result = await payment_service.create_freekassa_payment(
db=db,
user_id=user.id,
amount_kopeks=amount_kopeks,
description=settings.get_balance_payment_description(amount_kopeks),
email=getattr(user, "email", None),
language=user.language or settings.DEFAULT_LANGUAGE,
)
if not result or not result.get("payment_url"):
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
return MiniAppPaymentCreateResponse(
method=method,
payment_url=result["payment_url"],
amount_kopeks=amount_kopeks,
extra={
"local_payment_id": result.get("local_payment_id"),
"order_id": result.get("order_id"),
"requested_at": _current_request_timestamp(),
},
)
if method == "tribute":
if not settings.TRIBUTE_ENABLED:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
@@ -1536,8 +1481,6 @@ async def _resolve_payment_status_entry(
return await _resolve_heleket_payment_status(db, user, query)
if method == "cloudpayments":
return await _resolve_cloudpayments_payment_status(db, user, query)
if method == "freekassa":
return await _resolve_freekassa_payment_status(db, user, query)
if method == "stars":
return await _resolve_stars_payment_status(db, user, query)
if method == "tribute":
@@ -2152,64 +2095,6 @@ async def _resolve_cloudpayments_payment_status(
)
async def _resolve_freekassa_payment_status(
db: AsyncSession,
user: User,
query: MiniAppPaymentStatusQuery,
) -> MiniAppPaymentStatusResult:
from app.database.crud.freekassa import (
get_freekassa_payment_by_id,
get_freekassa_payment_by_order_id,
)
payment = None
if query.local_payment_id:
payment = await get_freekassa_payment_by_id(db, query.local_payment_id)
if not payment and query.payment_id:
payment = await get_freekassa_payment_by_order_id(db, query.payment_id)
if not payment or payment.user_id != user.id:
return MiniAppPaymentStatusResult(
method="freekassa",
status="pending",
is_paid=False,
amount_kopeks=query.amount_kopeks,
message="Payment not found",
extra={
"local_payment_id": query.local_payment_id,
"order_id": query.payment_id,
"payload": query.payload,
"started_at": query.started_at,
},
)
status_raw = payment.status
is_paid = bool(payment.is_paid)
status = _classify_status(status_raw, is_paid)
completed_at = payment.paid_at or payment.updated_at or payment.created_at
return MiniAppPaymentStatusResult(
method="freekassa",
status=status,
is_paid=status == "paid",
amount_kopeks=payment.amount_kopeks,
currency=payment.currency,
completed_at=completed_at,
transaction_id=payment.transaction_id,
external_id=payment.freekassa_order_id,
message=None,
extra={
"status": payment.status,
"local_payment_id": payment.id,
"order_id": payment.order_id,
"freekassa_order_id": payment.freekassa_order_id,
"payment_url": payment.payment_url,
"payload": query.payload,
"started_at": query.started_at,
},
)
async def _resolve_stars_payment_status(
db: AsyncSession,
user: User,

View File

@@ -5736,8 +5736,6 @@
'topup.method.tribute.description': 'Redirect to Tribute payment page',
'topup.method.cloudpayments.title': 'Bank card (CloudPayments)',
'topup.method.cloudpayments.description': 'Secure bank card payment',
'topup.method.freekassa.title': 'Freekassa',
'topup.method.freekassa.description': 'Various payment methods via Freekassa',
'topup.amount.title': 'Enter amount',
'topup.amount.subtitle': 'Specify how much you want to top up',
'topup.amount.placeholder': 'Amount in {currency}',
@@ -6160,8 +6158,6 @@
'topup.method.tribute.description': 'Переход на страницу оплаты Tribute',
'topup.method.cloudpayments.title': 'Банковская карта (CloudPayments)',
'topup.method.cloudpayments.description': 'Безопасная оплата банковской картой',
'topup.method.freekassa.title': 'Freekassa',
'topup.method.freekassa.description': 'Различные способы оплаты через Freekassa',
'topup.amount.title': 'Введите сумму',
'topup.amount.subtitle': 'Укажите сумму пополнения',
'topup.amount.placeholder': 'Сумма в {currency}',