fix(payments): enhance YooKassa webhook processing and notification handling
Improve error handling for YooKassa payment events by adding verification for the payment service configuration. Refactor notification sending for expired discounts to batch messages before sending, ensuring better performance and error logging.
This commit is contained in:
+101
-94
@@ -625,110 +625,117 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
|
|
||||||
async with async_session_factory() as session:
|
async with async_session_factory() as session:
|
||||||
try:
|
try:
|
||||||
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
||||||
if payment_dict_for_processing.get(
|
if not yookassa_service or not yookassa_service.configured:
|
||||||
"paid") and payment_dict_for_processing.get(
|
logging.critical(
|
||||||
"status") == "succeeded":
|
"YooKassa webhook rejected: verification service is not configured for succeeded event (payment_id=%s)",
|
||||||
await process_successful_payment(
|
payment_dict_for_processing.get("id"),
|
||||||
session, bot, payment_dict_for_processing,
|
)
|
||||||
i18n_instance, settings, panel_service,
|
return web.Response(status=503, text="yookassa_verification_required")
|
||||||
subscription_service, referral_service,
|
|
||||||
yookassa_service,
|
if payment_dict_for_processing.get(
|
||||||
lknpd_service)
|
"paid") and payment_dict_for_processing.get(
|
||||||
await session.commit()
|
"status") == "succeeded":
|
||||||
else:
|
await process_successful_payment(
|
||||||
logging.warning(
|
|
||||||
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
|
|
||||||
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
|
|
||||||
f"paid='{payment_dict_for_processing.get('paid')}'"
|
|
||||||
)
|
|
||||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
|
||||||
await process_cancelled_payment(
|
|
||||||
session, bot, payment_dict_for_processing,
|
session, bot, payment_dict_for_processing,
|
||||||
i18n_instance, settings)
|
i18n_instance, settings, panel_service,
|
||||||
|
subscription_service, referral_service,
|
||||||
|
yookassa_service,
|
||||||
|
lknpd_service)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
else:
|
||||||
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
logging.warning(
|
||||||
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} "
|
||||||
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', "
|
||||||
try:
|
f"paid='{payment_dict_for_processing.get('paid')}'"
|
||||||
user_id_str = metadata.get("user_id")
|
)
|
||||||
if user_id_str and user_id_str.isdigit():
|
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
||||||
user_id = int(user_id_str)
|
await process_cancelled_payment(
|
||||||
payment_method = payment_dict_for_processing.get("payment_method")
|
session, bot, payment_dict_for_processing,
|
||||||
if isinstance(payment_method, dict) and payment_method.get("id"):
|
i18n_instance, settings)
|
||||||
pm_type = payment_method.get("type")
|
await session.commit()
|
||||||
title = payment_method.get("title")
|
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
||||||
card = payment_method.get("card") or {}
|
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
||||||
account_number = payment_method.get("account_number") or payment_method.get("account")
|
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
||||||
display_network = None
|
if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1":
|
||||||
display_last4 = None
|
try:
|
||||||
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
user_id_str = metadata.get("user_id")
|
||||||
display_network = card.get("card_type") or title or "Card"
|
if user_id_str and user_id_str.isdigit():
|
||||||
display_last4 = card.get("last4")
|
user_id = int(user_id_str)
|
||||||
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
payment_method = payment_dict_for_processing.get("payment_method")
|
||||||
# Normalize wallet display name to avoid leaking full account from title
|
if isinstance(payment_method, dict) and payment_method.get("id"):
|
||||||
display_network = "YooMoney"
|
pm_type = payment_method.get("type")
|
||||||
if isinstance(account_number, str) and len(account_number) >= 4:
|
title = payment_method.get("title")
|
||||||
display_last4 = account_number[-4:]
|
card = payment_method.get("card") or {}
|
||||||
else:
|
account_number = payment_method.get("account_number") or payment_method.get("account")
|
||||||
display_last4 = None
|
display_network = None
|
||||||
|
display_last4 = None
|
||||||
|
if (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
||||||
|
display_network = card.get("card_type") or title or "Card"
|
||||||
|
display_last4 = card.get("last4")
|
||||||
|
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
||||||
|
# Normalize wallet display name to avoid leaking full account from title
|
||||||
|
display_network = "YooMoney"
|
||||||
|
if isinstance(account_number, str) and len(account_number) >= 4:
|
||||||
|
display_last4 = account_number[-4:]
|
||||||
else:
|
else:
|
||||||
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
|
||||||
display_last4 = None
|
display_last4 = None
|
||||||
await user_billing_dal.upsert_yk_payment_method(
|
else:
|
||||||
|
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
||||||
|
display_last4 = None
|
||||||
|
await user_billing_dal.upsert_yk_payment_method(
|
||||||
|
session,
|
||||||
|
user_id=user_id,
|
||||||
|
payment_method_id=payment_method.get("id"),
|
||||||
|
card_last4=display_last4,
|
||||||
|
card_network=display_network,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
# Save multi-card entry and mark default if first
|
||||||
|
try:
|
||||||
|
from db.dal import user_billing_dal as ub
|
||||||
|
await ub.upsert_user_payment_method(
|
||||||
session,
|
session,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
payment_method_id=payment_method.get("id"),
|
provider_payment_method_id=payment_method.get("id"),
|
||||||
|
provider="yookassa",
|
||||||
card_last4=display_last4,
|
card_last4=display_last4,
|
||||||
card_network=display_network,
|
card_network=display_network,
|
||||||
|
set_default=True,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
# Save multi-card entry and mark default if first
|
except Exception:
|
||||||
try:
|
await session.rollback()
|
||||||
from db.dal import user_billing_dal as ub
|
# Notify user about successful binding with Back button
|
||||||
await ub.upsert_user_payment_method(
|
try:
|
||||||
session,
|
# Use user's DB language for bind success notification
|
||||||
user_id=user_id,
|
i18n_lang = settings.DEFAULT_LANGUAGE
|
||||||
provider_payment_method_id=payment_method.get("id"),
|
from db.dal import user_dal
|
||||||
provider="yookassa",
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
||||||
card_last4=display_last4,
|
if db_user and db_user.language_code:
|
||||||
card_network=display_network,
|
i18n_lang = db_user.language_code
|
||||||
set_default=True,
|
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
||||||
)
|
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
||||||
await session.commit()
|
await bot.send_message(
|
||||||
except Exception:
|
chat_id=user_id,
|
||||||
await session.rollback()
|
text=_("payment_method_bound_success"),
|
||||||
# Notify user about successful binding with Back button
|
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||||
try:
|
)
|
||||||
# Use user's DB language for bind success notification
|
except Exception as exc:
|
||||||
i18n_lang = settings.DEFAULT_LANGUAGE
|
logging.debug(
|
||||||
from db.dal import user_dal
|
"Failed to notify user %s about payment method binding: %s",
|
||||||
db_user = await user_dal.get_user_by_id(session, user_id)
|
user_id,
|
||||||
if db_user and db_user.language_code:
|
exc,
|
||||||
i18n_lang = db_user.language_code
|
)
|
||||||
_ = lambda key, **kwargs: i18n_instance.gettext(i18n_lang, key, **kwargs)
|
# Attempt to cancel the authorization to avoid charge hold
|
||||||
from bot.keyboards.inline.user_keyboards import get_back_to_payment_methods_keyboard
|
try:
|
||||||
await bot.send_message(
|
yk: YooKassaService = request.app.get('yookassa_service')
|
||||||
chat_id=user_id,
|
if yk:
|
||||||
text=_("payment_method_bound_success"),
|
await yk.cancel_payment(payment_dict_for_processing.get("id"))
|
||||||
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
except Exception:
|
||||||
)
|
logging.exception("Failed to cancel bind-only payment auth")
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
logging.debug(
|
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
||||||
"Failed to notify user %s about payment method binding: %s",
|
|
||||||
user_id,
|
|
||||||
exc,
|
|
||||||
)
|
|
||||||
# Attempt to cancel the authorization to avoid charge hold
|
|
||||||
try:
|
|
||||||
yk: YooKassaService = request.app.get('yookassa_service')
|
|
||||||
if yk:
|
|
||||||
await yk.cancel_payment(payment_dict_for_processing.get("id"))
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Failed to cancel bind-only payment auth")
|
|
||||||
except Exception:
|
|
||||||
logging.exception("Failed to handle bind-only waiting_for_capture webhook")
|
|
||||||
except Exception as e_webhook_db_processing:
|
except Exception as e_webhook_db_processing:
|
||||||
await session.rollback()
|
await session.rollback()
|
||||||
logging.error(
|
logging.error(
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class PromoCodeService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
now_utc = datetime.now(timezone.utc)
|
now_utc = datetime.now(timezone.utc)
|
||||||
|
notifications_to_send: list[tuple[int, str]] = []
|
||||||
|
|
||||||
async with self._async_session_factory() as session:
|
async with self._async_session_factory() as session:
|
||||||
expired_discounts = await active_discount_dal.get_expired_active_discounts(
|
expired_discounts = await active_discount_dal.get_expired_active_discounts(
|
||||||
session,
|
session,
|
||||||
@@ -116,17 +118,7 @@ class PromoCodeService:
|
|||||||
code_part=(f" (<code>{promo_code}</code>)" if promo_code else ""),
|
code_part=(f" (<code>{promo_code}</code>)" if promo_code else ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
notifications_to_send.append((expired.user_id, message_text))
|
||||||
await self.bot.send_message(
|
|
||||||
chat_id=expired.user_id,
|
|
||||||
text=message_text,
|
|
||||||
parse_mode="HTML",
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
logging.exception(
|
|
||||||
"Failed to send discount expiration message to user %s",
|
|
||||||
expired.user_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
logging.info(
|
logging.info(
|
||||||
"Expired discount reservation removed: user=%s, promo=%s",
|
"Expired discount reservation removed: user=%s, promo=%s",
|
||||||
@@ -136,6 +128,19 @@ class PromoCodeService:
|
|||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
for user_id, message_text in notifications_to_send:
|
||||||
|
try:
|
||||||
|
await self.bot.send_message(
|
||||||
|
chat_id=user_id,
|
||||||
|
text=message_text,
|
||||||
|
parse_mode="HTML",
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.exception(
|
||||||
|
"Failed to send discount expiration message to user %s",
|
||||||
|
user_id,
|
||||||
|
)
|
||||||
|
|
||||||
async def apply_promo_code(
|
async def apply_promo_code(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
|
|||||||
Reference in New Issue
Block a user