diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index d489379..a932ad3 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -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') diff --git a/bot/services/crypto_pay_service.py b/bot/services/crypto_pay_service.py index d98c453..2b0a65a 100644 --- a/bot/services/crypto_pay_service.py +++ b/bot/services/crypto_pay_service.py @@ -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, diff --git a/bot/services/freekassa_service.py b/bot/services/freekassa_service.py index 2bcde66..ad9c50e 100644 --- a/bot/services/freekassa_service.py +++ b/bot/services/freekassa_service.py @@ -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" diff --git a/bot/services/platega_service.py b/bot/services/platega_service.py index 24ff38e..defb14f 100644 --- a/bot/services/platega_service.py +++ b/bot/services/platega_service.py @@ -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) diff --git a/bot/services/severpay_service.py b/bot/services/severpay_service.py index dac68f7..346b3e7 100644 --- a/bot/services/severpay_service.py +++ b/bot/services/severpay_service.py @@ -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"}: diff --git a/db/dal/payment_dal.py b/db/dal/payment_dal.py index 74a3223..7d6b1c9 100644 --- a/db/dal/payment_dal.py +++ b/db/dal/payment_dal.py @@ -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)