Add a new function `resolve_fiat_offer_price_for_user` to determine the correct offer price for users based on their subscription choices and any applicable discounts. Update payment handlers for CryptoPay, FreeKassa, Platega, SeverPay, and YooKassa to utilize this function, ensuring callback price validation and improving error handling for price mismatches. This enhances security by preventing callback payload tampering and ensures accurate pricing for users.
969 lines
40 KiB
Python
969 lines
40 KiB
Python
import logging
|
|
from typing import List, Optional, Tuple
|
|
|
|
from aiogram import F, Router, types
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from bot.keyboards.inline.user_keyboards import (
|
|
get_back_to_main_menu_markup,
|
|
get_payment_url_keyboard,
|
|
get_yk_autopay_choice_keyboard,
|
|
get_yk_saved_cards_keyboard,
|
|
)
|
|
from bot.middlewares.i18n import JsonI18n
|
|
from bot.services.yookassa_service import YooKassaService
|
|
from config.settings import Settings
|
|
from db.dal import payment_dal, user_billing_dal, active_discount_dal
|
|
|
|
router = Router(name="user_subscription_payments_yookassa_router")
|
|
|
|
|
|
from bot.handlers.user.subscription.payments_subscription import resolve_fiat_offer_price_for_user
|
|
|
|
def _format_value(val: float) -> str:
|
|
return str(int(val)) if float(val).is_integer() else f"{val:g}"
|
|
|
|
|
|
def _parse_offer_payload(payload: str) -> Optional[Tuple[float, float, str]]:
|
|
try:
|
|
parts = payload.split(":")
|
|
value = float(parts[0])
|
|
price = float(parts[1])
|
|
sale_mode = parts[2] if len(parts) > 2 else "subscription"
|
|
return value, price, sale_mode
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
|
|
def _format_saved_payment_method_title(get_text, network: Optional[str], last4: Optional[str], is_default: bool) -> str:
|
|
def _is_yoomoney_network(name: Optional[str]) -> bool:
|
|
s = (name or "").lower()
|
|
return "yoomoney" in s or "yoo money" in s or "yoo-money" in s
|
|
|
|
def _extract_last4(text: str) -> Optional[str]:
|
|
digits = "".join(ch for ch in text if ch.isdigit())
|
|
return digits[-4:] if len(digits) >= 4 else None
|
|
|
|
if _is_yoomoney_network(network):
|
|
inferred_last4 = last4 or (_extract_last4(network or "") or "****")
|
|
title = get_text("payment_method_wallet_title", last4=inferred_last4)
|
|
elif last4:
|
|
network_name = network or get_text("payment_network_card")
|
|
title = get_text("payment_method_card_title", network=network_name, last4=last4)
|
|
else:
|
|
network_name = network or get_text("payment_network_generic")
|
|
title = get_text("payment_method_generic_title", network=network_name)
|
|
return f"⭐ {title}" if is_default else title
|
|
|
|
|
|
async def _initiate_yk_payment(
|
|
callback: types.CallbackQuery,
|
|
*,
|
|
settings: Settings,
|
|
session: AsyncSession,
|
|
yookassa_service: YooKassaService,
|
|
promo_code_service, # NEW: Added promo_code_service
|
|
i18n: Optional[JsonI18n],
|
|
current_lang: str,
|
|
get_text,
|
|
user_id: int,
|
|
months: int,
|
|
price_rub: float,
|
|
currency_code_for_yk: str,
|
|
save_payment_method: bool,
|
|
back_callback: str,
|
|
payment_method_id: Optional[str] = None,
|
|
selected_method_internal_id: Optional[int] = None,
|
|
sale_mode: str = "subscription",
|
|
) -> bool:
|
|
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
|
if not callback.message:
|
|
return False
|
|
|
|
# Check for active discount to save metadata (price already discounted from previous step)
|
|
original_price = None
|
|
discount_amount = None
|
|
active_promo_code_id = None
|
|
|
|
if promo_code_service:
|
|
active_discount = await active_discount_dal.get_active_discount(session, user_id)
|
|
if active_discount:
|
|
# Price is already discounted, calculate original price backwards
|
|
discount_pct = active_discount.discount_percentage
|
|
active_promo_code_id = active_discount.promo_code_id
|
|
denominator = 1 - discount_pct / 100
|
|
if denominator <= 0:
|
|
price_source = (
|
|
getattr(settings, "traffic_packages", {}) or {}
|
|
if sale_mode == "traffic"
|
|
else (settings.subscription_options or {})
|
|
)
|
|
fallback_original = price_source.get(months)
|
|
if fallback_original is not None:
|
|
original_price = fallback_original
|
|
discount_amount = original_price - price_rub
|
|
logging.info(
|
|
f"Recording {discount_pct}% discount for YooKassa payment: "
|
|
f"original {original_price:.2f} -> final {price_rub}"
|
|
)
|
|
else:
|
|
logging.warning(
|
|
"YooKassa discount %s%% has invalid denominator and no fallback price for months=%s.",
|
|
discount_pct,
|
|
months,
|
|
)
|
|
else:
|
|
original_price = price_rub / denominator
|
|
discount_amount = original_price - price_rub
|
|
logging.info(
|
|
f"Recording {discount_pct}% discount for YooKassa payment: "
|
|
f"original {original_price:.2f} -> final {price_rub}"
|
|
)
|
|
|
|
payment_description = (
|
|
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
|
if sale_mode == "traffic"
|
|
else get_text("payment_description_subscription", months=int(months))
|
|
)
|
|
payment_record_data = {
|
|
"user_id": user_id,
|
|
"amount": price_rub, # Discounted amount
|
|
"original_amount": original_price if discount_amount else None, # NEW
|
|
"discount_applied": discount_amount, # NEW
|
|
"currency": currency_code_for_yk,
|
|
"status": "pending_yookassa",
|
|
"description": payment_description,
|
|
"subscription_duration_months": int(months),
|
|
"promo_code_id": active_promo_code_id, # NEW: Link to promo code
|
|
}
|
|
|
|
db_payment_record = None
|
|
try:
|
|
db_payment_record = await payment_dal.create_payment_record(session, payment_record_data)
|
|
await session.commit()
|
|
logging.info(
|
|
f"Payment record {db_payment_record.payment_id} created for user {user_id} with status 'pending_yookassa'."
|
|
)
|
|
except Exception as e_db_payment:
|
|
await session.rollback()
|
|
logging.error(
|
|
f"Failed to create payment record in DB for user {user_id}: {e_db_payment}",
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return False
|
|
|
|
if not db_payment_record:
|
|
try:
|
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return False
|
|
|
|
yookassa_metadata = {
|
|
"user_id": str(user_id),
|
|
"subscription_months": str(months),
|
|
"payment_db_id": str(db_payment_record.payment_id),
|
|
"sale_mode": sale_mode,
|
|
}
|
|
if sale_mode == "traffic":
|
|
yookassa_metadata["traffic_gb"] = str(months)
|
|
if payment_method_id:
|
|
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
|
|
|
receipt_email_for_yk = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
|
|
|
|
payment_response_yk = await yookassa_service.create_payment(
|
|
amount=price_rub,
|
|
currency=currency_code_for_yk,
|
|
description=payment_description,
|
|
metadata=yookassa_metadata,
|
|
receipt_email=receipt_email_for_yk,
|
|
save_payment_method=save_payment_method,
|
|
payment_method_id=payment_method_id,
|
|
)
|
|
|
|
if payment_response_yk and payment_response_yk.get("confirmation_url"):
|
|
pm = payment_response_yk.get("payment_method")
|
|
try:
|
|
if pm and pm.get("id"):
|
|
pm_type = pm.get("type")
|
|
title = pm.get("title")
|
|
card = pm.get("card") or {}
|
|
account_number = pm.get("account_number") or pm.get("account")
|
|
if isinstance(card, dict) and (pm_type or "").lower() in {"bank_card", "bank-card", "card"}:
|
|
display_network = card.get("card_type") or title or "Card"
|
|
display_last4 = card.get("last4")
|
|
elif (pm_type or "").lower() in {"yoo_money", "yoomoney", "yoo-money", "wallet"}:
|
|
display_network = "YooMoney"
|
|
display_last4 = (
|
|
account_number[-4:]
|
|
if isinstance(account_number, str) and len(account_number) >= 4
|
|
else None
|
|
)
|
|
else:
|
|
display_network = title or (pm_type.upper() if pm_type else "Payment method")
|
|
display_last4 = None
|
|
await user_billing_dal.upsert_yk_payment_method(
|
|
session,
|
|
user_id=user_id,
|
|
payment_method_id=pm["id"],
|
|
card_last4=display_last4,
|
|
card_network=display_network,
|
|
)
|
|
try:
|
|
await user_billing_dal.upsert_user_payment_method(
|
|
session,
|
|
user_id=user_id,
|
|
provider_payment_method_id=pm["id"],
|
|
provider="yookassa",
|
|
card_last4=display_last4,
|
|
card_network=display_network,
|
|
set_default=save_payment_method,
|
|
)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
logging.exception("Failed to save YooKassa payment method preliminarily")
|
|
try:
|
|
await payment_dal.update_payment_status_by_db_id(
|
|
session,
|
|
payment_db_id=db_payment_record.payment_id,
|
|
new_status=payment_response_yk.get("status", "pending"),
|
|
yk_payment_id=payment_response_yk.get("id"),
|
|
)
|
|
if selected_method_internal_id is not None:
|
|
try:
|
|
await user_billing_dal.set_user_default_payment_method(
|
|
session, user_id, selected_method_internal_id
|
|
)
|
|
except Exception:
|
|
logging.exception("Failed to set default payment method after initiating payment")
|
|
await session.commit()
|
|
except Exception as e_db_update_ykid:
|
|
await session.rollback()
|
|
logging.error(
|
|
f"Failed to update payment record {db_payment_record.payment_id} with YK ID: {e_db_update_ykid}",
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return False
|
|
|
|
try:
|
|
await callback.message.edit_text(
|
|
get_text(
|
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
|
months=int(months),
|
|
traffic_gb=_format_value(months),
|
|
),
|
|
reply_markup=get_payment_url_keyboard(
|
|
payment_response_yk["confirmation_url"],
|
|
current_lang,
|
|
i18n,
|
|
back_callback=back_callback,
|
|
back_text_key="back_to_payment_methods_button",
|
|
),
|
|
disable_web_page_preview=False,
|
|
)
|
|
except Exception as e_edit:
|
|
logging.warning(
|
|
f"Edit message for payment link failed: {e_edit}. Sending new one."
|
|
)
|
|
try:
|
|
await callback.message.answer(
|
|
get_text(
|
|
key="payment_link_message_traffic" if sale_mode == "traffic" else "payment_link_message",
|
|
months=int(months),
|
|
traffic_gb=_format_value(months),
|
|
),
|
|
reply_markup=get_payment_url_keyboard(
|
|
payment_response_yk["confirmation_url"],
|
|
current_lang,
|
|
i18n,
|
|
back_callback=back_callback,
|
|
back_text_key="back_to_payment_methods_button",
|
|
),
|
|
disable_web_page_preview=False,
|
|
)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return True
|
|
|
|
if payment_response_yk and payment_method_id:
|
|
status_to_store = payment_response_yk.get("status", "pending")
|
|
try:
|
|
await payment_dal.update_payment_status_by_db_id(
|
|
session,
|
|
payment_db_id=db_payment_record.payment_id,
|
|
new_status=status_to_store,
|
|
yk_payment_id=payment_response_yk.get("id"),
|
|
)
|
|
if selected_method_internal_id is not None:
|
|
try:
|
|
await user_billing_dal.set_user_default_payment_method(
|
|
session, user_id, selected_method_internal_id
|
|
)
|
|
except Exception:
|
|
logging.exception("Failed to set default payment method after saved-card payment start")
|
|
await session.commit()
|
|
except Exception as e_db_update_saved:
|
|
await session.rollback()
|
|
logging.error(
|
|
f"Failed to update saved-card payment record {db_payment_record.payment_id}: {e_db_update_saved}",
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return False
|
|
|
|
message_text = get_text("yookassa_autopay_charge_initiated")
|
|
try:
|
|
await callback.message.edit_text(
|
|
message_text,
|
|
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
|
)
|
|
except Exception as e_edit:
|
|
logging.warning(f"Failed to notify about saved-card charge start: {e_edit}")
|
|
try:
|
|
await callback.message.answer(
|
|
message_text,
|
|
reply_markup=get_back_to_main_menu_markup(current_lang, i18n),
|
|
)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return True
|
|
|
|
try:
|
|
await payment_dal.update_payment_status_by_db_id(
|
|
session, db_payment_record.payment_id, "failed_creation"
|
|
)
|
|
await session.commit()
|
|
except Exception as e_db_fail_create:
|
|
await session.rollback()
|
|
logging.error(
|
|
f"Additionally failed to update payment record to 'failed_creation': {e_db_fail_create}",
|
|
exc_info=True,
|
|
)
|
|
logging.error(
|
|
f"Failed to create payment in YooKassa for user {user_id}, payment_db_id {db_payment_record.payment_id}. Response: {payment_response_yk}"
|
|
)
|
|
try:
|
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return False
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pay_yk:"))
|
|
async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
if not i18n or not callback.message:
|
|
try:
|
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
if not yookassa_service or not yookassa_service.configured:
|
|
logging.error("YooKassa service is not configured or unavailable.")
|
|
target_msg_edit = callback.message
|
|
await target_msg_edit.edit_text(get_text("payment_service_unavailable"))
|
|
try:
|
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
try:
|
|
_, data_payload = callback.data.split(":", 1)
|
|
except ValueError:
|
|
logging.error(f"Invalid pay_yk data in callback: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
parsed = _parse_offer_payload(data_payload)
|
|
if not parsed:
|
|
logging.error(f"Invalid pay_yk payload structure: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
months, callback_price_rub, sale_mode = parsed
|
|
user_id = callback.from_user.id
|
|
|
|
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
|
session=session,
|
|
settings=settings,
|
|
user_id=user_id,
|
|
months=months,
|
|
sale_mode=sale_mode,
|
|
promo_code_service=promo_code_service,
|
|
)
|
|
if resolved_price_rub is None:
|
|
logging.warning(
|
|
"YooKassa: no server-side price for user %s, value=%s, mode=%s",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
|
logging.warning(
|
|
"YooKassa: callback price mismatch for user %s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
callback_price_rub,
|
|
resolved_price_rub,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
price_rub = resolved_price_rub
|
|
currency_code_for_yk = "RUB"
|
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
|
autopay_require_binding = bool(
|
|
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
|
)
|
|
saved_methods: List = []
|
|
if autopay_enabled:
|
|
try:
|
|
saved_methods = await user_billing_dal.list_user_payment_methods(
|
|
session, user_id, provider="yookassa"
|
|
)
|
|
except Exception as e_list:
|
|
logging.exception(f"Failed to load saved payment methods for user {user_id}: {e_list}")
|
|
saved_methods = []
|
|
|
|
if autopay_enabled and saved_methods:
|
|
try:
|
|
await callback.message.edit_text(
|
|
get_text("yookassa_autopay_flow_prompt"),
|
|
reply_markup=get_yk_autopay_choice_keyboard(
|
|
months,
|
|
price_rub,
|
|
current_lang,
|
|
i18n,
|
|
has_saved_cards=True,
|
|
sale_mode=sale_mode,
|
|
),
|
|
)
|
|
except Exception as e_edit:
|
|
logging.warning(f"Failed to show autopay choice: {e_edit}. Sending new message.")
|
|
try:
|
|
await callback.message.answer(
|
|
get_text("yookassa_autopay_flow_prompt"),
|
|
reply_markup=get_yk_autopay_choice_keyboard(
|
|
months,
|
|
price_rub,
|
|
current_lang,
|
|
i18n,
|
|
has_saved_cards=True,
|
|
sale_mode=sale_mode,
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
try:
|
|
await callback.answer()
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
await _initiate_yk_payment(
|
|
callback,
|
|
settings=settings,
|
|
session=session,
|
|
yookassa_service=yookassa_service,
|
|
promo_code_service=promo_code_service,
|
|
i18n=i18n,
|
|
current_lang=current_lang,
|
|
get_text=get_text,
|
|
user_id=user_id,
|
|
months=months,
|
|
price_rub=price_rub,
|
|
currency_code_for_yk=currency_code_for_yk,
|
|
save_payment_method=autopay_enabled and autopay_require_binding,
|
|
back_callback=f"subscribe_period:{_format_value(months)}",
|
|
sale_mode=sale_mode,
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pay_yk_new:"))
|
|
async def pay_yk_new_card_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
if not i18n or not callback.message:
|
|
try:
|
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
if not yookassa_service or not yookassa_service.configured:
|
|
logging.error("YooKassa service unavailable for pay_yk_new.")
|
|
try:
|
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
try:
|
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
try:
|
|
_, data_payload = callback.data.split(":", 1)
|
|
except ValueError:
|
|
logging.error(f"Invalid pay_yk_new data in callback: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
parsed = _parse_offer_payload(data_payload)
|
|
if not parsed:
|
|
logging.error(f"Invalid pay_yk_new payload structure: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
months, callback_price_rub, sale_mode = parsed
|
|
user_id = callback.from_user.id
|
|
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
|
session=session,
|
|
settings=settings,
|
|
user_id=user_id,
|
|
months=months,
|
|
sale_mode=sale_mode,
|
|
promo_code_service=promo_code_service,
|
|
)
|
|
if resolved_price_rub is None:
|
|
logging.warning(
|
|
"YooKassa: no server-side price for new-card flow, user=%s, value=%s, mode=%s",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
|
logging.warning(
|
|
"YooKassa: callback price mismatch in new-card flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
callback_price_rub,
|
|
resolved_price_rub,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
price_rub = resolved_price_rub
|
|
currency_code_for_yk = "RUB"
|
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
|
autopay_require_binding = bool(
|
|
getattr(settings, 'YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING', True)
|
|
)
|
|
|
|
await _initiate_yk_payment(
|
|
callback,
|
|
settings=settings,
|
|
session=session,
|
|
yookassa_service=yookassa_service,
|
|
promo_code_service=promo_code_service,
|
|
i18n=i18n,
|
|
current_lang=current_lang,
|
|
get_text=get_text,
|
|
user_id=user_id,
|
|
months=months,
|
|
price_rub=price_rub,
|
|
currency_code_for_yk=currency_code_for_yk,
|
|
save_payment_method=autopay_enabled and autopay_require_binding,
|
|
back_callback=f"subscribe_period:{_format_value(months)}",
|
|
sale_mode=sale_mode,
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pay_yk_saved_list:"))
|
|
async def pay_yk_saved_list_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
if not i18n or not callback.message:
|
|
try:
|
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
try:
|
|
_, data_payload = callback.data.split(":", 1)
|
|
except ValueError:
|
|
logging.error(f"Invalid pay_yk_saved_list data: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
parts = data_payload.split(":")
|
|
if len(parts) < 2:
|
|
logging.error(f"pay_yk_saved_list payload missing components: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
try:
|
|
months = float(parts[0])
|
|
callback_price_rub = float(parts[1])
|
|
page = int(parts[2]) if len(parts) > 2 else 0
|
|
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
|
except (ValueError, IndexError):
|
|
logging.error(f"pay_yk_saved_list payload parsing error: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
|
if not autopay_enabled:
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
user_id = callback.from_user.id
|
|
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
|
session=session,
|
|
settings=settings,
|
|
user_id=user_id,
|
|
months=months,
|
|
sale_mode=sale_mode,
|
|
promo_code_service=promo_code_service,
|
|
)
|
|
if resolved_price_rub is None:
|
|
logging.warning(
|
|
"YooKassa: no server-side price for saved-list flow, user=%s, value=%s, mode=%s",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
|
logging.warning(
|
|
"YooKassa: callback price mismatch in saved-list flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
callback_price_rub,
|
|
resolved_price_rub,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
price_rub = resolved_price_rub
|
|
try:
|
|
saved_methods = await user_billing_dal.list_user_payment_methods(
|
|
session, user_id, provider="yookassa"
|
|
)
|
|
except Exception as e_list:
|
|
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
|
saved_methods = []
|
|
|
|
if not saved_methods:
|
|
try:
|
|
await callback.message.edit_text(
|
|
get_text("yookassa_autopay_no_saved_cards"),
|
|
reply_markup=get_yk_autopay_choice_keyboard(
|
|
months,
|
|
price_rub,
|
|
current_lang,
|
|
i18n,
|
|
has_saved_cards=False,
|
|
sale_mode=sale_mode,
|
|
),
|
|
)
|
|
except Exception as e_edit:
|
|
logging.warning(f"Failed to display no-saved-card notice: {e_edit}")
|
|
try:
|
|
await callback.message.answer(
|
|
get_text("yookassa_autopay_no_saved_cards"),
|
|
reply_markup=get_yk_autopay_choice_keyboard(
|
|
months,
|
|
price_rub,
|
|
current_lang,
|
|
i18n,
|
|
has_saved_cards=False,
|
|
sale_mode=sale_mode,
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
try:
|
|
await callback.answer()
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
cards: List[Tuple[str, str]] = []
|
|
for method in saved_methods:
|
|
title = _format_saved_payment_method_title(
|
|
get_text, method.card_network, method.card_last4, method.is_default
|
|
)
|
|
cards.append((str(method.method_id), title))
|
|
|
|
per_page = 5
|
|
max_page = max(0, (len(cards) - 1) // per_page)
|
|
page = max(0, min(page, max_page))
|
|
|
|
try:
|
|
await callback.message.edit_text(
|
|
get_text("yookassa_autopay_choose_saved_card"),
|
|
reply_markup=get_yk_saved_cards_keyboard(
|
|
cards,
|
|
months,
|
|
price_rub,
|
|
current_lang,
|
|
i18n,
|
|
page=page,
|
|
sale_mode=sale_mode,
|
|
),
|
|
)
|
|
except Exception as e_edit:
|
|
logging.warning(f"Failed to display saved card list: {e_edit}")
|
|
try:
|
|
await callback.message.answer(
|
|
get_text("yookassa_autopay_choose_saved_card"),
|
|
reply_markup=get_yk_saved_cards_keyboard(
|
|
cards,
|
|
months,
|
|
price_rub,
|
|
current_lang,
|
|
i18n,
|
|
page=page,
|
|
sale_mode=sale_mode,
|
|
),
|
|
)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
try:
|
|
await callback.answer()
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pay_yk_use_saved:"))
|
|
async def pay_yk_use_saved_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, yookassa_service: YooKassaService, session: AsyncSession, promo_code_service=None):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
get_text = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
if not i18n or not callback.message:
|
|
try:
|
|
await callback.answer(get_text("error_occurred_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
if not yookassa_service or not yookassa_service.configured:
|
|
logging.error("YooKassa service unavailable for pay_yk_use_saved.")
|
|
try:
|
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
try:
|
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
try:
|
|
_, data_payload = callback.data.split(":", 1)
|
|
except ValueError:
|
|
logging.error(f"Invalid pay_yk_use_saved data: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
parts = data_payload.split(":")
|
|
if len(parts) < 3:
|
|
logging.error(f"pay_yk_use_saved payload missing components: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
try:
|
|
months = float(parts[0])
|
|
callback_price_rub = float(parts[1])
|
|
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
|
except (ValueError, IndexError):
|
|
logging.error(f"pay_yk_use_saved months/price parsing error: {callback.data}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
autopay_enabled = bool(settings.yookassa_autopayments_active and sale_mode != "traffic" and not settings.traffic_sale_mode)
|
|
if not autopay_enabled:
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
method_identifier = parts[2]
|
|
user_id = callback.from_user.id
|
|
resolved_price_rub = await resolve_fiat_offer_price_for_user(
|
|
session=session,
|
|
settings=settings,
|
|
user_id=user_id,
|
|
months=months,
|
|
sale_mode=sale_mode,
|
|
promo_code_service=promo_code_service,
|
|
)
|
|
if resolved_price_rub is None:
|
|
logging.warning(
|
|
"YooKassa: no server-side price for use-saved flow, user=%s, value=%s, mode=%s",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
if abs(resolved_price_rub - callback_price_rub) > 0.01:
|
|
logging.warning(
|
|
"YooKassa: callback price mismatch in use-saved flow, user=%s, value=%s, mode=%s, callback=%.2f, resolved=%.2f",
|
|
user_id,
|
|
months,
|
|
sale_mode,
|
|
callback_price_rub,
|
|
resolved_price_rub,
|
|
)
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
try:
|
|
saved_methods = await user_billing_dal.list_user_payment_methods(
|
|
session, user_id, provider="yookassa"
|
|
)
|
|
except Exception as e_list:
|
|
logging.exception(f"Failed to list saved payment methods for user {user_id}: {e_list}")
|
|
saved_methods = []
|
|
|
|
selected_method = None
|
|
for method in saved_methods:
|
|
if method_identifier.isdigit():
|
|
if method.method_id == int(method_identifier):
|
|
selected_method = method
|
|
break
|
|
if method.provider_payment_method_id == method_identifier:
|
|
selected_method = method
|
|
break
|
|
|
|
if not selected_method:
|
|
logging.warning(f"Selected payment method not found for user {user_id}: {method_identifier}")
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|
|
return
|
|
|
|
price_rub = resolved_price_rub
|
|
currency_code_for_yk = "RUB"
|
|
|
|
await _initiate_yk_payment(
|
|
callback,
|
|
settings=settings,
|
|
session=session,
|
|
yookassa_service=yookassa_service,
|
|
promo_code_service=promo_code_service,
|
|
i18n=i18n,
|
|
current_lang=current_lang,
|
|
get_text=get_text,
|
|
user_id=user_id,
|
|
months=months,
|
|
price_rub=price_rub,
|
|
currency_code_for_yk=currency_code_for_yk,
|
|
save_payment_method=False,
|
|
back_callback=f"pay_yk_saved_list:{_format_value(months)}:{price_rub}:{sale_mode}",
|
|
payment_method_id=selected_method.provider_payment_method_id,
|
|
selected_method_internal_id=selected_method.method_id,
|
|
sale_mode=sale_mode,
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception as exc:
|
|
logging.debug("Suppressed exception in bot/handlers/user/subscription/payments_yookassa.py: %s", exc)
|