fix(payments): enforce idempotent and verified webhook processing

This commit is contained in:
kavore
2026-02-08 21:30:39 +03:00
parent 4853a49112
commit 9a32cab718
6 changed files with 280 additions and 24 deletions
+84 -2
View File
@@ -39,6 +39,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
panel_service: PanelApiService,
subscription_service: SubscriptionService,
referral_service: ReferralService,
yookassa_service: Optional[YooKassaService] = None,
lknpd_service: Optional[LknpdService] = None):
metadata = payment_info_from_webhook.get("metadata", {})
user_id_str = metadata.get("user_id")
@@ -119,6 +120,81 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
)
return
# Provider-backed verification (defense-in-depth): verify actual YooKassa payment state
if yk_payment_id_from_hook and yookassa_service and yookassa_service.configured:
provider_payment_info = await yookassa_service.get_payment_info(yk_payment_id_from_hook)
if not provider_payment_info:
logging.error(
"YooKassa webhook verification failed: payment %s not found via provider API",
yk_payment_id_from_hook,
)
return
provider_status = str(provider_payment_info.get("status") or "")
provider_paid = bool(provider_payment_info.get("paid"))
if provider_status != "succeeded" or not provider_paid:
logging.error(
"YooKassa webhook verification failed: payment %s status/paid mismatch (status=%s, paid=%s)",
yk_payment_id_from_hook,
provider_status,
provider_paid,
)
return
provider_metadata_raw = provider_payment_info.get("metadata") or {}
provider_metadata = provider_metadata_raw if isinstance(provider_metadata_raw, dict) else {}
if str(provider_metadata.get("user_id") or "") != str(user_id):
logging.error(
"YooKassa webhook verification failed: user_id mismatch for payment %s (provider=%s, expected=%s)",
yk_payment_id_from_hook,
provider_metadata.get("user_id"),
user_id,
)
return
if payment_db_id and str(provider_metadata.get("payment_db_id") or "") != str(payment_db_id):
logging.error(
"YooKassa webhook verification failed: payment_db_id mismatch for payment %s (provider=%s, expected=%s)",
yk_payment_id_from_hook,
provider_metadata.get("payment_db_id"),
payment_db_id,
)
return
try:
provider_amount = float(provider_payment_info.get("amount_value") or 0.0)
if round(provider_amount, 2) != round(payment_value, 2):
logging.error(
"YooKassa webhook verification failed: amount mismatch for payment %s (payload %.2f vs provider %.2f)",
yk_payment_id_from_hook,
payment_value,
provider_amount,
)
return
if payment_record and round(float(payment_record.amount), 2) != round(provider_amount, 2):
logging.error(
"YooKassa webhook verification failed: DB amount mismatch for payment %s (db %.2f vs provider %.2f)",
payment_record.payment_id,
float(payment_record.amount),
provider_amount,
)
return
provider_currency = str(provider_payment_info.get("amount_currency") or "").upper()
if payment_record and provider_currency and str(payment_record.currency or "").upper() != provider_currency:
logging.error(
"YooKassa webhook verification failed: currency mismatch for payment %s (db=%s, provider=%s)",
payment_record.payment_id,
payment_record.currency,
provider_currency,
)
return
except Exception as e_amount_verify:
logging.error(
"YooKassa webhook verification failed for payment %s: cannot validate amount (%s)",
yk_payment_id_from_hook,
e_amount_verify,
)
return
if payment_record and payment_record.status == "succeeded":
logging.info(
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
@@ -470,6 +546,7 @@ async def yookassa_webhook_route(request: web.Request):
i18n_instance: JsonI18n = request.app['i18n']
settings: Settings = request.app['settings']
panel_service: PanelApiService = request.app['panel_service']
yookassa_service: Optional[YooKassaService] = request.app.get('yookassa_service')
subscription_service: SubscriptionService = request.app[
'subscription_service']
referral_service: ReferralService = request.app['referral_service']
@@ -567,6 +644,7 @@ async def yookassa_webhook_route(request: web.Request):
session, bot, payment_dict_for_processing,
i18n_instance, settings, panel_service,
subscription_service, referral_service,
yookassa_service,
lknpd_service)
await session.commit()
else:
@@ -647,8 +725,12 @@ async def yookassa_webhook_route(request: web.Request):
text=_("payment_method_bound_success"),
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
)
except Exception:
pass
except Exception as exc:
logging.debug(
"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')
+60 -2
View File
@@ -204,12 +204,70 @@ class CryptoPayService:
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
return
await payment_dal.update_provider_payment_and_status(
if payment_record.user_id != user_id:
logging.error(
"CryptoPay webhook: user mismatch for payment %s (db=%s, payload=%s)",
payment_db_id,
payment_record.user_id,
user_id,
)
return
provider_currency = None
for candidate in (
getattr(invoice, "fiat", None),
getattr(invoice, "asset", None),
getattr(invoice, "paid_asset", None),
settings.CRYPTOPAY_ASSET,
):
if candidate:
provider_currency = str(candidate).upper()
break
expected_currency = str(payment_record.currency or "").upper()
if expected_currency and provider_currency and expected_currency != provider_currency:
logging.error(
"CryptoPay webhook: currency mismatch for payment %s (expected %s, got %s)",
payment_db_id,
expected_currency,
provider_currency,
)
return
if payment_record.status == "succeeded":
logging.info("CryptoPay webhook: payment %s already succeeded", payment_db_id)
return
try:
expected_amount = float(payment_record.amount)
incoming_amount = float(invoice.amount)
if round(incoming_amount, 2) != round(expected_amount, 2):
logging.error(
"CryptoPay webhook: amount mismatch for payment %s (expected %.2f, got %.2f)",
payment_db_id,
expected_amount,
incoming_amount,
)
return
except Exception as amount_exc:
logging.error(
"CryptoPay webhook: failed to compare amount for payment %s: %s",
payment_db_id,
amount_exc,
)
return
marked = await payment_dal.mark_provider_payment_succeeded_once(
session,
payment_db_id,
str(invoice.invoice_id),
"succeeded",
)
if not marked:
logging.info(
"CryptoPay webhook: payment %s already processed atomically",
payment_db_id,
)
return
activation = await subscription_service.activate_subscription(
session,
user_id,
+27 -6
View File
@@ -238,7 +238,10 @@ class FreeKassaService:
if self.shop_id and self.second_secret:
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
expected_signature = hashlib.md5(signature_source.encode("utf-8")).hexdigest()
expected_signature = hashlib.md5(
signature_source.encode("utf-8"),
usedforsecurity=False,
).hexdigest()
if expected_signature.lower() == provided_signature.lower():
return True
@@ -317,6 +320,16 @@ class FreeKassaService:
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
return web.Response(status=404, text="payment_not_found")
if payment.currency and str(payment.currency).upper() != str(self.default_currency or payment.currency).upper():
# FreeKassa sends amount without currency; ensure DB currency matches configured service currency
logging.error(
"FreeKassa webhook: currency mismatch for payment %s (db=%s, expected=%s)",
payment_db_id,
payment.currency,
self.default_currency,
)
return web.Response(status=400, text="currency_mismatch")
if payment.status == "succeeded":
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
return web.Response(text="YES")
@@ -326,22 +339,30 @@ class FreeKassaService:
amount_decimal = Decimal(amount_str)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if amount_decimal.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) != expected_amount:
logging.warning(
logging.error(
f"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
f"(expected {expected_amount}, got {amount_decimal})"
)
return web.Response(status=400, text="amount_mismatch")
except Exception as e:
logging.warning(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
logging.error(f"FreeKassa webhook: failed to compare amount for payment {payment_db_id}: {e}")
return web.Response(status=400, text="amount_validation_error")
activation = None
referral_bonus = None
try:
await payment_dal.update_provider_payment_and_status(
provider_id = str(provider_payment_id or f"freekassa:{order_id_str}")
marked = await payment_dal.mark_provider_payment_succeeded_once(
session=session,
payment_db_id=payment.payment_id,
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
new_status="succeeded",
provider_payment_id=provider_id,
)
if not marked:
logging.info(
"FreeKassa webhook: payment %s already processed atomically",
payment.payment_id,
)
return web.Response(text="YES")
months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
+26 -7
View File
@@ -220,27 +220,46 @@ class PlategaService:
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
if status == "CONFIRMED":
if currency:
provider_currency = str(currency).upper()
expected_currency = str(payment.currency or "").upper()
if expected_currency and expected_currency != provider_currency:
logging.error(
"Platega webhook: currency mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_currency,
provider_currency,
)
return web.Response(status=400, text="currency_mismatch")
if amount_raw is not None:
try:
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if incoming_amount != expected_amount:
logging.warning(
logging.error(
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_amount,
incoming_amount,
)
return web.Response(status=400, text="amount_mismatch")
except Exception as exc:
logging.warning("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
logging.error("Platega webhook: failed to compare amounts for %s: %s", payment.payment_id, exc)
return web.Response(status=400, text="amount_validation_error")
try:
await payment_dal.update_provider_payment_and_status(
marked = await payment_dal.mark_provider_payment_succeeded_once(
session,
payment.payment_id,
transaction_id,
"succeeded",
)
if not marked:
logging.info(
"Platega webhook: payment %s already processed atomically",
payment.payment_id,
)
return web.Response(text="ok")
activation = await self.subscription_service.activate_subscription(
session,
@@ -365,7 +384,7 @@ class PlategaService:
return web.Response(text="ok")
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
if status in {"CANCELED", "CANCELLED", "CHARGEBACK", "CHARGEBACKED"}:
try:
await payment_dal.update_provider_payment_and_status(
session,
@@ -384,8 +403,8 @@ class PlategaService:
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
except Exception as exc:
logging.debug("Platega webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
return web.Response(text="ok_canceled")
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
+49 -5
View File
@@ -228,6 +228,8 @@ class SeverPayService:
provider_payment_id = str(data.get("id") or data.get("uid") or "")
order_id_raw = data.get("order_id")
status = str(data.get("status") or "").lower()
amount_raw = data.get("amount")
currency_raw = data.get("currency")
payment_db_id: Optional[int] = None
try:
@@ -249,16 +251,58 @@ class SeverPayService:
logging.error("SeverPay webhook: payment not found (order_id=%s, provider_id=%s)", order_id_raw, provider_payment_id)
return web.json_response({"status": False, "msg": "payment_not_found"}, status=404)
if payment.status == "succeeded" and status == "success":
logging.info("SeverPay webhook: payment %s already succeeded", payment.payment_id)
return web.json_response({"status": True})
if status == "success" and amount_raw is not None:
try:
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
if incoming_amount != expected_amount:
logging.error(
"SeverPay webhook: amount mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_amount,
incoming_amount,
)
return web.json_response({"status": False, "msg": "amount_mismatch"}, status=400)
except Exception as exc:
logging.error(
"SeverPay webhook: failed to compare amounts for payment %s: %s",
payment.payment_id,
exc,
)
return web.json_response({"status": False, "msg": "amount_validation_error"}, status=400)
if currency_raw:
provider_currency = str(currency_raw).upper()
expected_currency = str(payment.currency or "").upper()
if expected_currency and provider_currency != expected_currency:
logging.error(
"SeverPay webhook: currency mismatch for payment %s (expected %s, got %s)",
payment.payment_id,
expected_currency,
provider_currency,
)
return web.json_response({"status": False, "msg": "currency_mismatch"}, status=400)
payment_months = payment.subscription_duration_months or 1
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
if status == "success":
try:
await payment_dal.update_provider_payment_and_status(
provider_id = provider_payment_id or str(payment.payment_id)
marked = await payment_dal.mark_provider_payment_succeeded_once(
session,
payment.payment_id,
provider_payment_id or str(payment.payment_id),
"succeeded",
provider_id,
)
if not marked:
logging.info(
"SeverPay webhook: payment %s already processed atomically",
payment.payment_id,
)
return web.json_response({"status": True})
activation = await self.subscription_service.activate_subscription(
session,
@@ -402,8 +446,8 @@ class SeverPayService:
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
try:
await self.bot.send_message(payment.user_id, _("payment_failed"))
except Exception:
pass
except Exception as exc:
logging.debug("SeverPay webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
return web.json_response({"status": True})
if status in {"process", "new"}: