fix(payments): enforce strict YooKassa webhook binding
Require payment_db_id for successful webhook processing and validate ownership. Precreate payment records for auto-renew and include payment_db_id in provider metadata. Ignore replayed succeeded webhooks. Also normalize empty Telegram webhook secret before set_webhook and request handler usage.
This commit is contained in:
@@ -41,6 +41,7 @@ async def build_and_start_web_app(
|
|||||||
setup_application(app, dp, bot=bot)
|
setup_application(app, dp, bot=bot)
|
||||||
|
|
||||||
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
telegram_uses_webhook_mode = bool(settings.WEBHOOK_BASE_URL)
|
||||||
|
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
|
||||||
|
|
||||||
if telegram_uses_webhook_mode:
|
if telegram_uses_webhook_mode:
|
||||||
telegram_webhook_path = settings.telegram_webhook_path
|
telegram_webhook_path = settings.telegram_webhook_path
|
||||||
@@ -49,13 +50,13 @@ async def build_and_start_web_app(
|
|||||||
SimpleRequestHandler(
|
SimpleRequestHandler(
|
||||||
dispatcher=dp,
|
dispatcher=dp,
|
||||||
bot=bot,
|
bot=bot,
|
||||||
secret_token=settings.TELEGRAM_WEBHOOK_SECRET,
|
secret_token=telegram_webhook_secret,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
logging.info(
|
logging.info(
|
||||||
"Telegram webhook route configured at: [POST] %s (secret_token=%s)",
|
"Telegram webhook route configured at: [POST] %s (secret_token=%s)",
|
||||||
telegram_webhook_path,
|
telegram_webhook_path,
|
||||||
"set" if settings.TELEGRAM_WEBHOOK_SECRET else "not_set",
|
"set" if telegram_webhook_secret else "not_set",
|
||||||
)
|
)
|
||||||
|
|
||||||
from bot.handlers.user.payment import yookassa_webhook_route
|
from bot.handlers.user.payment import yookassa_webhook_route
|
||||||
|
|||||||
@@ -51,12 +51,10 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
auto_renew_subscription_id_str = metadata.get(
|
auto_renew_subscription_id_str = metadata.get(
|
||||||
"auto_renew_for_subscription_id")
|
"auto_renew_for_subscription_id")
|
||||||
|
|
||||||
# For auto-renew payments, payment_db_id may be absent. In that case,
|
|
||||||
# we will create/ensure a payment record idempotently using provider payment id.
|
|
||||||
if (
|
if (
|
||||||
not user_id_str
|
not user_id_str
|
||||||
or (not subscription_months_str and not traffic_gb_str)
|
or (not subscription_months_str and not traffic_gb_str)
|
||||||
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
or not payment_db_id_str
|
||||||
):
|
):
|
||||||
logging.error(
|
logging.error(
|
||||||
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}"
|
||||||
@@ -68,57 +66,37 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
user_id = int(user_id_str)
|
user_id = int(user_id_str)
|
||||||
subscription_months = float(subscription_months_str or 0)
|
subscription_months = float(subscription_months_str or 0)
|
||||||
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
traffic_amount_gb = float(traffic_gb_str) if traffic_gb_str else subscription_months
|
||||||
payment_db_id = int(
|
if not payment_db_id_str.isdigit():
|
||||||
payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
logging.error(
|
||||||
is_auto_renew = bool(auto_renew_subscription_id_str and not payment_db_id and sale_mode != "traffic")
|
"Invalid payment_db_id metadata for payment %s: %s",
|
||||||
|
payment_info_from_webhook.get("id"),
|
||||||
|
payment_db_id_str,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
payment_db_id = int(payment_db_id_str)
|
||||||
|
is_auto_renew = bool(auto_renew_subscription_id_str and sale_mode != "traffic")
|
||||||
promo_code_id = int(
|
promo_code_id = int(
|
||||||
promo_code_id_str
|
promo_code_id_str
|
||||||
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
||||||
|
|
||||||
amount_data = payment_info_from_webhook.get("amount", {})
|
amount_data = payment_info_from_webhook.get("amount", {})
|
||||||
months_for_record = int(subscription_months) if sale_mode != "traffic" else 0
|
|
||||||
payment_value = float(amount_data.get("value", 0.0))
|
payment_value = float(amount_data.get("value", 0.0))
|
||||||
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
||||||
|
|
||||||
payment_record = None
|
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
||||||
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
if not payment_record:
|
||||||
if payment_db_id is None and auto_renew_subscription_id_str:
|
logging.error(
|
||||||
try:
|
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
||||||
if not yk_payment_id_from_hook:
|
)
|
||||||
logging.error(
|
return
|
||||||
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record."
|
if payment_record.user_id != user_id:
|
||||||
)
|
logging.error(
|
||||||
return
|
"Payment ownership mismatch for payment %s: metadata user_id=%s, db user_id=%s",
|
||||||
from db.dal import payment_dal as _payment_dal
|
payment_db_id,
|
||||||
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
|
user_id,
|
||||||
session, yk_payment_id_from_hook
|
payment_record.user_id,
|
||||||
)
|
)
|
||||||
if not payment_record:
|
return
|
||||||
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
|
||||||
session,
|
|
||||||
user_id=user_id,
|
|
||||||
amount=payment_value,
|
|
||||||
currency=amount_data.get("currency", "RUB"),
|
|
||||||
months=months_for_record or 1,
|
|
||||||
description=payment_info_from_webhook.get(
|
|
||||||
"description") or f"Auto-renewal for {months_for_record or subscription_months} months",
|
|
||||||
provider="yookassa",
|
|
||||||
provider_payment_id=yk_payment_id_from_hook,
|
|
||||||
)
|
|
||||||
payment_db_id = payment_record.payment_id
|
|
||||||
except Exception as e_ensure:
|
|
||||||
logging.error(
|
|
||||||
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}",
|
|
||||||
exc_info=True,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
elif payment_db_id is not None:
|
|
||||||
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
|
||||||
if not payment_record:
|
|
||||||
logging.error(
|
|
||||||
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Provider-backed verification (defense-in-depth): verify actual YooKassa payment state
|
# Provider-backed verification (defense-in-depth): verify actual YooKassa payment state
|
||||||
if yk_payment_id_from_hook and yookassa_service and yookassa_service.configured:
|
if yk_payment_id_from_hook and yookassa_service and yookassa_service.configured:
|
||||||
@@ -151,7 +129,8 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
user_id,
|
user_id,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if payment_db_id and str(provider_metadata.get("payment_db_id") or "") != str(payment_db_id):
|
provider_payment_db_id = str(provider_metadata.get("payment_db_id") or "").strip()
|
||||||
|
if provider_payment_db_id != str(payment_db_id):
|
||||||
logging.error(
|
logging.error(
|
||||||
"YooKassa webhook verification failed: payment_db_id mismatch for payment %s (provider=%s, expected=%s)",
|
"YooKassa webhook verification failed: payment_db_id mismatch for payment %s (provider=%s, expected=%s)",
|
||||||
yk_payment_id_from_hook,
|
yk_payment_id_from_hook,
|
||||||
@@ -237,6 +216,13 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
session,
|
session,
|
||||||
payment_db_id,
|
payment_db_id,
|
||||||
)
|
)
|
||||||
|
if payment_before_update and payment_before_update.status == "succeeded":
|
||||||
|
logging.info(
|
||||||
|
"YooKassa webhook ignored: payment %s already succeeded (db_id=%s)",
|
||||||
|
yk_payment_id_from_hook,
|
||||||
|
payment_db_id,
|
||||||
|
)
|
||||||
|
return
|
||||||
should_send_lknpd_receipt = bool(
|
should_send_lknpd_receipt = bool(
|
||||||
lknpd_service
|
lknpd_service
|
||||||
and lknpd_service.configured
|
and lknpd_service.configured
|
||||||
|
|||||||
+2
-1
@@ -77,11 +77,12 @@ async def on_startup_configured(dispatcher: Dispatcher):
|
|||||||
else:
|
else:
|
||||||
logging.info("STARTUP: Telegram webhook currently empty (will set).")
|
logging.info("STARTUP: Telegram webhook currently empty (will set).")
|
||||||
|
|
||||||
|
telegram_webhook_secret = (settings.TELEGRAM_WEBHOOK_SECRET or "").strip() or None
|
||||||
set_success = await bot.set_webhook(
|
set_success = await bot.set_webhook(
|
||||||
url=full_telegram_webhook_url,
|
url=full_telegram_webhook_url,
|
||||||
drop_pending_updates=True,
|
drop_pending_updates=True,
|
||||||
allowed_updates=dispatcher.resolve_used_update_types(),
|
allowed_updates=dispatcher.resolve_used_update_types(),
|
||||||
secret_token=settings.TELEGRAM_WEBHOOK_SECRET,
|
secret_token=telegram_webhook_secret,
|
||||||
)
|
)
|
||||||
if set_success:
|
if set_success:
|
||||||
logging.info("STARTUP: bot.set_webhook returned SUCCESS (True).")
|
logging.info("STARTUP: bot.set_webhook returned SUCCESS (True).")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple
|
|||||||
from aiogram import Bot
|
from aiogram import Bot
|
||||||
from bot.middlewares.i18n import JsonI18n
|
from bot.middlewares.i18n import JsonI18n
|
||||||
|
|
||||||
from db.dal import user_dal, subscription_dal, promo_code_dal, user_billing_dal
|
from db.dal import user_dal, subscription_dal, promo_code_dal, user_billing_dal, payment_dal
|
||||||
from bot.utils.date_utils import add_months
|
from bot.utils.date_utils import add_months
|
||||||
from bot.utils.config_link import prepare_config_links
|
from bot.utils.config_link import prepare_config_links
|
||||||
from db.models import User, Subscription
|
from db.models import User, Subscription
|
||||||
@@ -1007,15 +1007,30 @@ class SubscriptionService:
|
|||||||
logging.error(f"Auto-renew price missing for {months} months")
|
logging.error(f"Auto-renew price missing for {months} months")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
payment_description = f"Auto-renewal for {months} months"
|
||||||
|
payment_record = await payment_dal.create_payment_record(
|
||||||
|
session,
|
||||||
|
{
|
||||||
|
"user_id": sub.user_id,
|
||||||
|
"amount": float(amount),
|
||||||
|
"currency": "RUB",
|
||||||
|
"status": "pending_yookassa",
|
||||||
|
"description": payment_description,
|
||||||
|
"subscription_duration_months": int(months),
|
||||||
|
"provider": "yookassa",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
metadata = {
|
metadata = {
|
||||||
"user_id": str(sub.user_id),
|
"user_id": str(sub.user_id),
|
||||||
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
"auto_renew_for_subscription_id": str(sub.subscription_id),
|
||||||
"subscription_months": str(months),
|
"subscription_months": str(months),
|
||||||
|
"payment_db_id": str(payment_record.payment_id),
|
||||||
}
|
}
|
||||||
resp = await yk.create_payment(
|
resp = await yk.create_payment(
|
||||||
amount=float(amount),
|
amount=float(amount),
|
||||||
currency="RUB",
|
currency="RUB",
|
||||||
description=f"Auto-renewal for {months} months",
|
description=payment_description,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
payment_method_id=default_pm.provider_payment_method_id,
|
payment_method_id=default_pm.provider_payment_method_id,
|
||||||
save_payment_method=False,
|
save_payment_method=False,
|
||||||
@@ -1024,6 +1039,14 @@ class SubscriptionService:
|
|||||||
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
|
if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}:
|
||||||
logging.error(f"Auto-renew create_payment failed: {resp}")
|
logging.error(f"Auto-renew create_payment failed: {resp}")
|
||||||
return False
|
return False
|
||||||
|
provider_payment_id = resp.get("id")
|
||||||
|
if provider_payment_id:
|
||||||
|
await payment_dal.update_provider_payment_and_status(
|
||||||
|
session,
|
||||||
|
payment_db_id=payment_record.payment_id,
|
||||||
|
provider_payment_id=provider_payment_id,
|
||||||
|
new_status="pending_yookassa",
|
||||||
|
)
|
||||||
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user