Files
remnawave-bedolaga-telegram…/app/database/crud/yookassa.py
T
Fringg 5e404cc082 feat: публичные лендинг-страницы для быстрой покупки VPN-подписок
- Модели LandingPage и GuestPurchase + миграции 0018/0019
- CRUD для лендингов и гостевых покупок
- Публичные роуты: GET /{slug}, POST /{slug}/purchase, GET /purchase/{token}
- Админ-роуты: CRUD лендингов с RBAC (manage_landings)
- Сервис guest_purchase_service: валидация, создание, фулфилмент
- Интеграция с PaymentService (YooKassa card/SBP) для гостевых платежей
- Webhook-обработка с идемпотентностью и атомарными транзакциями
- Rate limiting на публичных эндпоинтах
- YooKassaPayment.user_id теперь nullable для гостевых платежей
2026-03-06 07:02:42 +03:00

239 lines
8.0 KiB
Python

from datetime import UTC, datetime
import structlog
from sqlalchemy import and_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from app.database.models import YooKassaPayment
logger = structlog.get_logger(__name__)
async def create_yookassa_payment(
db: AsyncSession,
user_id: int | None,
yookassa_payment_id: str,
amount_kopeks: int,
currency: str,
description: str,
status: str,
confirmation_url: str | None = None,
metadata_json: dict | None = None,
payment_method_type: str | None = None,
yookassa_created_at: datetime | None = None,
test_mode: bool = False,
) -> YooKassaPayment | None:
payment = YooKassaPayment(
user_id=user_id,
yookassa_payment_id=yookassa_payment_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
confirmation_url=confirmation_url,
metadata_json=metadata_json,
payment_method_type=payment_method_type,
yookassa_created_at=yookassa_created_at,
test_mode=test_mode,
)
db.add(payment)
try:
await db.commit()
except IntegrityError as e:
await db.rollback()
logger.error(
'FK violation при создании платежа YooKassa : user_id= не существует в БД',
yookassa_payment_id=yookassa_payment_id,
user_id=user_id,
e=e,
)
return None
await db.refresh(payment)
logger.info(
'Создан платеж YooKassa: на ₽ для пользователя',
yookassa_payment_id=yookassa_payment_id,
amount_kopeks=amount_kopeks / 100,
user_id=user_id,
)
return payment
async def get_yookassa_payment_by_id(db: AsyncSession, yookassa_payment_id: str) -> YooKassaPayment | None:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
return result.scalar_one_or_none()
async def get_yookassa_payment_by_local_id(db: AsyncSession, local_id: int) -> YooKassaPayment | None:
result = await db.execute(
select(YooKassaPayment).options(selectinload(YooKassaPayment.user)).where(YooKassaPayment.id == local_id)
)
return result.scalar_one_or_none()
async def update_yookassa_payment_status(
db: AsyncSession,
yookassa_payment_id: str,
status: str,
is_paid: bool = False,
is_captured: bool = False,
captured_at: datetime | None = None,
payment_method_type: str | None = None,
) -> YooKassaPayment | None:
update_data = {'status': status, 'is_paid': is_paid, 'is_captured': is_captured, 'updated_at': datetime.now(UTC)}
if captured_at:
update_data['captured_at'] = captured_at
if payment_method_type:
update_data['payment_method_type'] = payment_method_type
await db.execute(
update(YooKassaPayment).where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id).values(**update_data)
)
await db.commit()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(
'Обновлен статус платежа YooKassa , paid',
yookassa_payment_id=yookassa_payment_id,
status=status,
is_paid=is_paid,
)
return payment
async def link_yookassa_payment_to_transaction(
db: AsyncSession, yookassa_payment_id: str, transaction_id: int
) -> YooKassaPayment | None:
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(transaction_id=transaction_id, updated_at=datetime.now(UTC))
)
await db.flush()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user), selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(
'Платеж YooKassa связан с транзакцией',
yookassa_payment_id=yookassa_payment_id,
transaction_id=transaction_id,
)
return payment
async def get_user_yookassa_payments(
db: AsyncSession, user_id: int, limit: int = 50, offset: int = 0
) -> list[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.user_id == user_id)
.order_by(YooKassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return result.scalars().all()
async def get_pending_yookassa_payments(
db: AsyncSession, user_id: int | None = None, limit: int = 100
) -> list[YooKassaPayment]:
query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user))
conditions = [YooKassaPayment.status.in_(['pending', 'waiting_for_capture'])]
if user_id:
conditions.append(YooKassaPayment.user_id == user_id)
result = await db.execute(query.where(and_(*conditions)).order_by(YooKassaPayment.created_at.desc()).limit(limit))
return result.scalars().all()
async def get_succeeded_yookassa_payments_without_transaction(
db: AsyncSession, limit: int = 50
) -> list[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(
and_(
YooKassaPayment.status == 'succeeded',
YooKassaPayment.is_paid == True,
YooKassaPayment.transaction_id == None,
)
)
.order_by(YooKassaPayment.captured_at.desc())
.limit(limit)
)
return result.scalars().all()
async def delete_yookassa_payment(db: AsyncSession, yookassa_payment_id: str) -> bool:
result = await db.execute(select(YooKassaPayment).where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id))
payment = result.scalar_one_or_none()
if payment:
await db.delete(payment)
await db.commit()
logger.info('Удален платеж YooKassa', yookassa_payment_id=yookassa_payment_id)
return True
return False
async def get_yookassa_payments_stats(db: AsyncSession, user_id: int | None = None) -> dict:
from sqlalchemy import case, func
query = select(
func.count(YooKassaPayment.id).label('total_payments'),
func.sum(YooKassaPayment.amount_kopeks).label('total_amount_kopeks'),
func.sum(case((YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks), else_=0)).label(
'succeeded_amount_kopeks'
),
func.count(case((YooKassaPayment.status == 'succeeded', 1), else_=None)).label('succeeded_count'),
func.count(case((YooKassaPayment.status == 'pending', 1), else_=None)).label('pending_count'),
func.count(case((YooKassaPayment.status.in_(['canceled', 'failed']), 1), else_=None)).label('failed_count'),
).select_from(YooKassaPayment)
if user_id:
query = query.where(YooKassaPayment.user_id == user_id)
result = await db.execute(query)
stats = result.first()
return {
'total_payments': stats.total_payments or 0,
'total_amount_kopeks': stats.total_amount_kopeks or 0,
'total_amount_rubles': (stats.total_amount_kopeks or 0) / 100,
'succeeded_amount_kopeks': stats.succeeded_amount_kopeks or 0,
'succeeded_amount_rubles': (stats.succeeded_amount_kopeks or 0) / 100,
'succeeded_count': stats.succeeded_count or 0,
'pending_count': stats.pending_count or 0,
'failed_count': stats.failed_count or 0,
'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0,
}