diff --git a/app/services/payment/cloudpayments.py b/app/services/payment/cloudpayments.py
index fa363100..b5c5980a 100644
--- a/app/services/payment/cloudpayments.py
+++ b/app/services/payment/cloudpayments.py
@@ -2,22 +2,21 @@
from __future__ import annotations
-import json
from datetime import datetime
from importlib import import_module
-from typing import Any, Dict, Optional
+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.services.subscription_auto_purchase_service import (
auto_activate_subscription_after_topup,
auto_purchase_saved_cart_after_topup,
)
-from app.services.cloudpayments_service import CloudPaymentsAPIError, CloudPaymentsService
-from app.utils.user_utils import format_referrer_info
from app.utils.payment_logger import payment_logger as logger
+from app.utils.user_utils import format_referrer_info
class CloudPaymentsPaymentMixin:
@@ -30,10 +29,10 @@ class CloudPaymentsPaymentMixin:
amount_kopeks: int,
description: str,
*,
- telegram_id: int,
- language: Optional[str] = None,
- email: Optional[str] = None,
- ) -> Optional[Dict[str, Any]]:
+ telegram_id: int | None = None,
+ language: str | None = None,
+ email: str | None = None,
+ ) -> dict[str, Any] | None:
"""
Create a CloudPayments payment and return payment link info.
@@ -49,13 +48,13 @@ class CloudPaymentsPaymentMixin:
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")
+ 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 меньше минимальной: %s < %s",
+ 'Сумма CloudPayments меньше минимальной: %s < %s',
amount_kopeks,
settings.CLOUDPAYMENTS_MIN_AMOUNT_KOPEKS,
)
@@ -63,36 +62,37 @@ class CloudPaymentsPaymentMixin:
if amount_kopeks > settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS:
logger.warning(
- "Сумма CloudPayments больше максимальной: %s > %s",
+ 'Сумма CloudPayments больше максимальной: %s > %s',
amount_kopeks,
settings.CLOUDPAYMENTS_MAX_AMOUNT_KOPEKS,
)
return None
- payment_module = import_module("app.services.payment_service")
+ payment_module = import_module('app.services.payment_service')
- # Generate unique invoice ID
- invoice_id = self.cloudpayments_service.generate_invoice_id(telegram_id)
+ # 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,
)
except CloudPaymentsAPIError as error:
- logger.error("Ошибка создания CloudPayments платежа: %s", error)
+ logger.error('Ошибка создания CloudPayments платежа: %s', error)
return None
except Exception as error:
- logger.exception("Непредвиденная ошибка при создании CloudPayments платежа: %s", error)
+ logger.exception('Непредвиденная ошибка при создании CloudPayments платежа: %s', error)
return None
metadata = {
- "language": language or settings.DEFAULT_LANGUAGE,
- "telegram_id": telegram_id,
+ 'language': language or settings.DEFAULT_LANGUAGE,
+ 'telegram_id': telegram_id,
}
# Create local payment record
@@ -108,30 +108,26 @@ class CloudPaymentsPaymentMixin:
)
if not local_payment:
- logger.error("Не удалось создать локальную запись CloudPayments платежа")
+ logger.error('Не удалось создать локальную запись CloudPayments платежа')
return None
- # CRITICAL: Commit immediately so webhook can find the payment
- # (webhook runs in a different database session)
- await db.commit()
-
logger.info(
- "Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s",
+ 'Создан CloudPayments платёж: invoice=%s, amount=%s₽, user=%s',
invoice_id,
amount_kopeks / 100,
user_id,
)
return {
- "payment_url": payment_url,
- "invoice_id": invoice_id,
- "payment_id": local_payment.id,
+ '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],
+ webhook_data: dict[str, Any],
) -> bool:
"""
Process CloudPayments Pay webhook (successful payment).
@@ -143,43 +139,44 @@ class CloudPaymentsPaymentMixin:
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)
+ invoice_id = webhook_data.get('invoice_id')
+ transaction_id_cp = webhook_data.get('transaction_id')
+ amount = webhook_data.get('amount', 0)
amount_kopeks = int(amount * 100)
- account_id = webhook_data.get("account_id", "")
- token = webhook_data.get("token")
- test_mode = webhook_data.get("test_mode", False)
+ account_id = webhook_data.get('account_id', '')
+ token = webhook_data.get('token')
+ test_mode = webhook_data.get('test_mode', False)
if not invoice_id:
- logger.error("CloudPayments webhook без invoice_id")
+ logger.error('CloudPayments webhook без invoice_id')
return False
- payment_module = import_module("app.services.payment_service")
+ 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=%s, создаём новый",
+ 'CloudPayments платёж не найден: invoice=%s, создаём новый',
invoice_id,
)
- # Try to extract telegram_id from account_id
+ # Try to extract user_id from account_id (we now use user_id as AccountId)
try:
- telegram_id = int(account_id) if account_id else None
+ user_id = int(account_id) if account_id else None
except ValueError:
- telegram_id = None
+ user_id = None
- if not telegram_id:
- logger.error("Не удалось определить telegram_id из account_id: %s", account_id)
+ if not user_id:
+ logger.error('Не удалось определить user_id из account_id: %s', account_id)
return False
- # Get user by telegram_id
- from app.database.crud.user import get_user_by_telegram_id
- user = await get_user_by_telegram_id(db, telegram_id)
+ # 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("Пользователь не найден: telegram_id=%s", telegram_id)
+ logger.error('Пользователь не найден: id=%s', user_id)
return False
# Create payment record
@@ -193,51 +190,49 @@ class CloudPaymentsPaymentMixin:
)
if not payment:
- logger.error("Не удалось создать запись платежа")
+ logger.error('Не удалось создать запись платежа')
return False
# Check if already processed
if payment.is_paid:
- logger.info("CloudPayments платёж уже обработан: invoice=%s", invoice_id)
+ logger.info('CloudPayments платёж уже обработан: invoice=%s', invoice_id)
return True
- # Note: Check notifications are now filtered at webhook handler level
- # by checking for presence of Reason or AuthCode fields.
- # This handler only receives Pay notifications (confirmed payments).
-
# Update payment record
payment.transaction_id_cp = transaction_id_cp
- payment.status = "completed"
+ payment.status = 'completed'
payment.is_paid = True
payment.paid_at = datetime.utcnow()
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.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
await db.flush()
# Get user
- from app.database.crud.user import get_user_by_id, add_user_balance
+ from app.database.crud.user import add_user_balance, get_user_by_id
+
user = await get_user_by_id(db, payment.user_id)
if not user:
- logger.error("Пользователь не найден: id=%s", payment.user_id)
+ logger.error('Пользователь не найден: id=%s', payment.user_id)
return False
- # Add balance (don't create transaction here - we create it explicitly below with external_id)
- await add_user_balance(db, user, amount_kopeks, create_transaction=False)
+ # Add balance
+ await add_user_balance(db, user.id, amount_kopeks)
# Create transaction record
from app.database.crud.transaction import create_transaction
+
transaction = await create_transaction(
db=db,
user_id=user.id,
- type=TransactionType.DEPOSIT,
+ type_=TransactionType.DEPOSIT,
amount_kopeks=amount_kopeks,
description=payment.description or settings.CLOUDPAYMENTS_DESCRIPTION,
payment_method=PaymentMethod.CLOUDPAYMENTS,
@@ -248,11 +243,12 @@ class CloudPaymentsPaymentMixin:
payment.transaction_id = transaction.id
await db.commit()
+ user_id_display = user.telegram_id or user.email or f'#{user.id}'
logger.info(
- "CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s",
+ 'CloudPayments платёж успешно обработан: invoice=%s, amount=%s₽, user=%s',
invoice_id,
amount_kopeks / 100,
- user.telegram_id,
+ user_id_display,
)
# Send notification to user
@@ -263,33 +259,31 @@ class CloudPaymentsPaymentMixin:
transaction=transaction,
)
except Exception as error:
- logger.exception("Ошибка отправки уведомления CloudPayments: %s", error)
+ logger.exception('Ошибка отправки уведомления CloudPayments: %s', error)
# Auto-purchase if enabled
auto_purchase_success = False
try:
- auto_purchase_success = await auto_purchase_saved_cart_after_topup(
- db, user, bot=getattr(self, "bot", None)
- )
+ auto_purchase_success = await auto_purchase_saved_cart_after_topup(db, user, bot=getattr(self, 'bot', None))
except Exception as error:
- logger.exception("Ошибка автопокупки после CloudPayments: %s", error)
+ logger.exception('Ошибка автопокупки после CloudPayments: %s', error)
# Умная автоактивация если автопокупка не сработала
if not auto_purchase_success:
try:
# Игнорируем notification_sent т.к. здесь нет дополнительных уведомлений
await auto_activate_subscription_after_topup(
- db, user, bot=getattr(self, "bot", None), topup_amount=amount_kopeks
+ db, user, bot=getattr(self, 'bot', None), topup_amount=amount_kopeks
)
except Exception as error:
- logger.exception("Ошибка умной автоактивации после CloudPayments: %s", error)
+ logger.exception('Ошибка умной автоактивации после CloudPayments: %s', error)
return True
async def process_cloudpayments_fail_webhook(
self,
db: AsyncSession,
- webhook_data: Dict[str, Any],
+ webhook_data: dict[str, Any],
) -> bool:
"""
Process CloudPayments Fail webhook (failed payment).
@@ -301,82 +295,51 @@ class CloudPaymentsPaymentMixin:
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", "")
+ 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")
+ logger.warning('CloudPayments fail webhook без invoice_id')
return True
- payment_module = import_module("app.services.payment_service")
+ 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:
- # CRITICAL: If payment was already credited, reverse the balance
- if payment.is_paid:
- logger.warning(
- "CloudPayments: получен Declined для уже оплаченного платежа! "
- "invoice=%s, amount=%s, reversing balance",
- invoice_id,
- payment.amount_kopeks,
- )
-
- # Get user and subtract balance
- from app.database.crud.user import get_user_by_id, subtract_user_balance
- user = await get_user_by_id(db, payment.user_id)
-
- if user:
- await subtract_user_balance(
- db, user, payment.amount_kopeks,
- f"Возврат: платёж CloudPayments отклонён ({reason})"
- )
-
- # Create reversal transaction
- from app.database.crud.transaction import create_transaction
- await create_transaction(
- db=db,
- user_id=user.id,
- type=TransactionType.WITHDRAWAL,
- amount_kopeks=payment.amount_kopeks,
- description=f"Возврат: платёж CloudPayments отклонён ({reason})",
- payment_method=PaymentMethod.CLOUDPAYMENTS,
- external_id=f"reversal_{invoice_id}",
- is_completed=True,
- )
-
- logger.info(
- "CloudPayments: баланс возвращён для пользователя %s, сумма %s₽",
- user.telegram_id,
- payment.amount_kopeks / 100,
- )
-
- payment.status = "failed"
- payment.is_paid = False
+ payment.status = 'failed'
payment.callback_payload = webhook_data
await db.commit()
logger.info(
- "CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)",
+ 'CloudPayments платёж неуспешен: invoice=%s, reason=%s (code=%s)',
invoice_id,
reason,
reason_code,
)
- # Notify user about failed payment
+ # Notify user about failed payment (account_id now contains user_id, not telegram_id)
try:
- telegram_id = int(account_id) if account_id else None
- if telegram_id:
- await self._send_cloudpayments_fail_notification(
- telegram_id=telegram_id,
- message=card_holder_message,
- )
+ 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("Ошибка отправки уведомления о неуспешном платеже: %s", error)
+ logger.exception('Ошибка отправки уведомления о неуспешном платеже: %s', error)
return True
@@ -387,12 +350,17 @@ class CloudPaymentsPaymentMixin:
transaction: Any,
) -> None:
"""Send success notification to user via Telegram."""
+ from app.bot import bot
from app.localization.texts import get_texts
- bot = getattr(self, "bot", None)
if not bot:
return
+ # Skip email-only users (no telegram_id)
+ if not user.telegram_id:
+ logger.debug('Skipping CloudPayments notification for email-only user %s', user.id)
+ return
+
texts = get_texts(user.language)
keyboard = await self.build_topup_success_keyboard(user)
@@ -402,29 +370,29 @@ class CloudPaymentsPaymentMixin:
new_balance = user.balance_kopeks / 100
message = texts.t(
- "PAYMENT_SUCCESS_CLOUDPAYMENTS",
- "✅ Оплата получена!\n\n"
- "💰 Сумма: {amount}₽\n"
- "💳 Способ: CloudPayments\n"
- "💵 Баланс: {balance}₽\n\n"
- "Спасибо за пополнение!",
+ 'PAYMENT_SUCCESS_CLOUDPAYMENTS',
+ '✅ Оплата получена!\n\n'
+ '💰 Сумма: {amount}₽\n'
+ '💳 Способ: CloudPayments\n'
+ '💵 Баланс: {balance}₽\n\n'
+ 'Спасибо за пополнение!',
).format(
- amount=f"{amount_rub:.2f}",
- balance=f"{new_balance:.2f}",
+ amount=f'{amount_rub:.2f}',
+ balance=f'{new_balance:.2f}',
)
if referrer_info:
- message += f"\n\n{referrer_info}"
+ message += f'\n\n{referrer_info}'
try:
await bot.send_message(
chat_id=user.telegram_id,
text=message,
- parse_mode="HTML",
+ parse_mode='HTML',
reply_markup=keyboard,
)
except Exception as error:
- logger.warning("Не удалось отправить уведомление пользователю %s: %s", user.telegram_id, error)
+ logger.warning('Не удалось отправить уведомление пользователю %s: %s', user.telegram_id, error)
async def _send_cloudpayments_fail_notification(
self,
@@ -432,26 +400,27 @@ class CloudPaymentsPaymentMixin:
message: str,
) -> None:
"""Send failure notification to user via Telegram."""
- bot = getattr(self, "bot", None)
+ from app.bot import bot
+
if not bot:
return
- text = f"❌ Оплата не прошла\n\n{message}"
+ text = f'❌ Оплата не прошла\n\n{message}'
try:
await bot.send_message(
chat_id=telegram_id,
text=text,
- parse_mode="HTML",
+ parse_mode='HTML',
)
except Exception as error:
- logger.warning("Не удалось отправить уведомление пользователю %s: %s", telegram_id, error)
+ logger.warning('Не удалось отправить уведомление пользователю %s: %s', telegram_id, error)
async def get_cloudpayments_payment_status(
self,
db: AsyncSession,
local_payment_id: int,
- ) -> Optional[Dict[str, Any]]:
+ ) -> dict[str, Any] | None:
"""
Check CloudPayments payment status via API.
@@ -462,69 +431,69 @@ class CloudPaymentsPaymentMixin:
Returns:
Dict with payment info or None if not found
"""
- payment_module = import_module("app.services.payment_service")
+ 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=%s", local_payment_id)
+ logger.warning('CloudPayments payment not found: id=%s', local_payment_id)
return None
# If already paid, return current state
if payment.is_paid:
- return {"payment": payment, "status": "completed"}
+ 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}
+ 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"):
+ if not api_response.get('Success'):
logger.debug(
- "CloudPayments API: payment not found or error for invoice=%s",
+ 'CloudPayments API: payment not found or error for invoice=%s',
payment.invoice_id,
)
- return {"payment": payment, "status": payment.status}
+ return {'payment': payment, 'status': payment.status}
- model = api_response.get("Model", {})
- api_status = model.get("Status", "")
- transaction_id_cp = model.get("TransactionId")
+ 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:
+ 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,
+ '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"
+ 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}
+ return {'payment': payment, 'status': payment.status}
except Exception as error:
logger.error(
- "Error checking CloudPayments payment status: id=%s, error=%s",
+ 'Error checking CloudPayments payment status: id=%s, error=%s',
local_payment_id,
error,
)
- return {"payment": payment, "status": payment.status}
+ return {'payment': payment, 'status': payment.status}