Files
Fringg 8175bc8bfe fix: comprehensive security hardening across payment and API layers
- CloudPayments: require webhook signature when secret configured (all 4 handlers)
- Platega: timing-safe HMAC comparison via hmac.compare_digest
- CryptoBot/Heleket: return False when API token unconfigured
- Tribute: return 503 when API key not configured
- Freekassa: use request.client.host instead of X-Forwarded-For
- Pal24: verify webhook amount matches stored payment amount
- YooKassa: reject test-mode payments in production; add YOOKASSA_TEST_MODE config
- CloudPayments: reject test-mode payments in production
- WebAPI: add upper bounds to duration_days, traffic_limit_gb, device_limit schemas
- WebAPI: bound balance update amount to ±100M kopeks
- WebAPI: sign-dispatch for balance updates (negative → subtract_user_balance)
- WebAPI miniapp: add blocked/deleted user checks, restriction_topup/subscription guards
- Admin handlers: add @admin_required and @error_handler to moderator panel
- add_user_balance: guard against negative amounts (use subtract_user_balance instead)
2026-03-23 16:44:38 +03:00

606 lines
23 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Mixin for integrating CloudPayments into the payment service."""
from __future__ import annotations
from datetime import UTC, datetime
from importlib import import_module
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database.models import PaymentMethod, TransactionType
from app.services.cloudpayments_service import CloudPaymentsAPIError
from app.utils.payment_logger import payment_logger as logger
from app.utils.user_utils import format_referrer_info
class CloudPaymentsPaymentMixin:
"""Encapsulates creation and webhook handling for CloudPayments."""
async def create_cloudpayments_payment(
self,
db: AsyncSession,
user_id: int | None,
amount_kopeks: int,
description: str,
*,
telegram_id: int | None = None,
language: str | None = None,
email: str | None = None,
return_url: str | None = None,
failed_url: str | None = None,
) -> dict[str, Any] | None:
"""
Create a CloudPayments payment and return payment link info.
Args:
db: Database session
user_id: Internal user ID
amount_kopeks: Payment amount in kopeks
description: Payment description
telegram_id: User's Telegram ID
language: User's language
email: User's email (optional)
Returns:
Dict with payment_url and invoice_id, or None on error
"""
if not getattr(self, 'cloudpayments_service', None):
logger.error('CloudPayments service is not initialised')
return None
if amount_kopeks < settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS:
logger.warning(
'Сумма CloudPayments меньше минимальной: <',
amount_kopeks=amount_kopeks,
CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS=settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
)
return None
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
logger.warning(
'Сумма CloudPayments больше максимальной: >',
amount_kopeks=amount_kopeks,
CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS=settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
)
return None
payment_module = import_module('app.services.payment_service')
# Generate unique invoice ID (use user_id for uniqueness, works for email-only users too)
invoice_id = self.cloudpayments_service.generate_invoice_id(user_id)
try:
# Create payment order via CloudPayments API
payment_url = await self.cloudpayments_service.generate_payment_link(
telegram_id=telegram_id,
user_id=user_id,
amount_kopeks=amount_kopeks,
invoice_id=invoice_id,
description=description,
email=email,
success_redirect_url=return_url,
fail_redirect_url=failed_url,
)
except CloudPaymentsAPIError as error:
logger.error('Ошибка создания CloudPayments платежа', error=error)
return None
except Exception as error:
logger.exception('Непредвиденная ошибка при создании CloudPayments платежа', error=error)
return None
metadata = {
'language': language or settings.DEFAULT_LANGUAGE,
'telegram_id': telegram_id,
}
# Create local payment record
local_payment = await payment_module.create_cloudpayments_payment(
db=db,
user_id=user_id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
description=description,
payment_url=payment_url,
metadata=metadata,
test_mode=settings.CLOUDPAYMENTS_TEST_MODE,
)
if not local_payment:
logger.error('Не удалось создать локальную запись CloudPayments платежа')
return None
logger.info(
'Создан CloudPayments платёж: invoice amount=₽, user',
invoice_id=invoice_id,
amount_kopeks=amount_kopeks / 100,
user_id=user_id,
)
return {
'payment_url': payment_url,
'invoice_id': invoice_id,
'payment_id': local_payment.id,
}
async def process_cloudpayments_pay_webhook(
self,
db: AsyncSession,
webhook_data: dict[str, Any],
) -> bool:
"""
Process CloudPayments Pay webhook (successful payment).
Args:
db: Database session
webhook_data: Parsed webhook data
Returns:
True if payment was processed successfully
"""
invoice_id = webhook_data.get('invoice_id')
transaction_id_cp = webhook_data.get('transaction_id')
amount = webhook_data.get('amount', 0)
amount_kopeks = int(round(amount * 100))
account_id = webhook_data.get('account_id', '')
token = webhook_data.get('token')
test_mode = webhook_data.get('test_mode', False)
# Reject test-mode payments when not in test mode
if test_mode and not getattr(settings, 'CLOUDPAYMENTS_TEST_MODE', False):
logger.warning(
'CloudPayments: rejecting test_mode payment in production',
invoice_id=invoice_id,
test_mode=test_mode,
)
return False
if not invoice_id:
logger.error('CloudPayments webhook без invoice_id')
return False
payment_module = import_module('app.services.payment_service')
# Find existing payment record
payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id)
if not payment:
logger.warning('CloudPayments платёж не найден: invoice создаём новый', invoice_id=invoice_id)
# Try to extract user_id from account_id (we now use user_id as AccountId)
try:
user_id = int(account_id) if account_id else None
except ValueError:
user_id = None
if not user_id:
logger.error('Не удалось определить user_id из account_id', account_id=account_id)
return False
# Get user by ID
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, user_id)
if not user:
logger.error('Пользователь не найден: id', user_id=user_id)
return False
# Create payment record
payment = await payment_module.create_cloudpayments_payment(
db=db,
user_id=user.id,
invoice_id=invoice_id,
amount_kopeks=amount_kopeks,
description=settings.CLOUDPAYMENTS_DESCRIPTION,
test_mode=test_mode,
)
if not payment:
logger.error('Не удалось создать запись платежа')
return False
# Lock payment row to prevent concurrent double-processing
from app.database.crud.cloudpayments import get_cloudpayments_payment_by_id_for_update
locked = await get_cloudpayments_payment_by_id_for_update(db, payment.id)
if not locked:
logger.error('CloudPayments: не удалось заблокировать платёж', payment_id=payment.id)
return False
payment = locked
# Check if already processed (under lock)
if payment.is_paid or payment.transaction_id:
logger.info('CloudPayments платёж уже обработан: invoice', invoice_id=invoice_id)
return True
# Verify webhook amount matches stored amount
from app.utils.payment_utils import verify_payment_amount
if not verify_payment_amount(amount_kopeks, payment.amount_kopeks):
logger.warning(
'CloudPayments: несоответствие суммы',
invoice_id=invoice_id,
received_kopeks=amount_kopeks,
expected_kopeks=payment.amount_kopeks,
)
return False
# --- Guest purchase flow (landing page) ---
cp_metadata = dict(getattr(payment, 'metadata_json', {}) or {})
from app.services.payment.common import try_fulfill_guest_purchase
guest_result = await try_fulfill_guest_purchase(
db,
metadata=cp_metadata,
payment_amount_kopeks=amount_kopeks,
provider_payment_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
provider_name='cloudpayments',
)
if guest_result is not None:
# Update payment record even for guest purchases
payment.transaction_id_cp = transaction_id_cp
payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.callback_payload = webhook_data
await db.flush()
return True
# Update payment record
payment.transaction_id_cp = transaction_id_cp
payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.now(UTC)
payment.token = token
payment.card_first_six = webhook_data.get('card_first_six')
payment.card_last_four = webhook_data.get('card_last_four')
payment.card_type = webhook_data.get('card_type')
payment.card_exp_date = webhook_data.get('card_exp_date')
payment.email = webhook_data.get('email')
payment.test_mode = test_mode
payment.callback_payload = webhook_data
# Get user
from app.database.crud.user import get_user_by_id
user = await get_user_by_id(db, payment.user_id)
if not user:
logger.error('Пользователь не найден: id', user_id=payment.user_id)
return False
# Загружаем промогруппы и данные для уведомлений
await db.refresh(user, attribute_names=['promo_group', 'user_promo_groups'])
for user_promo_group in getattr(user, 'user_promo_groups', []):
await db.refresh(user_promo_group, attribute_names=['promo_group'])
from app.utils.user_utils import format_referrer_info
promo_group = user.get_primary_promo_group()
subscription = getattr(user, 'subscription', None)
referrer_info = format_referrer_info(user)
# Lock user row to prevent concurrent balance race conditions
from app.database.crud.user import lock_user_for_update
user = await lock_user_for_update(db, user)
old_balance = user.balance_kopeks
was_first_topup = not user.has_made_first_topup
# Credit balance directly (not via add_user_balance which commits)
user.balance_kopeks += amount_kopeks
user.updated_at = datetime.now(UTC)
# Create transaction record
from app.database.crud.transaction import create_transaction
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
payment_method=PaymentMethod.CLOUDPAYMENTS,
external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
is_completed=True,
created_at=getattr(payment, 'created_at', None),
commit=False,
)
payment.transaction_id = transaction.id
await db.commit()
# Emit deferred side-effects after atomic commit
from app.database.crud.transaction import emit_transaction_side_effects
await emit_transaction_side_effects(
db,
transaction,
amount_kopeks=amount_kopeks,
user_id=user.id,
type=TransactionType.DEPOSIT,
payment_method=PaymentMethod.CLOUDPAYMENTS,
external_id=str(transaction_id_cp) if transaction_id_cp else invoice_id,
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
)
user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(
'CloudPayments платёж успешно обработан: invoice amount=₽, user',
invoice_id=invoice_id,
amount_kopeks=amount_kopeks / 100,
user_id_display=user_id_display,
)
# Send notification to user
try:
await self._send_cloudpayments_success_notification(
user=user,
amount_kopeks=amount_kopeks,
transaction=transaction,
)
except Exception as error:
logger.exception('Ошибка отправки уведомления CloudPayments', error=error)
# Начисляем реферальную комиссию
try:
from app.services.referral_service import process_referral_topup
await process_referral_topup(
db,
user.id,
amount_kopeks,
getattr(self, 'bot', None),
)
except Exception as error:
logger.error('Ошибка обработки реферального пополнения CloudPayments', error=error)
if was_first_topup and not user.has_made_first_topup and not user.referred_by_id:
user.has_made_first_topup = True
await db.commit()
await db.refresh(user)
topup_status = '🆕 Первое пополнение' if was_first_topup else '🔄 Пополнение'
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('Ошибка отправки админ уведомления CloudPayments', error=error)
# Автопокупка из сохранённой корзины и уведомление о корзине
try:
from app.services.payment.common import send_cart_notification_after_topup
await send_cart_notification_after_topup(user, amount_kopeks, db, getattr(self, 'bot', None))
except Exception as error:
logger.exception('Ошибка автопокупки после CloudPayments', error=error)
return True
async def process_cloudpayments_fail_webhook(
self,
db: AsyncSession,
webhook_data: dict[str, Any],
) -> bool:
"""
Process CloudPayments Fail webhook (failed payment).
Args:
db: Database session
webhook_data: Parsed webhook data
Returns:
True if processed successfully
"""
invoice_id = webhook_data.get('invoice_id')
reason = webhook_data.get('reason', 'Unknown')
reason_code = webhook_data.get('reason_code')
card_holder_message = webhook_data.get('card_holder_message', reason)
account_id = webhook_data.get('account_id', '')
if not invoice_id:
logger.warning('CloudPayments fail webhook без invoice_id')
return True
payment_module = import_module('app.services.payment_service')
# Find payment record
payment = await payment_module.get_cloudpayments_payment_by_invoice_id(db, invoice_id)
if payment:
payment.status = 'failed'
payment.callback_payload = webhook_data
await db.commit()
logger.info(
'CloudPayments платёж неуспешен: invoice reason= (code=)',
invoice_id=invoice_id,
reason=reason,
reason_code=reason_code,
)
# Notify user about failed payment (account_id now contains user_id, not telegram_id)
try:
user_id = int(account_id) if account_id else None
if user_id:
from app.database.crud.user import get_user_by_id
# Need a new session for this query since we're outside the main flow
from app.database.session import async_session_factory
async with async_session_factory() as session:
user = await get_user_by_id(session, user_id)
if user and user.telegram_id:
await self._send_cloudpayments_fail_notification(
telegram_id=user.telegram_id,
message=card_holder_message,
)
except Exception as error:
logger.exception('Ошибка отправки уведомления о неуспешном платеже', error=error)
return True
async def _send_cloudpayments_success_notification(
self,
user: Any,
amount_kopeks: int,
transaction: Any,
) -> None:
"""Send success notification to user via Telegram."""
from app.bot_factory import create_bot
from app.localization.texts import get_texts
# Skip email-only users (no telegram_id)
if not user.telegram_id:
logger.debug('Skipping CloudPayments notification for email-only user', user_id=user.id)
return
texts = get_texts(user.language)
keyboard = await self.build_topup_success_keyboard(user)
referrer_info = format_referrer_info(user)
amount_rub = amount_kopeks / 100
new_balance = user.balance_kopeks / 100
message = texts.t(
'PAYMENT_SUCCESS_CLOUDPAYMENTS',
'✅ <b>Оплата получена!</b>\n\n'
'💰 Сумма: {amount}\n'
'💳 Способ: CloudPayments\n'
'💵 Баланс: {balance}\n\n'
'Спасибо за пополнение!',
).format(
amount=f'{amount_rub:.2f}',
balance=f'{new_balance:.2f}',
)
if referrer_info:
message += f'\n\n{referrer_info}'
async with create_bot() as bot:
try:
await bot.send_message(
chat_id=user.telegram_id,
text=message,
parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
logger.warning(
'Не удалось отправить уведомление пользователю', telegram_id=user.telegram_id, error=error
)
async def _send_cloudpayments_fail_notification(
self,
telegram_id: int,
message: str,
) -> None:
"""Send failure notification to user via Telegram."""
from app.bot_factory import create_bot
text = f'❌ <b>Оплата не прошла</b>\n\n{message}'
async with create_bot() as bot:
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
parse_mode='HTML',
)
except Exception as error:
logger.warning('Не удалось отправить уведомление пользователю', telegram_id=telegram_id, error=error)
async def get_cloudpayments_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
) -> dict[str, Any] | None:
"""
Check CloudPayments payment status via API.
Args:
db: Database session
local_payment_id: Internal payment ID
Returns:
Dict with payment info or None if not found
"""
payment_module = import_module('app.services.payment_service')
# Get local payment record
payment = await payment_module.get_cloudpayments_payment_by_id(db, local_payment_id)
if not payment:
logger.warning('CloudPayments payment not found: id', local_payment_id=local_payment_id)
return None
# If already paid, return current state
if payment.is_paid:
return {'payment': payment, 'status': 'completed'}
# Check with CloudPayments API
if not getattr(self, 'cloudpayments_service', None):
logger.warning('CloudPayments service not initialized')
return {'payment': payment, 'status': payment.status}
try:
# Try to find payment by invoice_id
api_response = await self.cloudpayments_service.find_payment(payment.invoice_id)
if not api_response.get('Success'):
logger.debug('CloudPayments API: payment not found or error for invoice', invoice_id=payment.invoice_id)
return {'payment': payment, 'status': payment.status}
model = api_response.get('Model', {})
api_status = model.get('Status', '')
transaction_id_cp = model.get('TransactionId')
# Update local record if status changed
if api_status == 'Completed' and not payment.is_paid:
# Payment completed - process it
webhook_data = {
'invoice_id': payment.invoice_id,
'transaction_id': transaction_id_cp,
'amount': model.get('Amount', 0),
'account_id': model.get('AccountId', ''),
'token': model.get('Token'),
'card_first_six': model.get('CardFirstSix'),
'card_last_four': model.get('CardLastFour'),
'card_type': model.get('CardType'),
'card_exp_date': model.get('CardExpDate'),
'email': model.get('Email'),
'test_mode': model.get('TestMode', False),
'status': api_status,
}
await self.process_cloudpayments_pay_webhook(db, webhook_data)
await db.refresh(payment)
elif api_status in ('Declined', 'Cancelled') and payment.status not in ('failed', 'cancelled'):
payment.status = 'failed'
await db.flush()
await db.refresh(payment)
return {'payment': payment, 'status': payment.status}
except Exception as error:
logger.error(
'Error checking CloudPayments payment status: id error', local_payment_id=local_payment_id, error=error
)
return {'payment': payment, 'status': payment.status}