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,
|
panel_service: PanelApiService,
|
||||||
subscription_service: SubscriptionService,
|
subscription_service: SubscriptionService,
|
||||||
referral_service: ReferralService,
|
referral_service: ReferralService,
|
||||||
|
yookassa_service: Optional[YooKassaService] = None,
|
||||||
lknpd_service: Optional[LknpdService] = None):
|
lknpd_service: Optional[LknpdService] = None):
|
||||||
metadata = payment_info_from_webhook.get("metadata", {})
|
metadata = payment_info_from_webhook.get("metadata", {})
|
||||||
user_id_str = metadata.get("user_id")
|
user_id_str = metadata.get("user_id")
|
||||||
@@ -119,6 +120,81 @@ async def process_successful_payment(session: AsyncSession, bot: Bot,
|
|||||||
)
|
)
|
||||||
return
|
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":
|
if payment_record and payment_record.status == "succeeded":
|
||||||
logging.info(
|
logging.info(
|
||||||
f"Skipping duplicate YooKassa webhook for payment {payment_db_id} (YK: {yk_payment_id_from_hook})."
|
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']
|
i18n_instance: JsonI18n = request.app['i18n']
|
||||||
settings: Settings = request.app['settings']
|
settings: Settings = request.app['settings']
|
||||||
panel_service: PanelApiService = request.app['panel_service']
|
panel_service: PanelApiService = request.app['panel_service']
|
||||||
|
yookassa_service: Optional[YooKassaService] = request.app.get('yookassa_service')
|
||||||
subscription_service: SubscriptionService = request.app[
|
subscription_service: SubscriptionService = request.app[
|
||||||
'subscription_service']
|
'subscription_service']
|
||||||
referral_service: ReferralService = request.app['referral_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,
|
session, bot, payment_dict_for_processing,
|
||||||
i18n_instance, settings, panel_service,
|
i18n_instance, settings, panel_service,
|
||||||
subscription_service, referral_service,
|
subscription_service, referral_service,
|
||||||
|
yookassa_service,
|
||||||
lknpd_service)
|
lknpd_service)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
else:
|
else:
|
||||||
@@ -647,8 +725,12 @@ async def yookassa_webhook_route(request: web.Request):
|
|||||||
text=_("payment_method_bound_success"),
|
text=_("payment_method_bound_success"),
|
||||||
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
reply_markup=get_back_to_payment_methods_keyboard(i18n_lang, i18n_instance)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logging.debug(
|
||||||
|
"Failed to notify user %s about payment method binding: %s",
|
||||||
|
user_id,
|
||||||
|
exc,
|
||||||
|
)
|
||||||
# Attempt to cancel the authorization to avoid charge hold
|
# Attempt to cancel the authorization to avoid charge hold
|
||||||
try:
|
try:
|
||||||
yk: YooKassaService = request.app.get('yookassa_service')
|
yk: YooKassaService = request.app.get('yookassa_service')
|
||||||
|
|||||||
@@ -204,12 +204,70 @@ class CryptoPayService:
|
|||||||
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
|
logging.error(f"CryptoPay: Payment record {payment_db_id} not found")
|
||||||
return
|
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,
|
session,
|
||||||
payment_db_id,
|
payment_db_id,
|
||||||
str(invoice.invoice_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(
|
activation = await subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
user_id,
|
user_id,
|
||||||
|
|||||||
@@ -238,7 +238,10 @@ class FreeKassaService:
|
|||||||
|
|
||||||
if self.shop_id and self.second_secret:
|
if self.shop_id and self.second_secret:
|
||||||
signature_source = f"{self.shop_id}:{amount}:{self.second_secret}:{merchant_order_id}"
|
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():
|
if expected_signature.lower() == provided_signature.lower():
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -317,6 +320,16 @@ class FreeKassaService:
|
|||||||
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
logging.error(f"FreeKassa webhook: payment {payment_db_id} not found")
|
||||||
return web.Response(status=404, text="payment_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":
|
if payment.status == "succeeded":
|
||||||
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
logging.info(f"FreeKassa webhook: payment {payment_db_id} already succeeded")
|
||||||
return web.Response(text="YES")
|
return web.Response(text="YES")
|
||||||
@@ -326,22 +339,30 @@ class FreeKassaService:
|
|||||||
amount_decimal = Decimal(amount_str)
|
amount_decimal = Decimal(amount_str)
|
||||||
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
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:
|
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"FreeKassa webhook: amount mismatch for payment {payment_db_id} "
|
||||||
f"(expected {expected_amount}, got {amount_decimal})"
|
f"(expected {expected_amount}, got {amount_decimal})"
|
||||||
)
|
)
|
||||||
|
return web.Response(status=400, text="amount_mismatch")
|
||||||
except Exception as e:
|
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
|
activation = None
|
||||||
referral_bonus = None
|
referral_bonus = None
|
||||||
try:
|
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,
|
session=session,
|
||||||
payment_db_id=payment.payment_id,
|
payment_db_id=payment.payment_id,
|
||||||
provider_payment_id=str(provider_payment_id or f"freekassa:{order_id_str}"),
|
provider_payment_id=provider_id,
|
||||||
new_status="succeeded",
|
|
||||||
)
|
)
|
||||||
|
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
|
months = payment.subscription_duration_months or 1
|
||||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
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"
|
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
|
|
||||||
if status == "CONFIRMED":
|
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:
|
if amount_raw is not None:
|
||||||
try:
|
try:
|
||||||
incoming_amount = Decimal(str(amount_raw)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
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)
|
expected_amount = Decimal(str(payment.amount)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||||
if incoming_amount != expected_amount:
|
if incoming_amount != expected_amount:
|
||||||
logging.warning(
|
logging.error(
|
||||||
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
|
"Platega webhook: amount mismatch for payment %s (expected %s, got %s)",
|
||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
expected_amount,
|
expected_amount,
|
||||||
incoming_amount,
|
incoming_amount,
|
||||||
)
|
)
|
||||||
|
return web.Response(status=400, text="amount_mismatch")
|
||||||
except Exception as exc:
|
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:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
marked = await payment_dal.mark_provider_payment_succeeded_once(
|
||||||
session,
|
session,
|
||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
transaction_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(
|
activation = await self.subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
@@ -365,7 +384,7 @@ class PlategaService:
|
|||||||
|
|
||||||
return web.Response(text="ok")
|
return web.Response(text="ok")
|
||||||
|
|
||||||
if status in {"CANCELED", "CANCELLED", "CHARGEBACKED"}:
|
if status in {"CANCELED", "CANCELLED", "CHARGEBACK", "CHARGEBACKED"}:
|
||||||
try:
|
try:
|
||||||
await payment_dal.update_provider_payment_and_status(
|
await payment_dal.update_provider_payment_and_status(
|
||||||
session,
|
session,
|
||||||
@@ -384,8 +403,8 @@ class PlategaService:
|
|||||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logging.debug("Platega webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
|
||||||
return web.Response(text="ok_canceled")
|
return web.Response(text="ok_canceled")
|
||||||
|
|
||||||
logging.warning("Platega webhook: unhandled status '%s' for transaction %s", status, transaction_id)
|
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 "")
|
provider_payment_id = str(data.get("id") or data.get("uid") or "")
|
||||||
order_id_raw = data.get("order_id")
|
order_id_raw = data.get("order_id")
|
||||||
status = str(data.get("status") or "").lower()
|
status = str(data.get("status") or "").lower()
|
||||||
|
amount_raw = data.get("amount")
|
||||||
|
currency_raw = data.get("currency")
|
||||||
|
|
||||||
payment_db_id: Optional[int] = None
|
payment_db_id: Optional[int] = None
|
||||||
try:
|
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)
|
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)
|
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
|
payment_months = payment.subscription_duration_months or 1
|
||||||
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
sale_mode = "traffic" if self.settings.traffic_sale_mode else "subscription"
|
||||||
if status == "success":
|
if status == "success":
|
||||||
try:
|
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,
|
session,
|
||||||
payment.payment_id,
|
payment.payment_id,
|
||||||
provider_payment_id or str(payment.payment_id),
|
provider_id,
|
||||||
"succeeded",
|
|
||||||
)
|
)
|
||||||
|
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(
|
activation = await self.subscription_service.activate_subscription(
|
||||||
session,
|
session,
|
||||||
@@ -402,8 +446,8 @@ class SeverPayService:
|
|||||||
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
_ = lambda k, **kw: self.i18n.gettext(lang, k, **kw) if self.i18n else k
|
||||||
try:
|
try:
|
||||||
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
await self.bot.send_message(payment.user_id, _("payment_failed"))
|
||||||
except Exception:
|
except Exception as exc:
|
||||||
pass
|
logging.debug("SeverPay webhook: failed to send cancellation message to user %s: %s", payment.user_id, exc)
|
||||||
return web.json_response({"status": True})
|
return web.json_response({"status": True})
|
||||||
|
|
||||||
if status in {"process", "new"}:
|
if status in {"process", "new"}:
|
||||||
|
|||||||
+34
-2
@@ -5,7 +5,7 @@ from sqlalchemy.future import select
|
|||||||
from sqlalchemy import update, func, and_
|
from sqlalchemy import update, func, and_
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
|
|
||||||
from db.models import Payment, User
|
from db.models import Payment
|
||||||
|
|
||||||
|
|
||||||
async def create_payment_record(session: AsyncSession,
|
async def create_payment_record(session: AsyncSession,
|
||||||
@@ -176,6 +176,38 @@ async def update_provider_payment_and_status(
|
|||||||
return payment
|
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(
|
async def update_payment_discount_info(
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
payment_db_id: int,
|
payment_db_id: int,
|
||||||
@@ -205,7 +237,7 @@ async def update_payment_discount_info(
|
|||||||
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
async def get_financial_statistics(session: AsyncSession) -> Dict[str, Any]:
|
||||||
"""Get comprehensive financial statistics."""
|
"""Get comprehensive financial statistics."""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from sqlalchemy import and_, text
|
from sqlalchemy import and_
|
||||||
|
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|||||||
Reference in New Issue
Block a user