5e404cc082
- Модели 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 для гостевых платежей
239 lines
8.0 KiB
Python
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,
|
|
}
|