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:
kavore
2026-02-11 23:49:58 +03:00
parent 814663a528
commit 799bd01a79
2 changed files with 117 additions and 105 deletions
+101 -94
View File
@@ -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(
+16 -11
View File
@@ -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,