diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 53ebdd7..ef25452 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -627,6 +627,51 @@ async def yookassa_webhook_route(request: web.Request): async with async_session_factory() as session: try: + # Defense-in-depth: for state mutations beyond succeeded, verify against provider API. + if notification_object.event in { + YOOKASSA_EVENT_PAYMENT_CANCELED, + YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE, + }: + if not yookassa_service or not yookassa_service.configured: + logging.critical( + "YooKassa webhook rejected: verification service is not configured for event %s (payment_id=%s)", + notification_object.event, + payment_dict_for_processing.get("id"), + ) + return web.Response(status=503, text="yookassa_verification_required") + + provider_payment_info = await yookassa_service.get_payment_info( + payment_dict_for_processing.get("id") + ) + if not provider_payment_info: + logging.error( + "YooKassa webhook verification failed: payment %s not found via provider API", + payment_dict_for_processing.get("id"), + ) + return web.Response(status=503, text="yookassa_verification_failed") + + provider_status = str(provider_payment_info.get("status") or "") + provider_paid = bool(provider_payment_info.get("paid")) + provider_metadata_raw = provider_payment_info.get("metadata") or {} + provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {} + + payment_dict_for_processing["status"] = provider_status or payment_dict_for_processing.get("status") + payment_dict_for_processing["paid"] = provider_paid + payment_dict_for_processing["metadata"] = dict(provider_metadata) + + provider_amount_value = provider_payment_info.get("amount_value") + provider_amount_currency = provider_payment_info.get("amount_currency") + if provider_amount_value is not None and provider_amount_currency: + payment_dict_for_processing["amount"] = { + "value": str(provider_amount_value), + "currency": str(provider_amount_currency), + } + + provider_pm = provider_payment_info.get("payment_method") + if isinstance(provider_pm, dict) and provider_pm.get("id"): + # Use provider payment_method as authoritative. + payment_dict_for_processing["payment_method"] = provider_pm + if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED: if not yookassa_service or not yookassa_service.configured: logging.critical( @@ -669,6 +714,13 @@ async def yookassa_webhook_route(request: web.Request): await session.rollback() return web.Response(status=503, text="yookassa_invalid_succeeded_payload") elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED: + if payment_dict_for_processing.get("status") not in {"canceled", "cancelled"}: + logging.error( + "YooKassa webhook rejected: canceled event status mismatch for payment %s (status=%s)", + payment_dict_for_processing.get("id"), + payment_dict_for_processing.get("status"), + ) + return web.Response(status=503, text="yookassa_invalid_canceled_payload") await process_cancelled_payment( session, bot, payment_dict_for_processing, i18n_instance, settings) @@ -677,6 +729,13 @@ async def yookassa_webhook_route(request: web.Request): # Bind-only flow: save method and cancel auth if metadata has bind_only metadata = payment_dict_for_processing.get("metadata", {}) or {} if settings.yookassa_autopayments_active and metadata.get("bind_only") == "1": + if payment_dict_for_processing.get("status") != "waiting_for_capture": + logging.error( + "YooKassa webhook rejected: waiting_for_capture event status mismatch for payment %s (status=%s)", + payment_dict_for_processing.get("id"), + payment_dict_for_processing.get("status"), + ) + return web.Response(status=503, text="yookassa_invalid_waiting_payload") try: user_id_str = metadata.get("user_id") if user_id_str and user_id_str.isdigit(): @@ -685,23 +744,27 @@ async def yookassa_webhook_route(request: web.Request): if isinstance(payment_method, dict) and payment_method.get("id"): pm_type = payment_method.get("type") title = payment_method.get("title") + + # Support both webhook shape (nested card) and provider shape (card_last4) + last4_val = None card = payment_method.get("card") or {} - account_number = payment_method.get("account_number") or payment_method.get("account") + if isinstance(card, dict) and card.get("last4"): + last4_val = card.get("last4") + if not last4_val: + last4_val = payment_method.get("card_last4") + 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") + display_network = title or "Card" + display_last4 = last4_val 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: - display_last4 = None + display_last4 = last4_val else: display_network = title or (pm_type.upper() if pm_type else "Payment method") - display_last4 = None + display_last4 = last4_val + await user_billing_dal.upsert_yk_payment_method( session, user_id=user_id, diff --git a/bot/handlers/user/subscription/payments_crypto.py b/bot/handlers/user/subscription/payments_crypto.py index 450ff61..1a29d53 100644 --- a/bot/handlers/user/subscription/payments_crypto.py +++ b/bot/handlers/user/subscription/payments_crypto.py @@ -12,6 +12,8 @@ from config.settings import Settings router = Router(name="user_subscription_payments_crypto_router") +from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user + @router.callback_query(F.data.startswith("pay_crypto:")) async def pay_crypto_callback_handler( callback: types.CallbackQuery, @@ -43,7 +45,7 @@ async def pay_crypto_callback_handler( _, data_payload = callback.data.split(":", 1) parts = data_payload.split(":") months = float(parts[0]) - price_amount = float(parts[1]) + callback_price_amount = float(parts[1]) sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): try: @@ -53,6 +55,43 @@ async def pay_crypto_callback_handler( return user_id = callback.from_user.id + resolved_price_amount = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_amount is None: + logging.warning( + "CryptoPay: no server-side price for user %s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc) + return + + if abs(resolved_price_amount - callback_price_amount) > 0.01: + logging.warning( + "CryptoPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_amount, + resolved_price_amount, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_crypto.py: %s", exc) + return + + price_amount = resolved_price_amount human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) diff --git a/bot/handlers/user/subscription/payments_freekassa.py b/bot/handlers/user/subscription/payments_freekassa.py index 40994dd..2204cc4 100644 --- a/bot/handlers/user/subscription/payments_freekassa.py +++ b/bot/handlers/user/subscription/payments_freekassa.py @@ -14,6 +14,8 @@ from db.dal import payment_dal router = Router(name="user_subscription_payments_freekassa_router") +from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user + @router.callback_query(F.data.startswith("pay_fk:")) async def pay_fk_callback_handler( callback: types.CallbackQuery, @@ -50,7 +52,7 @@ async def pay_fk_callback_handler( _, data_payload = callback.data.split(":", 1) parts = data_payload.split(":") months = float(parts[0]) - price_rub = float(parts[1]) + callback_price_rub = float(parts[1]) sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_fk data in callback: {callback.data}") @@ -61,6 +63,43 @@ async def pay_fk_callback_handler( return user_id = callback.from_user.id + resolved_price_rub = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_rub is None: + logging.warning( + "FreeKassa: no server-side price for user %s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc) + return + + if abs(resolved_price_rub - callback_price_rub) > 0.01: + logging.warning( + "FreeKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_rub, + resolved_price_rub, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_freekassa.py: %s", exc) + return + + price_rub = resolved_price_rub human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) @@ -69,8 +108,6 @@ async def pay_fk_callback_handler( ) currency_code = getattr(freekassa_service, "default_currency", None) or "RUB" - # Price is already discounted at payments_subscription.py stage - # Service will handle discount metadata if needed payment_record_payload = { "user_id": user_id, "amount": price_rub, diff --git a/bot/handlers/user/subscription/payments_platega.py b/bot/handlers/user/subscription/payments_platega.py index 1b2eafb..627991b 100644 --- a/bot/handlers/user/subscription/payments_platega.py +++ b/bot/handlers/user/subscription/payments_platega.py @@ -14,6 +14,8 @@ from db.dal import payment_dal router = Router(name="user_subscription_payments_platega_router") +from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user + @router.callback_query(F.data.startswith("pay_platega:")) async def pay_platega_callback_handler( callback: types.CallbackQuery, @@ -50,7 +52,7 @@ async def pay_platega_callback_handler( _, data_payload = callback.data.split(":", 1) parts = data_payload.split(":") months = float(parts[0]) - price_rub = float(parts[1]) + callback_price_rub = float(parts[1]) sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_platega data in callback: {callback.data}") @@ -61,6 +63,43 @@ async def pay_platega_callback_handler( return user_id = callback.from_user.id + resolved_price_rub = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_rub is None: + logging.warning( + "Platega: no server-side price for user %s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc) + return + + if abs(resolved_price_rub - callback_price_rub) > 0.01: + logging.warning( + "Platega: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_rub, + resolved_price_rub, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_platega.py: %s", exc) + return + + price_rub = resolved_price_rub human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) diff --git a/bot/handlers/user/subscription/payments_severpay.py b/bot/handlers/user/subscription/payments_severpay.py index 0f6758f..e5d7e0e 100644 --- a/bot/handlers/user/subscription/payments_severpay.py +++ b/bot/handlers/user/subscription/payments_severpay.py @@ -13,6 +13,8 @@ from db.dal import payment_dal router = Router(name="user_subscription_payments_severpay_router") +from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user + @router.callback_query(F.data.startswith("pay_severpay:")) async def pay_severpay_callback_handler( callback: types.CallbackQuery, @@ -49,7 +51,7 @@ async def pay_severpay_callback_handler( _, data_payload = callback.data.split(":", 1) parts = data_payload.split(":") months = float(parts[0]) - price_rub = float(parts[1]) + callback_price_rub = float(parts[1]) sale_mode = parts[2] if len(parts) > 2 else "subscription" except (ValueError, IndexError): logging.error(f"Invalid pay_severpay data in callback: {callback.data}") @@ -60,6 +62,43 @@ async def pay_severpay_callback_handler( return user_id = callback.from_user.id + resolved_price_rub = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_rub is None: + logging.warning( + "SeverPay: no server-side price for user %s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc) + return + + if abs(resolved_price_rub - callback_price_rub) > 0.01: + logging.warning( + "SeverPay: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_rub, + resolved_price_rub, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_severpay.py: %s", exc) + return + + price_rub = resolved_price_rub human_value = str(int(months)) if float(months).is_integer() else f"{months:g}" payment_description = ( get_text("payment_description_traffic", traffic_gb=human_value) diff --git a/bot/handlers/user/subscription/payments_subscription.py b/bot/handlers/user/subscription/payments_subscription.py index b84dc9f..547c4bb 100644 --- a/bot/handlers/user/subscription/payments_subscription.py +++ b/bot/handlers/user/subscription/payments_subscription.py @@ -12,6 +12,36 @@ from config.settings import Settings router = Router(name="user_subscription_payments_selection_router") +async def resolve_fiat_offer_price_for_user( + session: AsyncSession, + settings: Settings, + user_id: int, + months: float, + sale_mode: str, + promo_code_service=None, +) -> Optional[float]: + """Resolve offer price server-side to prevent callback payload tampering.""" + price_source = ( + getattr(settings, "traffic_packages", {}) or {} + if sale_mode == "traffic" + else (settings.subscription_options or {}) + ) + base_price = price_source.get(months) + if base_price is None: + return None + + resolved_price = float(base_price) + if promo_code_service: + active_discount_info = await promo_code_service.get_user_active_discount(session, user_id) + if active_discount_info: + discount_pct, _ = active_discount_info + resolved_price, _ = promo_code_service.calculate_discounted_price( + resolved_price, + discount_pct, + ) + return resolved_price + + @router.callback_query(F.data.startswith("subscribe_period:")) async def select_subscription_period_callback_handler( callback: types.CallbackQuery, diff --git a/bot/handlers/user/subscription/payments_yookassa.py b/bot/handlers/user/subscription/payments_yookassa.py index 39f99dc..593f1cb 100644 --- a/bot/handlers/user/subscription/payments_yookassa.py +++ b/bot/handlers/user/subscription/payments_yookassa.py @@ -18,6 +18,8 @@ from db.dal import payment_dal, user_billing_dal, active_discount_dal router = Router(name="user_subscription_payments_yookassa_router") +from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user + def _format_value(val: float) -> str: return str(int(val)) if float(val).is_integer() else f"{val:g}" @@ -404,8 +406,46 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) return - months, price_rub, sale_mode = parsed + months, callback_price_rub, sale_mode = parsed user_id = callback.from_user.id + + resolved_price_rub = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_rub is None: + logging.warning( + "YooKassa: no server-side price for user %s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return + + if abs(resolved_price_rub - callback_price_rub) > 0.01: + logging.warning( + "YooKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_rub, + resolved_price_rub, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return + + price_rub = resolved_price_rub currency_code_for_yk = "RUB" autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_require_binding = bool( @@ -523,8 +563,45 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) return - months, price_rub, sale_mode = parsed + months, callback_price_rub, sale_mode = parsed user_id = callback.from_user.id + resolved_price_rub = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_rub is None: + logging.warning( + "YooKassa: no server-side price for new-card flow, user=%s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return + + if abs(resolved_price_rub - callback_price_rub) > 0.01: + logging.warning( + "YooKassa: callback price mismatch in new-card flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_rub, + resolved_price_rub, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return + + price_rub = resolved_price_rub currency_code_for_yk = "RUB" autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode) autopay_require_binding = bool( @@ -555,7 +632,7 @@ async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Setti @router.callback_query(F.data.startswith("pay_yk_saved_list:")) -async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession): +async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None): current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE) i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance") get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key @@ -588,7 +665,7 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set try: months = float(parts[0]) - price_rub = float(parts[1]) + callback_price_rub = float(parts[1]) page = int(parts[2]) if len(parts) > 2 else 0 sale_mode = parts[3] if len(parts) > 3 else "subscription" except (ValueError, IndexError): @@ -608,6 +685,43 @@ async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Set return user_id = callback.from_user.id + resolved_price_rub = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_rub is None: + logging.warning( + "YooKassa: no server-side price for saved-list flow, user=%s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return + + if abs(resolved_price_rub - callback_price_rub) > 0.01: + logging.warning( + "YooKassa: callback price mismatch in saved-list flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_rub, + resolved_price_rub, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return + + price_rub = resolved_price_rub try: saved_methods = await user_billing_dal.list_user_payment_methods( session, user_id, provider="yookassa" @@ -744,7 +858,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett try: months = float(parts[0]) - price_rub = float(parts[1]) + callback_price_rub = float(parts[1]) sale_mode = parts[3] if len(parts) > 3 else "subscription" except (ValueError, IndexError): logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}") @@ -764,6 +878,41 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett method_identifier = parts[2] user_id = callback.from_user.id + resolved_price_rub = await resolve_fiat_offer_price_for_user( + session=session, + settings=settings, + user_id=user_id, + months=months, + sale_mode=sale_mode, + promo_code_service=promo_code_service, + ) + if resolved_price_rub is None: + logging.warning( + "YooKassa: no server-side price for use-saved flow, user=%s, value=%s, mode=%s", + user_id, + months, + sale_mode, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return + + if abs(resolved_price_rub - callback_price_rub) > 0.01: + logging.warning( + "YooKassa: callback price mismatch in use-saved flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f", + user_id, + months, + sale_mode, + callback_price_rub, + resolved_price_rub, + ) + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception as exc: + logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) + return try: saved_methods = await user_billing_dal.list_user_payment_methods( @@ -791,6 +940,7 @@ async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Sett logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc) return + price_rub = resolved_price_rub currency_code_for_yk = "RUB" await _initiate_yk_payment(