fix(payments): enforce idempotent and verified webhook processing
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"}:
|
||||
|
||||
+34
-2
@@ -5,7 +5,7 @@ from sqlalchemy.future import select
|
||||
from sqlalchemy import update, func, and_
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from db.models import Payment, User
|
||||
from db.models import Payment
|
||||
|
||||
|
||||
async def create_payment_record(session: AsyncSession,
|
||||
@@ -176,6 +176,38 @@ async def update_provider_payment_and_status(
|
||||
return payment
|
||||
|
||||
|
||||
async def mark_provider_payment_succeeded_once(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
provider_payment_id: str) -> bool:
|
||||
"""Atomically mark payment as succeeded only once.
|
||||
|
||||
Returns True only for the first successful transition to "succeeded".
|
||||
Returns False when payment is missing or already succeeded.
|
||||
"""
|
||||
stmt = (
|
||||
update(Payment)
|
||||
.where(
|
||||
Payment.payment_id == payment_db_id,
|
||||
Payment.status != "succeeded",
|
||||
)
|
||||
.values(
|
||||
status="succeeded",
|
||||
provider_payment_id=provider_payment_id,
|
||||
updated_at=func.now(),
|
||||
)
|
||||
)
|
||||
result = await session.execute(stmt)
|
||||
updated = (result.rowcount or 0) > 0
|
||||
if updated:
|
||||
logging.info(
|
||||
"Payment record %s atomically marked as succeeded (provider id %s).",
|
||||
payment_db_id,
|
||||
provider_payment_id,
|
||||
)
|
||||
return updated
|
||||
|
||||
|
||||
async def update_payment_discount_info(
|
||||
session: AsyncSession,
|
||||
payment_db_id: int,
|
||||
@@ -205,7 +237,7 @@ async def update_payment_discount_info(
|
||||
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||
"""Get comprehensive financial statistics."""
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy import and_, text
|
||||
from sqlalchemy import and_
|
||||
|
||||
now = datetime.utcnow()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
Reference in New Issue
Block a user