312 lines
15 KiB
Python
312 lines
15 KiB
Python
import logging
|
|
import json
|
|
import aiosqlite
|
|
import asyncio
|
|
from datetime import datetime, timezone
|
|
from typing import Optional, Dict, Any
|
|
|
|
from aiohttp import web
|
|
from aiogram import Bot
|
|
from yookassa.domain.notification import WebhookNotification
|
|
from yookassa.domain.models import Amount
|
|
|
|
from db.database import get_db_connection_manager, _setup_db_connection, get_user
|
|
from bot.services.subscription_service import SubscriptionService
|
|
from bot.services.referral_service import ReferralService
|
|
from bot.services.panel_api_service import PanelApiService
|
|
from bot.middlewares.i18n import JsonI18n
|
|
from config.settings import Settings
|
|
from bot.services.payment_service import YooKassaService
|
|
|
|
payment_processing_lock = asyncio.Lock()
|
|
|
|
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = 'payment.succeeded'
|
|
YOOKASSA_EVENT_PAYMENT_CANCELED = 'payment.canceled'
|
|
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = 'payment.waiting_for_capture'
|
|
YOOKASSA_EVENT_REFUND_SUCCEEDED = 'refund.succeeded'
|
|
|
|
|
|
async def process_successful_payment(bot: Bot, payment_info_from_webhook: dict,
|
|
i18n: JsonI18n, settings: Settings,
|
|
panel_service: PanelApiService,
|
|
yk_service: YooKassaService,
|
|
subscription_service: SubscriptionService,
|
|
referral_service: ReferralService):
|
|
metadata = payment_info_from_webhook.get("metadata", {})
|
|
user_id_str = metadata.get("user_id")
|
|
subscription_months_str = metadata.get("subscription_months")
|
|
promo_code_id_str = metadata.get("promo_code_id")
|
|
payment_db_id_str = metadata.get("payment_db_id")
|
|
|
|
if not user_id_str or not subscription_months_str or not payment_db_id_str:
|
|
logging.error(
|
|
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
|
)
|
|
return
|
|
try:
|
|
user_id = int(user_id_str)
|
|
subscription_months = int(subscription_months_str)
|
|
payment_db_id = int(payment_db_id_str)
|
|
promo_code_id = int(
|
|
promo_code_id_str
|
|
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
|
amount_data = payment_info_from_webhook.get("amount", {})
|
|
payment_value = float(amount_data.get("value", 0.0))
|
|
except (TypeError, ValueError) as e:
|
|
logging.error(
|
|
f"Invalid metadata format for payment processing: {metadata} - {e}"
|
|
)
|
|
return
|
|
|
|
final_end_date_for_user: Optional[datetime] = None
|
|
applied_referee_bonus_days: Optional[int] = None
|
|
base_subscription_end_date: Optional[datetime] = None
|
|
|
|
async with get_db_connection_manager() as db:
|
|
await _setup_db_connection(db)
|
|
try:
|
|
await db.execute(
|
|
"UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",
|
|
(payment_info_from_webhook.get("status", "succeeded"),
|
|
payment_db_id, payment_info_from_webhook.get("id")))
|
|
|
|
new_sub_details = await subscription_service.activate_subscription(
|
|
user_id,
|
|
subscription_months,
|
|
payment_value,
|
|
payment_db_id,
|
|
db_conn=db,
|
|
promo_code_id=promo_code_id)
|
|
|
|
if new_sub_details and new_sub_details.get('end_date'):
|
|
base_subscription_end_date = new_sub_details['end_date']
|
|
final_end_date_for_user = base_subscription_end_date
|
|
|
|
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
|
user_id, subscription_months, db_conn=db)
|
|
|
|
if referral_bonus_info and referral_bonus_info.get(
|
|
"referee_new_end_date"):
|
|
final_end_date_for_user = referral_bonus_info[
|
|
"referee_new_end_date"]
|
|
applied_referee_bonus_days = referral_bonus_info.get(
|
|
"referee_bonus_applied_days")
|
|
|
|
await db.commit()
|
|
|
|
user_lang = await subscription_service.get_user_language(
|
|
user_id)
|
|
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs
|
|
)
|
|
|
|
success_message = ""
|
|
if applied_referee_bonus_days and final_end_date_for_user:
|
|
referee_user_data = await get_user(user_id)
|
|
inviter_name_for_msg = _("friend_placeholder")
|
|
|
|
if referee_user_data and referee_user_data[
|
|
'referred_by_id'] is not None:
|
|
inviter_user_data_for_msg = await get_user(
|
|
referee_user_data['referred_by_id'])
|
|
if inviter_user_data_for_msg and inviter_user_data_for_msg[
|
|
'first_name']:
|
|
inviter_name_for_msg = inviter_user_data_for_msg[
|
|
'first_name']
|
|
|
|
success_message = _(
|
|
"payment_successful_with_referral_bonus",
|
|
months=subscription_months,
|
|
base_end_date=base_subscription_end_date.strftime(
|
|
'%Y-%m-%d')
|
|
if base_subscription_end_date else "N/A",
|
|
bonus_days=applied_referee_bonus_days,
|
|
final_end_date=final_end_date_for_user.strftime(
|
|
'%Y-%m-%d'),
|
|
inviter_name=inviter_name_for_msg)
|
|
elif final_end_date_for_user:
|
|
success_message = _(
|
|
"payment_successful",
|
|
months=subscription_months,
|
|
end_date=final_end_date_for_user.strftime('%Y-%m-%d'))
|
|
else:
|
|
logging.error(
|
|
f"Critical error: final_end_date_for_user is None for user {user_id}"
|
|
)
|
|
success_message = _("payment_successful_error_details")
|
|
|
|
try:
|
|
await bot.send_message(user_id, success_message)
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Failed to send final payment success message to user {user_id}: {e}"
|
|
)
|
|
else:
|
|
logging.error(
|
|
f"Failed to activate subscription for user {user_id} after payment {payment_info_from_webhook.get('id')}"
|
|
)
|
|
await db.rollback()
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Error during process_successful_payment transaction for user {user_id}: {e}",
|
|
exc_info=True)
|
|
await db.rollback()
|
|
try:
|
|
user_lang_for_error = await subscription_service.get_user_language(
|
|
user_id)
|
|
_err = lambda key, **kwargs: i18n.gettext(
|
|
user_lang_for_error, key, **kwargs)
|
|
await bot.send_message(user_id,
|
|
_err("error_processing_your_payment"))
|
|
except Exception as notify_err:
|
|
logging.error(
|
|
f"Failed to send error notification to user {user_id}: {notify_err}"
|
|
)
|
|
|
|
|
|
async def process_cancelled_payment(bot: Bot, payment_info_from_webhook: dict,
|
|
i18n: JsonI18n, settings: Settings):
|
|
metadata = payment_info_from_webhook.get("metadata", {})
|
|
user_id_str = metadata.get("user_id")
|
|
payment_db_id_str = metadata.get("payment_db_id")
|
|
if not user_id_str or not payment_db_id_str:
|
|
logging.warning(
|
|
f"Missing metadata in cancelled payment: {payment_info_from_webhook.get('id')}"
|
|
)
|
|
return
|
|
try:
|
|
user_id = int(user_id_str)
|
|
payment_db_id = int(payment_db_id_str)
|
|
except ValueError:
|
|
logging.error(f"Invalid metadata in cancelled payment: {metadata}")
|
|
return
|
|
async with get_db_connection_manager() as db:
|
|
await _setup_db_connection(db)
|
|
await db.execute(
|
|
"UPDATE payments SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE payment_id = ? AND (yookassa_payment_id = ? OR yookassa_payment_id IS NULL)",
|
|
(payment_info_from_webhook.get("status", "canceled"),
|
|
payment_db_id, payment_info_from_webhook.get("id")))
|
|
await db.commit()
|
|
user_lang = getattr(settings, 'DEFAULT_LANGUAGE', 'en')
|
|
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
|
try:
|
|
await bot.send_message(user_id, _("payment_failed"))
|
|
except Exception as e:
|
|
logging.error(
|
|
f"Failed to send payment cancellation message to user {user_id}: {e}"
|
|
)
|
|
|
|
|
|
async def yookassa_webhook_route(request: web.Request):
|
|
logging.info(
|
|
f"YooKassa Webhook Route: Available keys in request.app: {list(request.app.keys())}"
|
|
)
|
|
try:
|
|
bot: Bot = request.app['bot']
|
|
i18n_instance: JsonI18n = request.app['i18n']
|
|
settings: Settings = request.app['settings']
|
|
yk_service: YooKassaService = request.app['yookassa_service']
|
|
panel_service: PanelApiService = request.app['panel_service']
|
|
subscription_service: SubscriptionService = request.app[
|
|
'subscription_service']
|
|
referral_service: ReferralService = request.app['referral_service']
|
|
except KeyError as e:
|
|
logging.error(
|
|
f"KeyError accessing app context in yookassa_webhook_route: {e}.",
|
|
exc_info=True)
|
|
return web.Response(status=500,
|
|
text="Internal Server Error: Missing app context")
|
|
try:
|
|
event_json = await request.json()
|
|
notification_object = WebhookNotification(event_json)
|
|
payment_data_from_notification = notification_object.object
|
|
logging.info(
|
|
f"YooKassa Webhook Parsed: Event='{notification_object.event}', PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'"
|
|
)
|
|
if not payment_data_from_notification or not hasattr(
|
|
payment_data_from_notification,
|
|
'metadata') or payment_data_from_notification.metadata is None:
|
|
logging.error(
|
|
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata."
|
|
)
|
|
return web.Response(status=200, text="ok_error_no_metadata")
|
|
payment_dict_for_processing = {}
|
|
if hasattr(payment_data_from_notification, 'model_dump'):
|
|
payment_dict_for_processing = payment_data_from_notification.model_dump(
|
|
exclude_none=True)
|
|
if 'amount' in payment_dict_for_processing and isinstance(
|
|
payment_dict_for_processing['amount'], Amount):
|
|
amount_obj = payment_dict_for_processing['amount']
|
|
payment_dict_for_processing['amount'] = {
|
|
"value": str(amount_obj.value),
|
|
"currency": str(amount_obj.currency)
|
|
}
|
|
elif 'amount' in payment_dict_for_processing and not isinstance(
|
|
payment_dict_for_processing['amount'], dict):
|
|
amount_obj_original = payment_data_from_notification.amount
|
|
payment_dict_for_processing['amount'] = {
|
|
"value": str(amount_obj_original.value),
|
|
"currency": str(amount_obj_original.currency)
|
|
} if hasattr(amount_obj_original, 'value') and hasattr(
|
|
amount_obj_original, 'currency') else {
|
|
"value": "0.0",
|
|
"currency": "RUB"
|
|
}
|
|
elif hasattr(payment_data_from_notification, 'amount') and hasattr(
|
|
payment_data_from_notification.amount, 'value') and hasattr(
|
|
payment_data_from_notification.amount, 'currency'):
|
|
amount_obj = payment_data_from_notification.amount
|
|
payment_dict_for_processing = {
|
|
"id":
|
|
str(payment_data_from_notification.id),
|
|
"status":
|
|
str(payment_data_from_notification.status),
|
|
"paid":
|
|
bool(payment_data_from_notification.paid),
|
|
"amount": {
|
|
"value": str(amount_obj.value),
|
|
"currency": str(amount_obj.currency)
|
|
},
|
|
"metadata":
|
|
dict(payment_data_from_notification.metadata)
|
|
if payment_data_from_notification.metadata else {},
|
|
"description":
|
|
str(payment_data_from_notification.description)
|
|
if payment_data_from_notification.description else None
|
|
}
|
|
else:
|
|
logging.error(
|
|
f"Could not serialize payment_data for payment {payment_data_from_notification.id}"
|
|
)
|
|
return web.Response(status=200, text="ok_error_serialization")
|
|
async with payment_processing_lock:
|
|
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
|
if payment_dict_for_processing.get(
|
|
"paid") and payment_dict_for_processing.get(
|
|
"status") == "succeeded":
|
|
await process_successful_payment(
|
|
bot, payment_dict_for_processing, i18n_instance,
|
|
settings, panel_service, yk_service,
|
|
subscription_service, referral_service)
|
|
else:
|
|
logging.warning(
|
|
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} but data not ok: status='{payment_dict_for_processing.get('status')}', paid='{payment_dict_for_processing.get('paid')}'"
|
|
)
|
|
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
|
await process_cancelled_payment(bot,
|
|
payment_dict_for_processing,
|
|
i18n_instance, settings)
|
|
return web.Response(status=200, text="ok")
|
|
except json.JSONDecodeError:
|
|
logging.error("YooKassa Webhook: Invalid JSON.")
|
|
return web.Response(status=200, text="ok_invalid_json")
|
|
except KeyError as e:
|
|
logging.error(
|
|
f"KeyError in yookassa_webhook_route after initial context access: {e}.",
|
|
exc_info=True)
|
|
return web.Response(
|
|
status=500,
|
|
text="Internal Server Error: Context error post-access")
|
|
except Exception as e:
|
|
logging.error(f"YooKassa Webhook processing error: {e}", exc_info=True)
|
|
return web.Response(status=200, text="ok_internal_error")
|