3129 lines
124 KiB
Python
3129 lines
124 KiB
Python
import asyncio
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
from aiogram import Bot, F, Router, types
|
|
from aiohttp import web
|
|
from pydantic import Field, field_validator
|
|
from pydantic_settings import SettingsConfigDict
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
from sqlalchemy.future import select
|
|
from sqlalchemy.orm import sessionmaker
|
|
from yookassa import Configuration
|
|
from yookassa import Payment as YooKassaPayment
|
|
from yookassa.domain.common.confirmation_type import ConfirmationType
|
|
from yookassa.domain.notification import WebhookNotification
|
|
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
|
|
|
|
from bot.infra.webhook_queue import enqueue_webhook_event
|
|
from bot.keyboards.inline.user_keyboards import (
|
|
get_back_to_main_menu_markup,
|
|
get_bind_url_keyboard,
|
|
get_payment_method_delete_confirm_keyboard,
|
|
get_payment_method_details_keyboard,
|
|
get_payment_methods_list_keyboard,
|
|
get_payment_url_keyboard,
|
|
get_yk_autopay_choice_keyboard,
|
|
get_yk_saved_cards_keyboard,
|
|
payment_methods_back_callback,
|
|
)
|
|
from bot.middlewares.i18n import JsonI18n
|
|
from bot.services.lknpd_service import LknpdService
|
|
from bot.services.panel_api_service import PanelApiService
|
|
from bot.services.referral_service import ReferralService
|
|
from bot.services.subscription_service import SubscriptionService
|
|
from bot.services.user_email_notifications import send_user_notification_email
|
|
from bot.utils.config_link import prepare_config_links
|
|
from bot.utils.install_links import ensure_user_install_guide_links
|
|
from bot.utils.request_security import ip_in_allowlist, request_client_ip
|
|
from config.settings import Settings
|
|
from config.tariffs_config import (
|
|
default_currency_key_for_settings,
|
|
default_payment_currency_code_for_settings,
|
|
)
|
|
from db.dal import payment_dal, user_billing_dal, user_dal
|
|
from db.models import Payment
|
|
|
|
from .base import (
|
|
PaymentProviderSpec,
|
|
ProviderEnvConfig,
|
|
ProviderManifestField,
|
|
ServiceFactoryContext,
|
|
WebAppPaymentContext,
|
|
normalize_payment_currency_code,
|
|
provider_env_file,
|
|
provider_runtime_enabled,
|
|
)
|
|
from .shared import (
|
|
PaymentCallbackParts,
|
|
SuccessMessage,
|
|
append_hwid_renewal_note,
|
|
build_success_message,
|
|
create_webapp_payment_record,
|
|
decimal_amounts_equal,
|
|
format_human_units,
|
|
format_number_for_payload,
|
|
is_traffic_sale_base,
|
|
make_translator,
|
|
mark_payment_failed_creation,
|
|
notify_admins_payment_received,
|
|
parse_positive_int_units,
|
|
payment_failed,
|
|
payment_link_response,
|
|
payment_record_amounts,
|
|
payment_unavailable,
|
|
quote_hwid_callback_parts,
|
|
resolve_inviter_name,
|
|
send_success_message_to_user,
|
|
)
|
|
from .shared import (
|
|
sale_mode_base as _sale_mode_base,
|
|
)
|
|
from .shared import (
|
|
sale_mode_tariff_key as _sale_mode_tariff_key,
|
|
)
|
|
|
|
|
|
class YooKassaConfig(ProviderEnvConfig):
|
|
model_config = SettingsConfigDict(
|
|
env_file=provider_env_file(),
|
|
env_file_encoding="utf-8",
|
|
env_prefix="YOOKASSA_",
|
|
extra="ignore",
|
|
)
|
|
|
|
ENABLED: bool = Field(default=True)
|
|
SHOP_ID: Optional[str] = None
|
|
SECRET_KEY: Optional[str] = None
|
|
RETURN_URL: Optional[str] = None
|
|
DEFAULT_RECEIPT_EMAIL: Optional[str] = None
|
|
VAT_CODE: int = Field(default=1)
|
|
PAYMENT_MODE: str = Field(default="full_prepayment")
|
|
PAYMENT_SUBJECT: str = Field(default="service")
|
|
AUTOPAYMENTS_ENABLED: bool = Field(default=False)
|
|
AUTOPAYMENTS_REQUIRE_CARD_BINDING: bool = Field(default=True)
|
|
|
|
@field_validator("SHOP_ID", "SECRET_KEY", "RETURN_URL", "DEFAULT_RECEIPT_EMAIL", mode="before")
|
|
@classmethod
|
|
def _strip_optional(cls, v):
|
|
if isinstance(v, str) and not v.strip():
|
|
return None
|
|
return v
|
|
|
|
@property
|
|
def autopayments_active(self) -> bool:
|
|
return bool(self.ENABLED and self.AUTOPAYMENTS_ENABLED)
|
|
|
|
@property
|
|
def yk_receipt_payment_mode(self) -> str:
|
|
return "service" if self.AUTOPAYMENTS_ENABLED else "full_prepayment"
|
|
|
|
@property
|
|
def yk_receipt_payment_subject(self) -> str:
|
|
return "full_payment" if self.AUTOPAYMENTS_ENABLED else "payment"
|
|
|
|
@property
|
|
def webhook_path(self) -> str:
|
|
return "/webhook/yookassa"
|
|
|
|
|
|
class YooKassaPresentation(ProviderEnvConfig):
|
|
model_config = SettingsConfigDict(
|
|
env_file=provider_env_file(),
|
|
env_file_encoding="utf-8",
|
|
env_prefix="PAYMENT_YOOKASSA_",
|
|
extra="ignore",
|
|
)
|
|
|
|
WEBAPP_LABEL_RU: Optional[str] = None
|
|
WEBAPP_LABEL_EN: Optional[str] = None
|
|
WEBAPP_ICON: Optional[str] = None
|
|
TELEGRAM_LABEL_RU: Optional[str] = None
|
|
TELEGRAM_LABEL_EN: Optional[str] = None
|
|
TELEGRAM_EMOJI: Optional[str] = None
|
|
|
|
|
|
class YooKassaService:
|
|
def __init__(
|
|
self,
|
|
shop_id: Optional[str],
|
|
secret_key: Optional[str],
|
|
configured_return_url: Optional[str],
|
|
bot_username_for_default_return: Optional[str] = None,
|
|
settings_obj: Optional[Settings] = None,
|
|
config: Optional[YooKassaConfig] = None,
|
|
subscription_service: Optional[SubscriptionService] = None,
|
|
):
|
|
|
|
self.settings = settings_obj
|
|
self.config = config or YooKassaConfig()
|
|
self.subscription_service = subscription_service
|
|
self._bot_username_for_default_return = bot_username_for_default_return
|
|
self._configured_return_url_override = configured_return_url
|
|
self._sdk_configured_for = (
|
|
None # (shop_id, secret_key) currently loaded into the global SDK
|
|
)
|
|
|
|
if not self.configured:
|
|
if not provider_runtime_enabled(self.config):
|
|
logging.warning(
|
|
"YooKassa is disabled via YOOKASSA_ENABLED flag. Payment functionality will be DISABLED." # noqa: E501
|
|
)
|
|
else:
|
|
logging.warning(
|
|
"YooKassa SHOP_ID or SECRET_KEY not configured in settings. "
|
|
"Payment functionality will be DISABLED."
|
|
)
|
|
logging.info("YooKassa Service effective return_url for payments: %s", self.return_url)
|
|
|
|
@property
|
|
def configured(self) -> bool:
|
|
if not (
|
|
provider_runtime_enabled(self.config) and self.config.SHOP_ID and self.config.SECRET_KEY
|
|
):
|
|
return False
|
|
self._ensure_sdk_configured()
|
|
return self._sdk_configured_for is not None
|
|
|
|
def _ensure_sdk_configured(self) -> None:
|
|
"""Reconfigure the global YooKassa SDK if shop_id/secret_key changed at runtime."""
|
|
shop_id = self.config.SHOP_ID
|
|
secret_key = self.config.SECRET_KEY
|
|
if not shop_id or not secret_key:
|
|
self._sdk_configured_for = None
|
|
return
|
|
if self._sdk_configured_for == (shop_id, secret_key):
|
|
return
|
|
try:
|
|
Configuration.configure(shop_id, secret_key)
|
|
self._sdk_configured_for = (shop_id, secret_key)
|
|
logging.info("YooKassa SDK (re)configured for shop_id: %s...", shop_id[:5])
|
|
except Exception:
|
|
logging.exception("Failed to configure YooKassa SDK.")
|
|
self._sdk_configured_for = None
|
|
|
|
@property
|
|
def return_url(self) -> str:
|
|
url = self._configured_return_url_override or self.config.RETURN_URL
|
|
if url:
|
|
return url
|
|
if self._bot_username_for_default_return:
|
|
return f"https://t.me/{self._bot_username_for_default_return}"
|
|
return "https://example.com/payment_error_no_return_url_configured"
|
|
|
|
async def create_payment(
|
|
self,
|
|
amount: float,
|
|
currency: str,
|
|
description: str,
|
|
metadata: Dict[str, Any],
|
|
receipt_email: Optional[str] = None,
|
|
receipt_phone: Optional[str] = None,
|
|
save_payment_method: bool = False,
|
|
payment_method_id: Optional[str] = None,
|
|
capture: bool = True,
|
|
bind_only: bool = False,
|
|
) -> Optional[Dict[str, Any]]:
|
|
if not self.configured:
|
|
logging.error("YooKassa is not configured. Cannot create payment.")
|
|
return None
|
|
|
|
if not self.settings:
|
|
logging.error(
|
|
"YooKassaService: Settings object not available. Cannot create payment with receipt details." # noqa: E501
|
|
)
|
|
return {
|
|
"error": True,
|
|
"internal_message": "Service settings (Settings object) not initialized.",
|
|
}
|
|
|
|
currency = normalize_payment_currency_code(currency)
|
|
if currency != "RUB":
|
|
logging.error("YooKassa currency %s is not supported by this integration", currency)
|
|
return None
|
|
|
|
customer_contact_for_receipt = {}
|
|
if receipt_email:
|
|
customer_contact_for_receipt["email"] = receipt_email
|
|
elif receipt_phone:
|
|
customer_contact_for_receipt["phone"] = receipt_phone
|
|
elif self.config.DEFAULT_RECEIPT_EMAIL:
|
|
customer_contact_for_receipt["email"] = self.config.DEFAULT_RECEIPT_EMAIL
|
|
else:
|
|
logging.error(
|
|
"CRITICAL: No email/phone for YooKassa receipt provided and YOOKASSA_DEFAULT_RECEIPT_EMAIL is not set." # noqa: E501
|
|
)
|
|
return {
|
|
"error": True,
|
|
"internal_message": "YooKassa receipt customer contact (email/phone) missing and no default email configured.", # noqa: E501
|
|
}
|
|
|
|
try:
|
|
builder = PaymentRequestBuilder()
|
|
builder.set_amount({"value": str(round(amount, 2)), "currency": currency.upper()})
|
|
# For binding cards only, do not capture and set minimal amount
|
|
if bind_only:
|
|
capture = False
|
|
amount = max(amount, 1.00)
|
|
builder.set_capture(capture)
|
|
if not payment_method_id:
|
|
# Saved payment_method_id charges must omit confirmation per YooKassa API
|
|
builder.set_confirmation(
|
|
{"type": ConfirmationType.REDIRECT, "return_url": self.return_url}
|
|
)
|
|
builder.set_description(description)
|
|
builder.set_metadata(metadata)
|
|
if save_payment_method:
|
|
# Ask YooKassa to save method for off-session charges
|
|
builder.set_save_payment_method(True)
|
|
elif not payment_method_id:
|
|
# Keep the Smart Payment form unrestricted for one-off payments.
|
|
builder.set_save_payment_method(False)
|
|
if payment_method_id:
|
|
# Use a previously saved payment method for merchant-initiated payments
|
|
builder.set_payment_method_id(payment_method_id)
|
|
|
|
receipt_items_list: List[Dict[str, Any]] = [
|
|
{
|
|
"description": description[:128],
|
|
"quantity": "1.00",
|
|
"amount": {"value": str(round(amount, 2)), "currency": currency.upper()},
|
|
"vat_code": str(self.config.VAT_CODE),
|
|
"payment_mode": self.config.yk_receipt_payment_mode,
|
|
"payment_subject": self.config.yk_receipt_payment_subject,
|
|
}
|
|
]
|
|
|
|
receipt_data_dict: Dict[str, Any] = {
|
|
"customer": customer_contact_for_receipt,
|
|
"items": receipt_items_list,
|
|
}
|
|
|
|
builder.set_receipt(receipt_data_dict)
|
|
|
|
idempotence_key = str(uuid.uuid4())
|
|
payment_request = builder.build()
|
|
|
|
logging.info(
|
|
f"Creating YooKassa payment (Idempotence-Key: {idempotence_key}). "
|
|
f"Amount: {amount} {currency}. Metadata: {metadata}. Receipt: {receipt_data_dict}"
|
|
)
|
|
|
|
response = await asyncio.to_thread(
|
|
YooKassaPayment.create,
|
|
payment_request,
|
|
idempotence_key,
|
|
)
|
|
|
|
logging.info(
|
|
f"YooKassa Payment.create response: ID={response.id}, Status={response.status}, Paid={response.paid}" # noqa: E501
|
|
)
|
|
|
|
return {
|
|
"id": response.id,
|
|
"confirmation_url": response.confirmation.confirmation_url
|
|
if response.confirmation
|
|
else None,
|
|
"status": response.status,
|
|
"metadata": response.metadata,
|
|
"amount_value": float(response.amount.value),
|
|
"amount_currency": response.amount.currency,
|
|
"idempotence_key_used": idempotence_key,
|
|
"paid": response.paid,
|
|
"refundable": response.refundable,
|
|
"created_at": response.created_at.isoformat()
|
|
if hasattr(response.created_at, "isoformat")
|
|
else str(response.created_at),
|
|
"description_from_yk": response.description,
|
|
"test_mode": response.test if hasattr(response, "test") else None,
|
|
"payment_method": getattr(response, "payment_method", None),
|
|
}
|
|
except Exception:
|
|
logging.exception("YooKassa payment creation failed.")
|
|
return None
|
|
|
|
async def get_payment_info(self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
|
|
if not self.configured:
|
|
logging.error("YooKassa is not configured. Cannot get payment info.")
|
|
return None
|
|
try:
|
|
logging.info(f"Fetching payment info from YooKassa for ID: {payment_id_in_yookassa}")
|
|
|
|
payment_info_yk = await asyncio.to_thread(
|
|
YooKassaPayment.find_one,
|
|
payment_id_in_yookassa,
|
|
)
|
|
|
|
if payment_info_yk:
|
|
logging.info(
|
|
f"YooKassa payment info for {payment_id_in_yookassa}: Status={payment_info_yk.status}, Paid={payment_info_yk.paid}" # noqa: E501
|
|
)
|
|
pm = getattr(payment_info_yk, "payment_method", None)
|
|
pm_payload: Dict[str, Any] = {}
|
|
if pm:
|
|
# Collect common fields, including id and hints for last4
|
|
pm_id = getattr(pm, "id", None)
|
|
pm_type = getattr(pm, "type", None)
|
|
pm_title = getattr(pm, "title", None)
|
|
account_number = getattr(pm, "account_number", None) or getattr(
|
|
pm, "account", None
|
|
)
|
|
card_obj = getattr(pm, "card", None)
|
|
last4_val = None
|
|
if card_obj and hasattr(card_obj, "last4"):
|
|
last4_val = getattr(card_obj, "last4")
|
|
elif isinstance(account_number, str) and len(account_number) >= 4:
|
|
last4_val = account_number[-4:]
|
|
pm_payload = {
|
|
"id": pm_id,
|
|
"type": pm_type,
|
|
"title": pm_title,
|
|
"card_last4": last4_val,
|
|
}
|
|
confirmation = getattr(payment_info_yk, "confirmation", None)
|
|
confirmation_url = (
|
|
getattr(confirmation, "confirmation_url", None) if confirmation else None
|
|
)
|
|
return {
|
|
"id": payment_info_yk.id,
|
|
"status": payment_info_yk.status,
|
|
"paid": payment_info_yk.paid,
|
|
"amount_value": float(payment_info_yk.amount.value),
|
|
"amount_currency": payment_info_yk.amount.currency,
|
|
"metadata": payment_info_yk.metadata,
|
|
"description": payment_info_yk.description,
|
|
"refundable": payment_info_yk.refundable,
|
|
"created_at": payment_info_yk.created_at.isoformat()
|
|
if hasattr(payment_info_yk.created_at, "isoformat")
|
|
else str(payment_info_yk.created_at),
|
|
"captured_at": payment_info_yk.captured_at.isoformat()
|
|
if getattr(payment_info_yk, "captured_at", None)
|
|
and hasattr(payment_info_yk.captured_at, "isoformat")
|
|
else None,
|
|
"payment_method": pm_payload,
|
|
"confirmation_url": confirmation_url,
|
|
"test_mode": getattr(payment_info_yk, "test", None),
|
|
}
|
|
else:
|
|
logging.warning(
|
|
f"No payment info found in YooKassa for ID: {payment_id_in_yookassa}"
|
|
)
|
|
return None
|
|
except Exception:
|
|
logging.exception("YooKassa get payment info for %s failed.", payment_id_in_yookassa)
|
|
return None
|
|
|
|
async def cancel_payment(self, payment_id_in_yookassa: str) -> bool:
|
|
if not self.configured:
|
|
logging.error("YooKassa is not configured. Cannot cancel payment.")
|
|
return False
|
|
try:
|
|
await asyncio.to_thread(YooKassaPayment.cancel, payment_id_in_yookassa)
|
|
logging.info(f"Cancelled YooKassa payment {payment_id_in_yookassa}")
|
|
return True
|
|
except Exception:
|
|
logging.exception("Failed to cancel YooKassa payment %s.", payment_id_in_yookassa)
|
|
return False
|
|
|
|
|
|
payment_processing_lock = asyncio.Lock()
|
|
|
|
YOOKASSA_EVENT_PAYMENT_SUCCEEDED = "payment.succeeded"
|
|
YOOKASSA_EVENT_PAYMENT_CANCELED = "payment.canceled"
|
|
YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE = "payment.waiting_for_capture"
|
|
YOOKASSA_WEBHOOK_ALLOWED_IPS = [
|
|
"185.71.76.0/27",
|
|
"185.71.77.0/27",
|
|
"77.75.153.0/25",
|
|
"77.75.156.11",
|
|
"77.75.156.35",
|
|
"77.75.154.128/25",
|
|
"2a02:5180::/32",
|
|
]
|
|
HWID_DEVICE_SALE_BASES = {"hwid_device", "hwid_devices", "hwid_devices_renewal"}
|
|
|
|
|
|
def _is_hwid_device_sale_base(sale_mode_base: str) -> bool:
|
|
return sale_mode_base in HWID_DEVICE_SALE_BASES
|
|
|
|
|
|
def _metadata_value_present(value: Optional[Any]) -> bool:
|
|
return value is not None and str(value).strip() != ""
|
|
|
|
|
|
def _metadata_int(value: Optional[Any]) -> Optional[int]:
|
|
if not _metadata_value_present(value):
|
|
return None
|
|
try:
|
|
return int(float(str(value).strip()))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _metadata_float(value: Optional[Any]) -> Optional[float]:
|
|
if not _metadata_value_present(value):
|
|
return None
|
|
try:
|
|
return float(str(value).strip())
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _metadata_datetime(value: Optional[Any]) -> Optional[datetime]:
|
|
if not _metadata_value_present(value):
|
|
return None
|
|
try:
|
|
parsed = datetime.fromisoformat(str(value).strip().replace("Z", "+00:00"))
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
return parsed.replace(tzinfo=timezone.utc)
|
|
return parsed
|
|
|
|
|
|
def _resolve_yookassa_activation_amounts(
|
|
*,
|
|
sale_mode_base: str,
|
|
subscription_months_raw: Optional[Any],
|
|
traffic_gb_raw: Optional[Any],
|
|
hwid_devices_raw: Optional[Any],
|
|
) -> tuple[float, float, int, int, Optional[float]]:
|
|
subscription_months = float(subscription_months_raw or 0)
|
|
traffic_amount_gb = (
|
|
float(traffic_gb_raw) if _metadata_value_present(traffic_gb_raw) else subscription_months
|
|
)
|
|
hwid_devices_count = 0
|
|
if _metadata_value_present(hwid_devices_raw):
|
|
parsed_hwid_devices = parse_positive_int_units(hwid_devices_raw)
|
|
if parsed_hwid_devices is None:
|
|
raise ValueError("Invalid HWID device count")
|
|
hwid_devices_count = parsed_hwid_devices
|
|
elif _is_hwid_device_sale_base(sale_mode_base):
|
|
parsed_hwid_devices = parse_positive_int_units(subscription_months_raw)
|
|
if parsed_hwid_devices is None:
|
|
raise ValueError("Invalid HWID device count")
|
|
hwid_devices_count = parsed_hwid_devices
|
|
|
|
if sale_mode_base == "subscription":
|
|
months_for_activation = int(subscription_months)
|
|
elif _is_hwid_device_sale_base(sale_mode_base):
|
|
months_for_activation = hwid_devices_count
|
|
else:
|
|
months_for_activation = int(traffic_amount_gb)
|
|
|
|
traffic_gb_for_activation = traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
|
|
return (
|
|
subscription_months,
|
|
traffic_amount_gb,
|
|
hwid_devices_count,
|
|
months_for_activation,
|
|
traffic_gb_for_activation,
|
|
)
|
|
|
|
|
|
async def process_successful_payment(
|
|
session: AsyncSession,
|
|
bot: Bot,
|
|
payment_info_from_webhook: dict,
|
|
i18n: JsonI18n,
|
|
settings: Settings,
|
|
panel_service: PanelApiService,
|
|
subscription_service: SubscriptionService,
|
|
referral_service: ReferralService,
|
|
lknpd_service: Optional[LknpdService] = None,
|
|
):
|
|
metadata = payment_info_from_webhook.get("metadata", {})
|
|
user_id_str = metadata.get("user_id")
|
|
subscription_months_str = metadata.get("subscription_months")
|
|
traffic_gb_str = metadata.get("traffic_gb")
|
|
hwid_devices_str = metadata.get("hwid_devices")
|
|
sale_mode = metadata.get("sale_mode") or (
|
|
"traffic" if settings.traffic_sale_mode else "subscription"
|
|
)
|
|
sale_mode_base = sale_mode.split("@", 1)[0].split("|", 1)[0]
|
|
promo_code_id_str = metadata.get("promo_code_id")
|
|
payment_db_id_str = metadata.get("payment_db_id")
|
|
auto_renew_subscription_id_str = metadata.get("auto_renew_for_subscription_id")
|
|
|
|
# For auto-renew payments, payment_db_id may be absent. In that case,
|
|
# we will create/ensure a payment record idempotently using provider payment id.
|
|
if (
|
|
not user_id_str
|
|
or not (
|
|
_metadata_value_present(subscription_months_str)
|
|
or _metadata_value_present(traffic_gb_str)
|
|
or _metadata_value_present(hwid_devices_str)
|
|
)
|
|
or (not payment_db_id_str and not auto_renew_subscription_id_str)
|
|
):
|
|
logging.error(
|
|
f"Missing crucial metadata for payment: {payment_info_from_webhook.get('id')}, metadata: {metadata}" # noqa: E501
|
|
)
|
|
return
|
|
|
|
db_user = None
|
|
try:
|
|
user_id = int(user_id_str)
|
|
(
|
|
subscription_months,
|
|
traffic_amount_gb,
|
|
hwid_devices_count,
|
|
months_for_activation,
|
|
traffic_gb_for_activation,
|
|
) = _resolve_yookassa_activation_amounts(
|
|
sale_mode_base=sale_mode_base,
|
|
subscription_months_raw=subscription_months_str,
|
|
traffic_gb_raw=traffic_gb_str,
|
|
hwid_devices_raw=hwid_devices_str,
|
|
)
|
|
payment_db_id = (
|
|
int(payment_db_id_str) if payment_db_id_str and payment_db_id_str.isdigit() else None
|
|
)
|
|
is_auto_renew = bool(
|
|
auto_renew_subscription_id_str
|
|
and not payment_db_id
|
|
and sale_mode_base == "subscription"
|
|
)
|
|
promo_code_id = (
|
|
int(promo_code_id_str) if promo_code_id_str and promo_code_id_str.isdigit() else None
|
|
)
|
|
|
|
amount_data = payment_info_from_webhook.get("amount", {})
|
|
months_for_record = int(subscription_months) if sale_mode_base == "subscription" else 0
|
|
payment_value = float(amount_data.get("value", 0.0))
|
|
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
|
hwid_valid_from = _metadata_datetime(metadata.get("hwid_valid_from"))
|
|
hwid_valid_until = _metadata_datetime(metadata.get("hwid_valid_until"))
|
|
hwid_pricing_period_months = _metadata_int(metadata.get("hwid_pricing_period_months"))
|
|
hwid_proration_ratio = _metadata_float(metadata.get("hwid_proration_ratio"))
|
|
hwid_full_price = _metadata_float(metadata.get("hwid_full_price"))
|
|
|
|
if _is_hwid_device_sale_base(sale_mode_base) and hwid_devices_count <= 0:
|
|
logging.error(
|
|
"YooKassa HWID payment %s has invalid device count in metadata: %s",
|
|
yk_payment_id_from_hook,
|
|
metadata,
|
|
)
|
|
if payment_db_id is not None:
|
|
await payment_dal.update_payment_status_by_db_id(
|
|
session,
|
|
payment_db_id,
|
|
"failed_metadata_error",
|
|
yk_payment_id_from_hook,
|
|
)
|
|
return
|
|
if sale_mode_base == "subscription" and hwid_devices_count > 0:
|
|
if (
|
|
not hwid_valid_from
|
|
or not hwid_valid_until
|
|
or hwid_valid_from >= hwid_valid_until
|
|
or hwid_full_price is None
|
|
):
|
|
logging.error(
|
|
"YooKassa subscription+HWID payment %s has invalid HWID metadata: %s",
|
|
yk_payment_id_from_hook,
|
|
metadata,
|
|
)
|
|
return
|
|
|
|
payment_record = None
|
|
# If this is an auto-renewal (no payment_db_id in metadata), ensure a payment record exists
|
|
if payment_db_id is None and auto_renew_subscription_id_str:
|
|
try:
|
|
if not yk_payment_id_from_hook:
|
|
logging.error(
|
|
"Auto-renew webhook missing YooKassa payment id; cannot ensure payment record." # noqa: E501
|
|
)
|
|
return
|
|
from db.dal import payment_dal as _payment_dal
|
|
|
|
payment_record = await _payment_dal.get_payment_by_provider_payment_id(
|
|
session, yk_payment_id_from_hook
|
|
)
|
|
if not payment_record:
|
|
payment_record = await _payment_dal.ensure_payment_with_provider_id(
|
|
session,
|
|
user_id=user_id,
|
|
amount=payment_value,
|
|
currency=amount_data.get("currency", settings.DEFAULT_CURRENCY_SYMBOL),
|
|
months=months_for_record or 1,
|
|
description=payment_info_from_webhook.get("description")
|
|
or f"Auto-renewal for {months_for_record or subscription_months} months",
|
|
provider="yookassa",
|
|
provider_payment_id=yk_payment_id_from_hook,
|
|
sale_mode=sale_mode,
|
|
tariff_key=_sale_mode_tariff_key(sale_mode),
|
|
purchased_hwid_devices=(
|
|
hwid_devices_count if hwid_devices_count > 0 else None
|
|
),
|
|
hwid_valid_from=hwid_valid_from,
|
|
hwid_valid_until=hwid_valid_until,
|
|
hwid_pricing_period_months=hwid_pricing_period_months,
|
|
hwid_proration_ratio=hwid_proration_ratio,
|
|
hwid_full_price=hwid_full_price,
|
|
)
|
|
payment_db_id = payment_record.payment_id
|
|
except Exception as e_ensure:
|
|
logging.error(
|
|
f"Failed to ensure payment record for auto-renew webhook (YK {payment_info_from_webhook.get('id')}): {e_ensure}", # noqa: E501
|
|
exc_info=True,
|
|
)
|
|
return
|
|
elif payment_db_id is not None:
|
|
payment_record = await payment_dal.get_payment_by_db_id(session, payment_db_id)
|
|
if not payment_record:
|
|
logging.error(
|
|
f"Payment record {payment_db_id} not found for YK ID {yk_payment_id_from_hook}."
|
|
)
|
|
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})." # noqa: E501
|
|
)
|
|
return
|
|
|
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
|
if not db_user:
|
|
logging.error(
|
|
f"User {user_id} not found in DB during successful payment processing for YK ID {payment_info_from_webhook.get('id')}. Payment record {payment_db_id}." # noqa: E501
|
|
)
|
|
|
|
await payment_dal.update_payment_status_by_db_id(
|
|
session, payment_db_id, "failed_user_not_found", payment_info_from_webhook.get("id")
|
|
)
|
|
|
|
return
|
|
|
|
except (TypeError, ValueError) as e:
|
|
logging.error(f"Invalid metadata format for payment processing: {metadata} - {e}")
|
|
|
|
if payment_db_id_str and payment_db_id_str.isdigit():
|
|
try:
|
|
await payment_dal.update_payment_status_by_db_id(
|
|
session,
|
|
int(payment_db_id_str),
|
|
"failed_metadata_error",
|
|
payment_info_from_webhook.get("id"),
|
|
)
|
|
except Exception as e_upd:
|
|
logging.error(f"Failed to update payment status after metadata error: {e_upd}")
|
|
return
|
|
|
|
try:
|
|
yk_payment_id_from_hook = payment_info_from_webhook.get("id")
|
|
payment_before_update = None
|
|
if payment_db_id is not None:
|
|
payment_before_update = await payment_dal.get_payment_by_db_id(
|
|
session,
|
|
payment_db_id,
|
|
)
|
|
should_send_lknpd_receipt = bool(
|
|
lknpd_service
|
|
and lknpd_service.configured
|
|
and payment_info_from_webhook.get("paid") is True
|
|
and payment_info_from_webhook.get("status") == "succeeded"
|
|
and payment_before_update
|
|
and payment_before_update.status != "succeeded"
|
|
)
|
|
# Try to capture and save payment method for future charges if available
|
|
try:
|
|
payment_method = payment_info_from_webhook.get("payment_method")
|
|
if (
|
|
settings.yookassa_autopayments_active
|
|
and isinstance(payment_method, dict)
|
|
and payment_method.get("saved", False)
|
|
):
|
|
pm_id = payment_method.get("id")
|
|
pm_type = payment_method.get("type")
|
|
title = payment_method.get("title")
|
|
card = payment_method.get("card") or {}
|
|
account_number = payment_method.get("account_number") or payment_method.get(
|
|
"account"
|
|
)
|
|
display_network = None
|
|
display_last4 = None
|
|
# Build generic display for various instrument types
|
|
if (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"}:
|
|
# Normalize wallet display name to avoid leaking full account from title
|
|
display_network = "YooMoney"
|
|
if isinstance(account_number, str) and len(account_number) >= 4:
|
|
display_last4 = account_number[-4:]
|
|
else:
|
|
display_last4 = None
|
|
else:
|
|
# Wallets, SBP, etc. — use provided title/type; no last4
|
|
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=True,
|
|
)
|
|
except Exception:
|
|
logging.exception("Failed to persist multi-card YooKassa method from webhook")
|
|
except Exception:
|
|
logging.exception("Failed to persist YooKassa payment method from webhook")
|
|
activation_details = await subscription_service.activate_subscription(
|
|
session,
|
|
user_id,
|
|
months_for_activation,
|
|
payment_value,
|
|
payment_db_id,
|
|
promo_code_id_from_payment=promo_code_id,
|
|
provider="yookassa",
|
|
sale_mode=sale_mode,
|
|
traffic_gb=traffic_gb_for_activation,
|
|
)
|
|
|
|
if not activation_details or (
|
|
sale_mode_base == "subscription" and not activation_details.get("end_date")
|
|
):
|
|
logging.error(
|
|
f"Failed to activate subscription for user {user_id} after payment {yk_payment_id_from_hook}" # noqa: E501
|
|
)
|
|
raise Exception(f"Subscription Error: Failed to activate for user {user_id}")
|
|
|
|
updated_payment_record = await payment_dal.update_payment_status_by_db_id(
|
|
session,
|
|
payment_db_id=payment_db_id,
|
|
new_status=payment_info_from_webhook.get("status", "succeeded"),
|
|
yk_payment_id=yk_payment_id_from_hook,
|
|
)
|
|
if not updated_payment_record:
|
|
logging.error(
|
|
f"Failed to update payment record {payment_db_id} for yk_id {yk_payment_id_from_hook}" # noqa: E501
|
|
)
|
|
raise Exception(f"DB Error: Could not update payment record {payment_db_id}")
|
|
|
|
base_subscription_end_date = activation_details.get("end_date")
|
|
final_end_date_for_user = base_subscription_end_date
|
|
applied_promo_bonus_days = activation_details.get("applied_promo_bonus_days", 0)
|
|
|
|
referral_bonus_info = None
|
|
if sale_mode_base == "subscription":
|
|
referral_bonus_info = await referral_service.apply_referral_bonuses_for_payment(
|
|
session,
|
|
user_id,
|
|
months_for_activation or int(subscription_months) or 1,
|
|
current_payment_db_id=payment_db_id,
|
|
skip_if_active_before_payment=False,
|
|
tariff_key=_sale_mode_tariff_key(sale_mode),
|
|
)
|
|
applied_referee_bonus_days_from_referral: Optional[int] = None
|
|
if referral_bonus_info and referral_bonus_info.get("referee_new_end_date"):
|
|
final_end_date_for_user = referral_bonus_info["referee_new_end_date"]
|
|
applied_referee_bonus_days_from_referral = referral_bonus_info.get(
|
|
"referee_bonus_applied_days"
|
|
)
|
|
|
|
# Use user's DB language for all user-facing messages
|
|
user_lang = (
|
|
db_user.language_code
|
|
if db_user and db_user.language_code
|
|
else settings.DEFAULT_LANGUAGE
|
|
)
|
|
translator = make_translator(i18n, user_lang)
|
|
_ = translator
|
|
|
|
traffic_label = format_human_units(traffic_amount_gb)
|
|
if should_send_lknpd_receipt:
|
|
receipt_item_name = payment_info_from_webhook.get("description")
|
|
if not receipt_item_name:
|
|
if is_traffic_sale_base(sale_mode_base):
|
|
receipt_item_name = settings.LKNPD_RECEIPT_NAME_TRAFFIC.format(gb=traffic_label)
|
|
elif _is_hwid_device_sale_base(sale_mode_base):
|
|
receipt_item_name = _(
|
|
"payment_description_hwid_devices",
|
|
count=hwid_devices_count,
|
|
)
|
|
else:
|
|
receipt_item_name = settings.LKNPD_RECEIPT_NAME_SUBSCRIPTION.format(
|
|
months=int(subscription_months)
|
|
)
|
|
try:
|
|
await lknpd_service.create_income_receipt(
|
|
item_name=receipt_item_name,
|
|
amount=payment_value,
|
|
quantity=1.0,
|
|
operation_time=datetime.now(timezone.utc),
|
|
)
|
|
except Exception:
|
|
logging.exception(
|
|
"Failed to send LKNPD receipt for payment %s",
|
|
yk_payment_id_from_hook,
|
|
)
|
|
config_link_display, connect_button_url = await prepare_config_links(
|
|
settings, activation_details.get("subscription_url") if activation_details else None
|
|
)
|
|
# Auto-renew charges show a concise message and skip the connect keyboard, so
|
|
# they bypass the shared success-message builder.
|
|
if sale_mode_base == "subscription" and is_auto_renew and final_end_date_for_user:
|
|
details_message = _(
|
|
"yookassa_auto_renewal",
|
|
months=int(subscription_months),
|
|
end_date=final_end_date_for_user.strftime("%Y-%m-%d"),
|
|
)
|
|
include_keyboard = False
|
|
elif (
|
|
sale_mode_base == "subscription"
|
|
and not final_end_date_for_user
|
|
and not is_traffic_sale_base(sale_mode_base)
|
|
):
|
|
logging.error(
|
|
f"Critical error: final_end_date_for_user is None for user {user_id} after successful payment logic." # noqa: E501
|
|
)
|
|
details_message = _("payment_successful_error_details")
|
|
include_keyboard = True
|
|
else:
|
|
inviter_name = None
|
|
if applied_referee_bonus_days_from_referral and final_end_date_for_user:
|
|
inviter_name = await resolve_inviter_name(session, translator, db_user)
|
|
details_message = build_success_message(
|
|
SuccessMessage(
|
|
translator=translator,
|
|
sale_mode=sale_mode,
|
|
months=(
|
|
traffic_label
|
|
if is_traffic_sale_base(sale_mode_base)
|
|
else (
|
|
hwid_devices_count
|
|
if _is_hwid_device_sale_base(sale_mode_base)
|
|
else int(subscription_months)
|
|
)
|
|
),
|
|
base_end_date=base_subscription_end_date,
|
|
final_end_date=final_end_date_for_user,
|
|
applied_referee_bonus_days=applied_referee_bonus_days_from_referral or 0,
|
|
applied_promo_bonus_days=applied_promo_bonus_days,
|
|
inviter_name=inviter_name,
|
|
fallback_date_text="—",
|
|
)
|
|
)
|
|
include_keyboard = True
|
|
|
|
if sale_mode_base == "subscription" and activation_details:
|
|
details_message = append_hwid_renewal_note(
|
|
details_message,
|
|
translator,
|
|
count=activation_details.get("hwid_devices_renewal_recommended_count"),
|
|
valid_until=activation_details.get("hwid_devices_valid_until"),
|
|
)
|
|
|
|
install_share_url = None
|
|
if include_keyboard:
|
|
install_links = await ensure_user_install_guide_links(session, settings, user_id)
|
|
install_share_url = install_links.public_share_url
|
|
await send_success_message_to_user(
|
|
bot=bot,
|
|
user_id=user_id,
|
|
text=details_message,
|
|
language=user_lang,
|
|
i18n=i18n,
|
|
settings=settings,
|
|
config_link_display=config_link_display,
|
|
connect_button_url=connect_button_url,
|
|
install_share_url=install_share_url,
|
|
include_keyboard=include_keyboard,
|
|
log_prefix="YooKassa webhook",
|
|
)
|
|
|
|
tariff_for_log = None
|
|
for source in (payment_before_update, updated_payment_record, payment_record):
|
|
if source is not None and getattr(source, "tariff_key", None):
|
|
tariff_for_log = source.tariff_key
|
|
break
|
|
user_for_notify = db_user or await user_dal.get_user_by_id(session, user_id)
|
|
await notify_admins_payment_received(
|
|
bot=bot,
|
|
settings=settings,
|
|
i18n=i18n,
|
|
user_id=user_id,
|
|
amount=payment_value,
|
|
currency=amount_data.get(
|
|
"currency",
|
|
default_payment_currency_code_for_settings(settings),
|
|
),
|
|
months_for_admin=int(subscription_months) if sale_mode_base == "subscription" else 0,
|
|
traffic_gb_for_admin=(
|
|
traffic_amount_gb if is_traffic_sale_base(sale_mode_base) else None
|
|
),
|
|
payment_provider="yookassa",
|
|
username=user_for_notify.username if user_for_notify else None,
|
|
email=getattr(user_for_notify, "email", None) if user_for_notify else None,
|
|
traffic_is_premium=sale_mode_base == "premium_topup",
|
|
tariff_key=tariff_for_log,
|
|
log_prefix="YooKassa webhook",
|
|
)
|
|
|
|
except Exception as e_process:
|
|
logging.error(
|
|
f"Error during process_successful_payment main try block for user {user_id}: {e_process}", # noqa: E501
|
|
exc_info=True,
|
|
)
|
|
|
|
raise
|
|
|
|
|
|
async def process_cancelled_payment(
|
|
session: AsyncSession,
|
|
bot: Bot,
|
|
payment_info_from_webhook: dict,
|
|
i18n: JsonI18n,
|
|
settings: Settings,
|
|
):
|
|
|
|
metadata = payment_info_from_webhook.get("metadata", {})
|
|
user_id_str = metadata.get("user_id")
|
|
payment_db_id_str = metadata.get("payment_db_id")
|
|
|
|
if not user_id_str or not payment_db_id_str:
|
|
logging.warning(
|
|
f"Missing metadata in cancelled payment webhook: {payment_info_from_webhook.get('id')}"
|
|
)
|
|
return
|
|
try:
|
|
user_id = int(user_id_str)
|
|
payment_db_id = int(payment_db_id_str)
|
|
except ValueError:
|
|
logging.error(f"Invalid metadata in cancelled payment webhook: {metadata}")
|
|
return
|
|
|
|
try:
|
|
updated_payment = await payment_dal.update_payment_status_by_db_id(
|
|
session,
|
|
payment_db_id=payment_db_id,
|
|
new_status=payment_info_from_webhook.get("status", "canceled"),
|
|
yk_payment_id=payment_info_from_webhook.get("id"),
|
|
)
|
|
|
|
if updated_payment:
|
|
logging.info(
|
|
f"Payment {payment_db_id} (YK: {payment_info_from_webhook.get('id')}) status updated to cancelled for user {user_id}." # noqa: E501
|
|
)
|
|
else:
|
|
logging.warning(
|
|
f"Could not find payment record {payment_db_id} to update status to cancelled for user {user_id}." # noqa: E501
|
|
)
|
|
|
|
db_user = await user_dal.get_user_by_id(session, user_id)
|
|
user_lang = settings.DEFAULT_LANGUAGE
|
|
if db_user and db_user.language_code:
|
|
user_lang = db_user.language_code
|
|
|
|
_ = lambda key, **kwargs: i18n.gettext(user_lang, key, **kwargs)
|
|
message_text = _("payment_failed")
|
|
try:
|
|
await bot.send_message(user_id, message_text)
|
|
except Exception:
|
|
logging.exception("Failed to notify YooKassa user %s about cancelled payment.", user_id)
|
|
if db_user:
|
|
await send_user_notification_email(
|
|
settings=settings,
|
|
i18n=i18n,
|
|
user=db_user,
|
|
subject_key="email_payment_failed_subject",
|
|
message_text=message_text,
|
|
dashboard_url=(settings.SUBSCRIPTION_MINI_APP_URL or None),
|
|
)
|
|
|
|
except Exception as e_process_cancel:
|
|
logging.error(
|
|
f"Error processing cancelled payment for user {user_id}, payment_db_id {payment_db_id}: {e_process_cancel}", # noqa: E501
|
|
exc_info=True,
|
|
)
|
|
raise
|
|
|
|
|
|
async def yookassa_webhook_route(request: web.Request):
|
|
|
|
try:
|
|
bot: Bot = request.app["bot"]
|
|
i18n_instance: JsonI18n = request.app["i18n"]
|
|
settings: Settings = request.app["settings"]
|
|
panel_service: PanelApiService = request.app["panel_service"]
|
|
subscription_service: SubscriptionService = request.app["subscription_service"]
|
|
referral_service: ReferralService = request.app["referral_service"]
|
|
lknpd_service: Optional[LknpdService] = request.app.get("lknpd_service")
|
|
async_session_factory: sessionmaker = request.app["async_session_factory"]
|
|
except KeyError:
|
|
logging.exception("KeyError accessing app context in yookassa_webhook_route.")
|
|
return web.Response(status=500, text="Internal Server Error: Missing app context component")
|
|
|
|
client_ip = request_client_ip(request, trusted_proxies=settings.trusted_proxies)
|
|
if not ip_in_allowlist(client_ip, YOOKASSA_WEBHOOK_ALLOWED_IPS):
|
|
logging.warning(
|
|
"YooKassa webhook denied from unauthorized IP source "
|
|
"(client_ip=%s remote=%s x_forwarded_for=%s trusted_ips=%s trusted_proxies=%s).",
|
|
client_ip,
|
|
request.remote,
|
|
request.headers.get("X-Forwarded-For"),
|
|
YOOKASSA_WEBHOOK_ALLOWED_IPS,
|
|
settings.trusted_proxies,
|
|
)
|
|
return web.Response(status=403)
|
|
|
|
try:
|
|
event_json = await request.json()
|
|
|
|
notification_object = WebhookNotification(event_json)
|
|
payment_data_from_notification = notification_object.object
|
|
|
|
logging.info(
|
|
f"YooKassa Webhook Parsed: Event='{notification_object.event}', "
|
|
f"PaymentId='{payment_data_from_notification.id}', Status='{payment_data_from_notification.status}'" # noqa: E501
|
|
)
|
|
|
|
if (
|
|
not payment_data_from_notification
|
|
or not hasattr(payment_data_from_notification, "metadata")
|
|
or payment_data_from_notification.metadata is None
|
|
):
|
|
logging.error(
|
|
f"YooKassa webhook payment {payment_data_from_notification.id} lacks metadata. Cannot process." # noqa: E501
|
|
)
|
|
return web.Response(status=200, text="ok_error_no_metadata")
|
|
|
|
# Safely extract payment_method details (SDK objects may not have to_dict)
|
|
pm_obj = getattr(payment_data_from_notification, "payment_method", None)
|
|
pm_dict = None
|
|
if pm_obj is not None:
|
|
try:
|
|
card_obj = getattr(pm_obj, "card", None)
|
|
pm_dict = {
|
|
"id": getattr(pm_obj, "id", None),
|
|
"type": getattr(pm_obj, "type", None),
|
|
"saved": bool(getattr(pm_obj, "saved", False)),
|
|
"title": getattr(pm_obj, "title", None),
|
|
"account_number": (
|
|
getattr(pm_obj, "account_number", None)
|
|
if hasattr(pm_obj, "account_number")
|
|
else (
|
|
getattr(pm_obj, "account", None) if hasattr(pm_obj, "account") else None
|
|
)
|
|
),
|
|
"card": (
|
|
{
|
|
"first6": getattr(card_obj, "first6", None),
|
|
"last4": getattr(card_obj, "last4", None),
|
|
"expiry_month": getattr(card_obj, "expiry_month", None),
|
|
"expiry_year": getattr(card_obj, "expiry_year", None),
|
|
"card_type": getattr(card_obj, "card_type", None),
|
|
}
|
|
if card_obj is not None
|
|
else None
|
|
),
|
|
}
|
|
except Exception:
|
|
logging.exception("Failed to serialize YooKassa payment_method from webhook")
|
|
pm_dict = None
|
|
|
|
payment_dict_for_processing = {
|
|
"id": str(payment_data_from_notification.id),
|
|
"status": str(payment_data_from_notification.status),
|
|
"paid": bool(payment_data_from_notification.paid),
|
|
"amount": {
|
|
"value": str(payment_data_from_notification.amount.value),
|
|
"currency": str(payment_data_from_notification.amount.currency),
|
|
}
|
|
if payment_data_from_notification.amount
|
|
else {},
|
|
"metadata": dict(payment_data_from_notification.metadata),
|
|
"description": str(payment_data_from_notification.description)
|
|
if payment_data_from_notification.description
|
|
else None,
|
|
"payment_method": pm_dict,
|
|
}
|
|
|
|
if notification_object.event in {
|
|
YOOKASSA_EVENT_PAYMENT_SUCCEEDED,
|
|
YOOKASSA_EVENT_PAYMENT_CANCELED,
|
|
}:
|
|
queued = await enqueue_webhook_event(
|
|
settings,
|
|
"yookassa",
|
|
{
|
|
"event": notification_object.event,
|
|
"payment": payment_dict_for_processing,
|
|
},
|
|
event_id=f"{notification_object.event}:{payment_dict_for_processing.get('id')}",
|
|
)
|
|
if queued:
|
|
return web.Response(status=200, text="queued")
|
|
|
|
async with payment_processing_lock:
|
|
async with async_session_factory() as session:
|
|
try:
|
|
if notification_object.event == YOOKASSA_EVENT_PAYMENT_SUCCEEDED:
|
|
if (
|
|
payment_dict_for_processing.get("paid")
|
|
and payment_dict_for_processing.get("status") == "succeeded"
|
|
):
|
|
await process_successful_payment(
|
|
session,
|
|
bot,
|
|
payment_dict_for_processing,
|
|
i18n_instance,
|
|
settings,
|
|
panel_service,
|
|
subscription_service,
|
|
referral_service,
|
|
lknpd_service,
|
|
)
|
|
await session.commit()
|
|
else:
|
|
logging.warning(
|
|
f"Payment Succeeded event for {payment_dict_for_processing.get('id')} " # noqa: E501
|
|
f"but data not as expected: status='{payment_dict_for_processing.get('status')}', " # noqa: E501
|
|
f"paid='{payment_dict_for_processing.get('paid')}'"
|
|
)
|
|
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_CANCELED:
|
|
await process_cancelled_payment(
|
|
session, bot, payment_dict_for_processing, i18n_instance, settings
|
|
)
|
|
await session.commit()
|
|
elif notification_object.event == YOOKASSA_EVENT_PAYMENT_WAITING_FOR_CAPTURE:
|
|
# Bind-only flow: save method and cancel auth if metadata has bind_only
|
|
metadata = payment_dict_for_processing.get("metadata", {}) or {}
|
|
if (
|
|
settings.yookassa_autopayments_active
|
|
and metadata.get("bind_only") == "1"
|
|
):
|
|
try:
|
|
user_id_str = metadata.get("user_id")
|
|
if user_id_str and user_id_str.isdigit():
|
|
user_id = int(user_id_str)
|
|
payment_method = payment_dict_for_processing.get(
|
|
"payment_method"
|
|
)
|
|
if isinstance(payment_method, dict) and payment_method.get(
|
|
"id"
|
|
):
|
|
pm_type = payment_method.get("type")
|
|
title = payment_method.get("title")
|
|
card = payment_method.get("card") or {}
|
|
account_number = payment_method.get(
|
|
"account_number"
|
|
) or payment_method.get("account")
|
|
display_network = None
|
|
display_last4 = None
|
|
if (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",
|
|
}:
|
|
# Normalize wallet display name to avoid leaking full account from title # noqa: E501
|
|
display_network = "YooMoney"
|
|
if (
|
|
isinstance(account_number, str)
|
|
and len(account_number) >= 4
|
|
):
|
|
display_last4 = account_number[-4:]
|
|
else:
|
|
display_last4 = 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=payment_method.get("id"),
|
|
card_last4=display_last4,
|
|
card_network=display_network,
|
|
)
|
|
await session.commit()
|
|
# Save multi-card entry and mark default if first
|
|
try:
|
|
from db.dal import user_billing_dal as ub
|
|
|
|
await ub.upsert_user_payment_method(
|
|
session,
|
|
user_id=user_id,
|
|
provider_payment_method_id=payment_method.get("id"),
|
|
provider="yookassa",
|
|
card_last4=display_last4,
|
|
card_network=display_network,
|
|
set_default=True,
|
|
)
|
|
await session.commit()
|
|
except Exception:
|
|
await session.rollback()
|
|
# Notify user about successful binding with Back button
|
|
try:
|
|
# Use user's DB language for bind success notification
|
|
i18n_lang = settings.DEFAULT_LANGUAGE
|
|
from db.dal import user_dal
|
|
|
|
db_user = await user_dal.get_user_by_id(
|
|
session, user_id
|
|
)
|
|
if db_user and db_user.language_code:
|
|
i18n_lang = db_user.language_code
|
|
_ = lambda key, **kwargs: i18n_instance.gettext(
|
|
i18n_lang, key, **kwargs
|
|
)
|
|
from bot.keyboards.inline.user_keyboards import (
|
|
get_back_to_payment_methods_keyboard,
|
|
)
|
|
|
|
message_text = _("payment_method_bound_success")
|
|
try:
|
|
await bot.send_message(
|
|
chat_id=user_id,
|
|
text=message_text,
|
|
reply_markup=get_back_to_payment_methods_keyboard(
|
|
i18n_lang, i18n_instance
|
|
),
|
|
)
|
|
except Exception:
|
|
logging.exception(
|
|
"Failed to notify user %s "
|
|
"about payment method binding.",
|
|
user_id,
|
|
)
|
|
if db_user:
|
|
await send_user_notification_email(
|
|
settings=settings,
|
|
i18n=i18n_instance,
|
|
user=db_user,
|
|
subject_key="email_payment_method_bound_subject",
|
|
message_text=message_text,
|
|
dashboard_url=(
|
|
settings.SUBSCRIPTION_MINI_APP_URL or None
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
# Attempt to cancel the authorization to avoid charge hold
|
|
try:
|
|
yk: YooKassaService = request.app.get(
|
|
"yookassa_service"
|
|
)
|
|
if yk:
|
|
await yk.cancel_payment(
|
|
payment_dict_for_processing.get("id")
|
|
)
|
|
except Exception:
|
|
logging.exception(
|
|
"Failed to cancel bind-only payment auth"
|
|
)
|
|
except Exception:
|
|
logging.exception(
|
|
"Failed to handle bind-only waiting_for_capture webhook"
|
|
)
|
|
except Exception:
|
|
await session.rollback()
|
|
logging.exception(
|
|
"Error processing YooKassa webhook event '%s' for YK Payment ID %s in DB transaction.", # noqa: E501
|
|
notification_object.event,
|
|
payment_dict_for_processing.get("id"),
|
|
)
|
|
return web.Response(status=500, text="internal_processing_error")
|
|
|
|
return web.Response(status=200, text="ok")
|
|
|
|
except json.JSONDecodeError:
|
|
logging.error("YooKassa Webhook: Invalid JSON received.")
|
|
return web.Response(status=400, text="bad_request_invalid_json")
|
|
except Exception:
|
|
logging.exception("YooKassa Webhook general processing error.")
|
|
return web.Response(status=500, text="internal_error")
|
|
|
|
|
|
router = Router(name="user_subscription_payments_yookassa_router")
|
|
|
|
|
|
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 _parse_saved_list_payload(payload: str) -> Optional[Tuple[float, float, int, str]]:
|
|
parts = payload.split(":")
|
|
if len(parts) < 2:
|
|
return None
|
|
try:
|
|
months = float(parts[0])
|
|
price = float(parts[1])
|
|
except (ValueError, IndexError):
|
|
return None
|
|
|
|
page = 0
|
|
sale_mode = "subscription"
|
|
if len(parts) > 2:
|
|
try:
|
|
page = int(parts[2])
|
|
sale_mode = parts[3] if len(parts) > 3 else "subscription"
|
|
except ValueError:
|
|
sale_mode = parts[2]
|
|
return months, price, page, sale_mode
|
|
|
|
|
|
def _metadata_iso(value: Any) -> Optional[str]:
|
|
if value is None:
|
|
return None
|
|
if hasattr(value, "isoformat"):
|
|
return value.isoformat()
|
|
text = str(value).strip()
|
|
return text or 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,
|
|
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",
|
|
hwid_quote: Optional[dict] = None,
|
|
) -> bool:
|
|
"""Create payment record and initiate YooKassa payment (new card or saved card)."""
|
|
if not callback.message:
|
|
return False
|
|
|
|
sale_base = _sale_mode_base(sale_mode)
|
|
hwid_device_count = None
|
|
if hwid_quote:
|
|
hwid_device_count = parse_positive_int_units(hwid_quote.get("device_count"))
|
|
payment_description = (
|
|
get_text("payment_description_traffic", traffic_gb=_format_value(months))
|
|
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
|
else (
|
|
get_text("payment_description_hwid_devices", count=int(months))
|
|
if sale_base in HWID_DEVICE_SALE_BASES
|
|
else get_text("payment_description_subscription", months=int(months))
|
|
)
|
|
)
|
|
payment_record_data = {
|
|
"user_id": user_id,
|
|
"amount": price_rub,
|
|
"currency": currency_code_for_yk,
|
|
"status": "pending_yookassa",
|
|
"description": payment_description,
|
|
"subscription_duration_months": int(months) if sale_base == "subscription" else None,
|
|
"sale_mode": sale_mode,
|
|
"tariff_key": sale_mode.split("@", 1)[1].split("|", 1)[0] if "@" in sale_mode else None,
|
|
"purchased_gb": float(months)
|
|
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
|
else None,
|
|
"purchased_hwid_devices": (
|
|
int(months) if sale_base in HWID_DEVICE_SALE_BASES else hwid_device_count
|
|
),
|
|
"hwid_valid_from": hwid_quote.get("valid_from") if hwid_quote else None,
|
|
"hwid_valid_until": hwid_quote.get("valid_until") if hwid_quote else None,
|
|
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months")
|
|
if hwid_quote
|
|
else None,
|
|
"hwid_proration_ratio": hwid_quote.get("proration_ratio") if hwid_quote else None,
|
|
"hwid_full_price": hwid_quote.get("full_price") if hwid_quote else None,
|
|
}
|
|
|
|
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'." # noqa: E501
|
|
)
|
|
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:
|
|
pass
|
|
return False
|
|
|
|
if not db_payment_record:
|
|
try:
|
|
await callback.message.edit_text(get_text("error_creating_payment_record"))
|
|
except Exception:
|
|
pass
|
|
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_base in {"traffic", "traffic_package", "topup", "premium_topup"}:
|
|
yookassa_metadata["traffic_gb"] = str(months)
|
|
if sale_base in HWID_DEVICE_SALE_BASES:
|
|
yookassa_metadata["hwid_devices"] = str(months)
|
|
elif hwid_device_count:
|
|
yookassa_metadata["hwid_devices"] = str(hwid_device_count)
|
|
if hwid_quote and hwid_device_count:
|
|
hwid_metadata = {
|
|
"hwid_valid_from": _metadata_iso(hwid_quote.get("valid_from")),
|
|
"hwid_valid_until": _metadata_iso(hwid_quote.get("valid_until")),
|
|
"hwid_pricing_period_months": hwid_quote.get("pricing_period_months"),
|
|
"hwid_proration_ratio": hwid_quote.get("proration_ratio"),
|
|
"hwid_full_price": hwid_quote.get("full_price"),
|
|
}
|
|
yookassa_metadata.update(
|
|
{key: str(value) for key, value in hwid_metadata.items() if value is not None}
|
|
)
|
|
if payment_method_id:
|
|
yookassa_metadata["used_saved_payment_method_id"] = payment_method_id
|
|
|
|
receipt_email_for_yk = yookassa_service.config.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:
|
|
pass
|
|
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}", # noqa: E501
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
await callback.message.edit_text(get_text("error_payment_gateway_link_failed"))
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
try:
|
|
await callback.message.edit_text(
|
|
get_text(
|
|
key="payment_link_message_traffic"
|
|
if sale_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
|
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_base in {"traffic", "traffic_package", "topup", "premium_topup"}
|
|
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:
|
|
pass
|
|
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}", # noqa: E501
|
|
exc_info=True,
|
|
)
|
|
try:
|
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
|
except Exception:
|
|
pass
|
|
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:
|
|
pass
|
|
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}", # noqa: E501
|
|
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}" # noqa: E501
|
|
)
|
|
try:
|
|
await callback.message.edit_text(get_text("error_payment_gateway"))
|
|
except Exception:
|
|
pass
|
|
return False
|
|
|
|
|
|
async def _yookassa_available_to_callback_user(
|
|
callback: types.CallbackQuery,
|
|
settings: Settings,
|
|
get_text,
|
|
) -> bool:
|
|
if SPEC.is_available_to_user(
|
|
settings,
|
|
user_id=callback.from_user.id,
|
|
require_configured=False,
|
|
):
|
|
return True
|
|
try:
|
|
await callback.answer(get_text("payment_service_unavailable_alert"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
if callback.message:
|
|
try:
|
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
|
except Exception:
|
|
pass
|
|
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,
|
|
):
|
|
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:
|
|
pass
|
|
return
|
|
|
|
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
|
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:
|
|
pass
|
|
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:
|
|
pass
|
|
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:
|
|
pass
|
|
return
|
|
|
|
months, price_rub, sale_mode = parsed
|
|
hwid_quote = None
|
|
user_id = callback.from_user.id
|
|
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
|
autopay_enabled = bool(
|
|
settings.yookassa_autopayments_active
|
|
and _sale_mode_base(sale_mode) == "subscription"
|
|
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,
|
|
back_callback=payment_methods_back_callback(
|
|
_format_value(months), sale_mode, price_rub
|
|
),
|
|
),
|
|
)
|
|
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,
|
|
back_callback=payment_methods_back_callback(
|
|
_format_value(months), sale_mode, price_rub
|
|
),
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
|
session=session,
|
|
user_id=callback.from_user.id,
|
|
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
|
subscription_service=yookassa_service.subscription_service,
|
|
currency=default_currency_key_for_settings(settings),
|
|
)
|
|
if not quoted_parts:
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
months = quoted_parts.months
|
|
price_rub = quoted_parts.price
|
|
|
|
await _initiate_yk_payment(
|
|
callback,
|
|
settings=settings,
|
|
session=session,
|
|
yookassa_service=yookassa_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=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
|
sale_mode=sale_mode,
|
|
hwid_quote=hwid_quote,
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@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,
|
|
):
|
|
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:
|
|
pass
|
|
return
|
|
|
|
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
|
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:
|
|
pass
|
|
try:
|
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
|
except Exception:
|
|
pass
|
|
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:
|
|
pass
|
|
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:
|
|
pass
|
|
return
|
|
|
|
months, price_rub, sale_mode = parsed
|
|
hwid_quote = None
|
|
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
|
session=session,
|
|
user_id=callback.from_user.id,
|
|
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
|
subscription_service=yookassa_service.subscription_service,
|
|
currency=default_currency_key_for_settings(settings),
|
|
)
|
|
if not quoted_parts:
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
months = quoted_parts.months
|
|
price_rub = quoted_parts.price
|
|
user_id = callback.from_user.id
|
|
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
|
autopay_enabled = bool(
|
|
settings.yookassa_autopayments_active
|
|
and _sale_mode_base(sale_mode) == "subscription"
|
|
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,
|
|
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=payment_methods_back_callback(_format_value(months), sale_mode, price_rub),
|
|
sale_mode=sale_mode,
|
|
hwid_quote=hwid_quote,
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@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,
|
|
):
|
|
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:
|
|
pass
|
|
return
|
|
|
|
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
|
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:
|
|
pass
|
|
return
|
|
|
|
parsed_saved_list = _parse_saved_list_payload(data_payload)
|
|
if not parsed_saved_list:
|
|
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:
|
|
pass
|
|
return
|
|
months, price_rub, page, sale_mode = parsed_saved_list
|
|
|
|
autopay_enabled = bool(
|
|
settings.yookassa_autopayments_active
|
|
and _sale_mode_base(sale_mode) == "subscription"
|
|
and not settings.traffic_sale_mode
|
|
)
|
|
if not autopay_enabled:
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
user_id = callback.from_user.id
|
|
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,
|
|
back_callback=payment_methods_back_callback(
|
|
_format_value(months), sale_mode, price_rub
|
|
),
|
|
),
|
|
)
|
|
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,
|
|
back_callback=payment_methods_back_callback(
|
|
_format_value(months), sale_mode, price_rub
|
|
),
|
|
),
|
|
)
|
|
except Exception:
|
|
pass
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
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:
|
|
pass
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@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,
|
|
):
|
|
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:
|
|
pass
|
|
return
|
|
|
|
if not await _yookassa_available_to_callback_user(callback, settings, get_text):
|
|
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:
|
|
pass
|
|
try:
|
|
await callback.message.edit_text(get_text("payment_service_unavailable"))
|
|
except Exception:
|
|
pass
|
|
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:
|
|
pass
|
|
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:
|
|
pass
|
|
return
|
|
|
|
try:
|
|
months = float(parts[0])
|
|
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:
|
|
pass
|
|
return
|
|
|
|
autopay_enabled = bool(
|
|
settings.yookassa_autopayments_active
|
|
and _sale_mode_base(sale_mode) == "subscription"
|
|
and not settings.traffic_sale_mode
|
|
)
|
|
if not autopay_enabled:
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
method_identifier = parts[2]
|
|
user_id = callback.from_user.id
|
|
base_months = months
|
|
base_price_rub = price_rub
|
|
hwid_quote = None
|
|
quoted_parts, hwid_quote = await quote_hwid_callback_parts(
|
|
session=session,
|
|
user_id=user_id,
|
|
parts=PaymentCallbackParts(months=months, price=price_rub, sale_mode=sale_mode),
|
|
subscription_service=yookassa_service.subscription_service,
|
|
currency=default_currency_key_for_settings(settings),
|
|
)
|
|
if not quoted_parts:
|
|
try:
|
|
await callback.answer(get_text("error_try_again"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
months = quoted_parts.months
|
|
price_rub = quoted_parts.price
|
|
|
|
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:
|
|
pass
|
|
return
|
|
|
|
currency_code_for_yk = default_payment_currency_code_for_settings(settings)
|
|
|
|
await _initiate_yk_payment(
|
|
callback,
|
|
settings=settings,
|
|
session=session,
|
|
yookassa_service=yookassa_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(base_months)}:{base_price_rub}:0:{sale_mode}"
|
|
),
|
|
payment_method_id=selected_method.provider_payment_method_id,
|
|
selected_method_internal_id=selected_method.method_id,
|
|
sale_mode=sale_mode,
|
|
hwid_quote=hwid_quote,
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.callback_query(F.data == "pm:manage")
|
|
async def payment_methods_manage(
|
|
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
|
):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
if not settings.yookassa_autopayments_active:
|
|
try:
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
from db.dal.user_billing_dal import list_user_payment_methods
|
|
|
|
get_text = _
|
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
|
cards: List[tuple] = []
|
|
|
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
|
s = (network 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
|
|
|
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
|
if _is_yoomoney_network(network):
|
|
l4 = last4 or _extract_last4(network or "")
|
|
if l4:
|
|
return get_text("payment_method_wallet_title", last4=l4)
|
|
return get_text("payment_method_wallet_title", last4="****")
|
|
if last4:
|
|
network_name = network or get_text("payment_network_card")
|
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
|
network_name = network or get_text("payment_network_generic")
|
|
return get_text("payment_method_generic_title", network=network_name)
|
|
|
|
for m in methods:
|
|
title = _format_pm_title(m.card_network, m.card_last4)
|
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
|
|
|
text = get_text("payment_methods_title")
|
|
if not cards:
|
|
text += "\n\n" + get_text("payment_method_none")
|
|
|
|
await callback.message.edit_text(
|
|
text, reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n)
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.callback_query(F.data == "pm:bind")
|
|
async def payment_method_bind(
|
|
callback: types.CallbackQuery,
|
|
settings: Settings,
|
|
i18n_data: dict,
|
|
session: AsyncSession,
|
|
yookassa_service: YooKassaService,
|
|
):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
if not settings.yookassa_autopayments_active:
|
|
try:
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
metadata = {"user_id": str(callback.from_user.id), "bind_only": "1"}
|
|
resp = await yookassa_service.create_payment(
|
|
amount=1.00,
|
|
currency=default_payment_currency_code_for_settings(settings),
|
|
description="Bind card",
|
|
metadata=metadata,
|
|
receipt_email=yookassa_service.config.DEFAULT_RECEIPT_EMAIL,
|
|
save_payment_method=True,
|
|
capture=False,
|
|
bind_only=True,
|
|
)
|
|
if not resp or not resp.get("confirmation_url"):
|
|
await callback.answer(_("error_payment_gateway"), show_alert=True)
|
|
return
|
|
await callback.message.edit_text(
|
|
_("payment_methods_title"),
|
|
reply_markup=get_bind_url_keyboard(resp["confirmation_url"], current_lang, i18n),
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pm:delete_confirm"))
|
|
async def payment_method_delete_confirm(
|
|
callback: types.CallbackQuery, settings: Settings, i18n_data: dict
|
|
):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
if not settings.yookassa_autopayments_active:
|
|
try:
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
parts = callback.data.split(":", 2)
|
|
pm_id = parts[2] if len(parts) >= 3 else ""
|
|
await callback.message.edit_text(
|
|
_("payment_method_delete_confirm"),
|
|
reply_markup=get_payment_method_delete_confirm_keyboard(pm_id, current_lang, i18n),
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pm:delete"))
|
|
async def payment_method_delete(
|
|
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
|
):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
if not settings.yookassa_autopayments_active:
|
|
try:
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
parts = callback.data.split(":", 2)
|
|
pm_id_raw = parts[2] if len(parts) >= 3 else ""
|
|
deleted = False
|
|
|
|
try:
|
|
from db.dal.user_billing_dal import (
|
|
delete_user_payment_method,
|
|
delete_user_payment_method_by_provider_id,
|
|
list_user_payment_methods,
|
|
)
|
|
|
|
if pm_id_raw:
|
|
if pm_id_raw.isdigit():
|
|
deleted = await delete_user_payment_method(
|
|
session, callback.from_user.id, int(pm_id_raw)
|
|
)
|
|
else:
|
|
deleted = await delete_user_payment_method_by_provider_id(
|
|
session, callback.from_user.id, pm_id_raw
|
|
)
|
|
try:
|
|
legacy_deleted = await user_billing_dal.delete_yk_payment_method(
|
|
session, callback.from_user.id
|
|
)
|
|
deleted = deleted or legacy_deleted
|
|
except Exception:
|
|
pass
|
|
await session.commit()
|
|
|
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
|
text = _("payment_methods_title")
|
|
cards = []
|
|
for m in methods:
|
|
|
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
|
s = (network 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
|
|
|
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
|
if _is_yoomoney_network(network):
|
|
l4 = last4 or _extract_last4(network or "")
|
|
if l4:
|
|
return _("payment_method_wallet_title", last4=l4)
|
|
return _("payment_method_wallet_title", last4="****")
|
|
if last4:
|
|
network_name = network or _("payment_network_card")
|
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
|
network_name = network or _("payment_network_generic")
|
|
return _("payment_method_generic_title", network=network_name)
|
|
|
|
title = _format_pm_title(m.card_network, m.card_last4)
|
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
|
if not cards:
|
|
text += "\n\n" + _("payment_method_none")
|
|
msg = _("payment_method_deleted_success") if deleted else _("error_try_again")
|
|
await callback.message.edit_text(
|
|
f"{msg}\n\n{text}",
|
|
reply_markup=get_payment_methods_list_keyboard(cards, 0, current_lang, i18n),
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
return
|
|
except Exception:
|
|
await session.rollback()
|
|
try:
|
|
await callback.answer(_("error_try_again"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pm:view"))
|
|
async def payment_method_view(
|
|
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
|
):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
if not settings.yookassa_autopayments_active:
|
|
try:
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
billing = await user_billing_dal.get_user_billing(session, callback.from_user.id)
|
|
if not billing or not billing.yookassa_payment_method_id:
|
|
from db.dal.user_billing_dal import list_user_payment_methods
|
|
|
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
|
if not methods:
|
|
await callback.answer(_("payment_method_none"), show_alert=True)
|
|
return
|
|
parts = callback.data.split(":", 2)
|
|
pm_id = parts[2] if len(parts) >= 3 else str(methods[0].method_id)
|
|
sel = next(
|
|
(
|
|
m
|
|
for m in methods
|
|
if str(m.method_id) == pm_id or m.provider_payment_method_id == pm_id
|
|
),
|
|
methods[0],
|
|
)
|
|
|
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
|
s = (network 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
|
|
|
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
|
if _is_yoomoney_network(network):
|
|
l4 = last4 or _extract_last4(network or "")
|
|
if l4:
|
|
return _("payment_method_wallet_title", last4=l4)
|
|
return _("payment_method_wallet_title", last4="****")
|
|
if last4:
|
|
network_name = network or _("payment_network_card")
|
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
|
network_name = network or _("payment_network_generic")
|
|
return _("payment_method_generic_title", network=network_name)
|
|
|
|
title = _format_pm_title(sel.card_network, sel.card_last4)
|
|
added_at = sel.created_at.strftime("%Y-%m-%d") if getattr(sel, "created_at", None) else "—"
|
|
last_tx = "—"
|
|
try:
|
|
stmt = (
|
|
select(Payment)
|
|
.where(
|
|
Payment.user_id == callback.from_user.id,
|
|
Payment.status == "succeeded",
|
|
Payment.provider == "yookassa",
|
|
)
|
|
.order_by(Payment.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
result = await session.execute(stmt)
|
|
lp = result.scalar_one_or_none()
|
|
if lp and lp.created_at:
|
|
last_tx = lp.created_at.strftime("%Y-%m-%d")
|
|
except Exception:
|
|
pass
|
|
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
|
|
await callback.message.edit_text(
|
|
details,
|
|
reply_markup=get_payment_method_details_keyboard(
|
|
str(sel.method_id), current_lang, i18n
|
|
),
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
return
|
|
|
|
added_at = (
|
|
billing.created_at.strftime("%Y-%m-%d") if getattr(billing, "created_at", None) else "—"
|
|
)
|
|
last_tx = "—"
|
|
try:
|
|
stmt = (
|
|
select(Payment)
|
|
.where(
|
|
Payment.user_id == callback.from_user.id,
|
|
Payment.status == "succeeded",
|
|
Payment.provider == "yookassa",
|
|
)
|
|
.order_by(Payment.created_at.desc())
|
|
.limit(1)
|
|
)
|
|
result = await session.execute(stmt)
|
|
last_payment = result.scalar_one_or_none()
|
|
if last_payment and last_payment.created_at:
|
|
last_tx = last_payment.created_at.strftime("%Y-%m-%d")
|
|
except Exception:
|
|
pass
|
|
|
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
|
s = (network 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
|
|
|
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
|
if _is_yoomoney_network(network):
|
|
l4 = last4 or _extract_last4(network or "")
|
|
if l4:
|
|
return _("payment_method_wallet_title", last4=l4)
|
|
return _("payment_method_wallet_title", last4="****")
|
|
if last4:
|
|
network_name = network or _("payment_network_card")
|
|
return _("payment_method_card_title", network=network_name, last4=last4)
|
|
network_name = network or _("payment_network_generic")
|
|
return _("payment_method_generic_title", network=network_name)
|
|
|
|
title = _format_pm_title(billing.card_network, billing.card_last4)
|
|
details = f"{title}\n{_('payment_method_added_at', date=added_at)}\n{_('payment_method_last_tx', date=last_tx)}" # noqa: E501
|
|
await callback.message.edit_text(
|
|
details,
|
|
reply_markup=get_payment_method_details_keyboard(
|
|
billing.yookassa_payment_method_id, current_lang, i18n
|
|
),
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pm:history"))
|
|
async def payment_method_history(
|
|
callback: types.CallbackQuery,
|
|
settings: Settings,
|
|
i18n_data: dict,
|
|
session: AsyncSession,
|
|
yookassa_service: YooKassaService,
|
|
):
|
|
current_lang = i18n_data.get("current_language", settings.DEFAULT_LANGUAGE)
|
|
i18n: Optional[JsonI18n] = i18n_data.get("i18n_instance")
|
|
if not settings.yookassa_autopayments_active:
|
|
try:
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
await callback.answer(_("error_service_unavailable"), show_alert=True)
|
|
except Exception:
|
|
pass
|
|
return
|
|
_ = lambda key, **kwargs: i18n.gettext(current_lang, key, **kwargs) if i18n else key
|
|
|
|
from db.dal import payment_dal
|
|
|
|
payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=30, offset=0)
|
|
user_payments = [p for p in payments if p.user_id == callback.from_user.id]
|
|
|
|
selected_pm_provider_id: Optional[str] = None
|
|
pm_filter_requested: bool = False
|
|
try:
|
|
split_a, split_b, split_pm_id = callback.data.split(":", 2)
|
|
if split_pm_id:
|
|
pm_filter_requested = True
|
|
if split_pm_id.isdigit():
|
|
from db.dal.user_billing_dal import list_user_payment_methods
|
|
|
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
|
sel = next((m for m in methods if str(m.method_id) == split_pm_id), None)
|
|
if sel and sel.provider_payment_method_id:
|
|
selected_pm_provider_id = sel.provider_payment_method_id
|
|
else:
|
|
selected_pm_provider_id = split_pm_id
|
|
except Exception:
|
|
selected_pm_provider_id = None
|
|
pm_filter_requested = False
|
|
|
|
if pm_filter_requested and not selected_pm_provider_id:
|
|
user_payments = []
|
|
|
|
if selected_pm_provider_id:
|
|
filtered: List[Payment] = []
|
|
for p in user_payments:
|
|
if p.provider != "yookassa":
|
|
continue
|
|
if p.yookassa_payment_id and yookassa_service:
|
|
try:
|
|
info = await yookassa_service.get_payment_info(p.yookassa_payment_id)
|
|
pm = (info or {}).get("payment_method") or {}
|
|
if pm.get("id") == selected_pm_provider_id:
|
|
filtered.append(p)
|
|
continue
|
|
except Exception:
|
|
pass
|
|
user_payments = filtered
|
|
|
|
if not user_payments:
|
|
from bot.keyboards.inline.user_keyboards import (
|
|
get_back_to_payment_method_details_keyboard,
|
|
get_payment_methods_manage_keyboard,
|
|
)
|
|
|
|
back_pm_id = ""
|
|
try:
|
|
split_a, split_b, back_pm_id = callback.data.split(":", 2)
|
|
except Exception:
|
|
back_pm_id = ""
|
|
back_markup = (
|
|
get_back_to_payment_method_details_keyboard(back_pm_id, current_lang, i18n)
|
|
if back_pm_id
|
|
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
|
)
|
|
await callback.message.edit_text(_("payment_method_no_history"), reply_markup=back_markup)
|
|
return
|
|
|
|
traffic_mode = getattr(settings, "traffic_sale_mode", False)
|
|
|
|
def _format_item(p: Payment) -> str:
|
|
if traffic_mode:
|
|
units_val = p.subscription_duration_months or 0
|
|
units_display = (
|
|
str(int(units_val)) if float(units_val).is_integer() else f"{units_val:g}"
|
|
)
|
|
title = p.description or _("traffic_purchase_title", traffic_gb=units_display)
|
|
else:
|
|
title = p.description or _(
|
|
"subscription_purchase_title", months=p.subscription_duration_months or 1
|
|
)
|
|
date_str = p.created_at.strftime("%Y-%m-%d") if p.created_at else "N/A"
|
|
return f"{date_str} — {title} — {p.amount:.2f} {p.currency}"
|
|
|
|
lines = [_format_item(p) for p in user_payments]
|
|
text = _("payment_method_tx_history_title") + "\n\n" + "\n".join(lines)
|
|
try:
|
|
split_a, split_b, split_pm_id_for_back = callback.data.split(":", 2)
|
|
except Exception:
|
|
split_pm_id_for_back = ""
|
|
from bot.keyboards.inline.user_keyboards import (
|
|
get_back_to_payment_method_details_keyboard,
|
|
get_payment_methods_manage_keyboard,
|
|
)
|
|
|
|
back_markup = (
|
|
get_back_to_payment_method_details_keyboard(split_pm_id_for_back, current_lang, i18n)
|
|
if split_pm_id_for_back
|
|
else get_payment_methods_manage_keyboard(current_lang, i18n, has_card=True)
|
|
)
|
|
await callback.message.edit_text(text, reply_markup=back_markup)
|
|
|
|
|
|
@router.callback_query(F.data.startswith("pm:list:"))
|
|
async def payment_methods_list(
|
|
callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession
|
|
):
|
|
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
|
|
|
|
from db.dal.user_billing_dal import list_user_payment_methods
|
|
|
|
cards: List[tuple] = []
|
|
methods = await list_user_payment_methods(session, callback.from_user.id)
|
|
for m in methods:
|
|
|
|
def _is_yoomoney_network(network: Optional[str]) -> bool:
|
|
s = (network 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
|
|
|
|
def _format_pm_title(network: Optional[str], last4: Optional[str]) -> str:
|
|
if _is_yoomoney_network(network):
|
|
l4 = last4 or _extract_last4(network or "")
|
|
if l4:
|
|
return get_text("payment_method_wallet_title", last4=l4)
|
|
return get_text("payment_method_wallet_title", last4="****")
|
|
if last4:
|
|
network_name = network or get_text("payment_network_card")
|
|
return get_text("payment_method_card_title", network=network_name, last4=last4)
|
|
network_name = network or get_text("payment_network_generic")
|
|
return get_text("payment_method_generic_title", network=network_name)
|
|
|
|
title = _format_pm_title(m.card_network, m.card_last4)
|
|
cards.append((str(m.method_id), title if not m.is_default else f"⭐ {title}"))
|
|
|
|
try:
|
|
_, _, page_str = callback.data.split(":", 2)
|
|
page = int(page_str)
|
|
except Exception:
|
|
page = 0
|
|
|
|
text = get_text("payment_methods_title")
|
|
if not cards:
|
|
text += "\n\n" + get_text("payment_method_none")
|
|
await callback.message.edit_text(
|
|
text, reply_markup=get_payment_methods_list_keyboard(cards, page, current_lang, i18n)
|
|
)
|
|
try:
|
|
await callback.answer()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def create_service(ctx: ServiceFactoryContext) -> YooKassaService:
|
|
bundle = ctx.config_for("yookassa_service")
|
|
config = (
|
|
bundle.config if bundle and isinstance(bundle.config, YooKassaConfig) else YooKassaConfig()
|
|
)
|
|
return YooKassaService(
|
|
shop_id=config.SHOP_ID,
|
|
secret_key=config.SECRET_KEY,
|
|
configured_return_url=config.RETURN_URL,
|
|
bot_username_for_default_return=ctx.bot_username_for_default_return,
|
|
settings_obj=ctx.settings,
|
|
config=config,
|
|
subscription_service=ctx.subscription_service,
|
|
)
|
|
|
|
|
|
async def create_webapp_payment(ctx: WebAppPaymentContext) -> web.Response:
|
|
service: YooKassaService = ctx.request.app["yookassa_service"]
|
|
if not service or not service.configured:
|
|
return payment_unavailable()
|
|
currency = (ctx.currency or "RUB").upper()
|
|
|
|
try:
|
|
amounts = payment_record_amounts(
|
|
months=ctx.months,
|
|
sale_mode=ctx.sale_mode,
|
|
traffic_gb=ctx.traffic_gb,
|
|
hwid_device_count=ctx.hwid_device_count,
|
|
)
|
|
payment = await create_webapp_payment_record(
|
|
ctx,
|
|
amount=ctx.price,
|
|
currency=currency,
|
|
status="pending_yookassa",
|
|
provider="yookassa",
|
|
)
|
|
metadata = {
|
|
"user_id": str(ctx.user_id),
|
|
"subscription_months": str(
|
|
int(float(ctx.months))
|
|
if not amounts.traffic_sale and not amounts.hwid_devices_sale
|
|
else 0
|
|
),
|
|
"payment_db_id": str(payment.payment_id),
|
|
"sale_mode": ctx.sale_mode,
|
|
"source": "webapp",
|
|
}
|
|
if amounts.traffic_sale:
|
|
metadata["traffic_gb"] = format_number_for_payload(ctx.traffic_gb or ctx.months)
|
|
if amounts.purchased_hwid_devices:
|
|
metadata["hwid_devices"] = str(int(amounts.purchased_hwid_devices))
|
|
if amounts.tariff_key:
|
|
metadata["tariff_key"] = amounts.tariff_key
|
|
response = await service.create_payment(
|
|
amount=ctx.price,
|
|
currency=currency,
|
|
description=ctx.description,
|
|
metadata=metadata,
|
|
receipt_email=service.config.DEFAULT_RECEIPT_EMAIL,
|
|
save_payment_method=False,
|
|
)
|
|
payment_url = response.get("confirmation_url") if response else None
|
|
if not payment_url:
|
|
await mark_payment_failed_creation(ctx.session, payment.payment_id)
|
|
return payment_failed()
|
|
|
|
await payment_dal.update_payment_status_by_db_id(
|
|
ctx.session,
|
|
payment.payment_id,
|
|
response.get("status", "pending"),
|
|
yk_payment_id=response.get("id"),
|
|
)
|
|
await ctx.session.commit()
|
|
return payment_link_response(payment_url=payment_url, payment_id=payment.payment_id)
|
|
except Exception:
|
|
await ctx.session.rollback()
|
|
logger.exception("YooKassa WebApp payment failed")
|
|
return payment_failed()
|
|
|
|
|
|
async def reuse_webapp_payment(ctx: WebAppPaymentContext, payment: Any) -> Optional[str]:
|
|
service: YooKassaService = ctx.request.app.get("yookassa_service")
|
|
if not service or not service.configured:
|
|
return None
|
|
|
|
provider_payment_id = str(
|
|
getattr(payment, "yookassa_payment_id", None)
|
|
or getattr(payment, "provider_payment_id", None)
|
|
or ""
|
|
).strip()
|
|
if not provider_payment_id:
|
|
return None
|
|
|
|
info = await service.get_payment_info(provider_payment_id)
|
|
if not info or str(info.get("status") or "").strip().lower() != "pending":
|
|
return None
|
|
if bool(info.get("paid")):
|
|
return None
|
|
if not decimal_amounts_equal(info.get("amount_value"), getattr(payment, "amount", None)):
|
|
return None
|
|
provider_currency = normalize_payment_currency_code(info.get("amount_currency"))
|
|
payment_currency = normalize_payment_currency_code(getattr(payment, "currency", None))
|
|
if provider_currency != payment_currency:
|
|
return None
|
|
|
|
metadata = info.get("metadata") or {}
|
|
expected_metadata = {
|
|
"user_id": str(ctx.user_id),
|
|
"payment_db_id": str(payment.payment_id),
|
|
"sale_mode": str(ctx.sale_mode),
|
|
}
|
|
if any(str(metadata.get(key) or "") != value for key, value in expected_metadata.items()):
|
|
return None
|
|
return str(info.get("confirmation_url") or "").strip() or None
|
|
|
|
|
|
_PRESENTATION_MANIFEST = tuple(
|
|
ProviderManifestField(
|
|
key=key,
|
|
type=type_,
|
|
label=label,
|
|
description=description,
|
|
placeholder=placeholder,
|
|
subsection="YooKassa",
|
|
target="presentation",
|
|
attr=attr,
|
|
)
|
|
for key, type_, label, description, placeholder, attr in (
|
|
(
|
|
"PAYMENT_YOOKASSA_WEBAPP_LABEL_RU",
|
|
"string",
|
|
"WebApp button text (RU)",
|
|
"Custom Russian text shown in the Web App payment method button.",
|
|
"",
|
|
"WEBAPP_LABEL_RU",
|
|
),
|
|
(
|
|
"PAYMENT_YOOKASSA_WEBAPP_LABEL_EN",
|
|
"string",
|
|
"WebApp button text (EN)",
|
|
"Custom English text shown in the Web App payment method button.",
|
|
"",
|
|
"WEBAPP_LABEL_EN",
|
|
),
|
|
(
|
|
"PAYMENT_YOOKASSA_WEBAPP_ICON",
|
|
"icon",
|
|
"WebApp button icon",
|
|
"Lucide icon name rendered inside the Web App payment method button.",
|
|
"CreditCard",
|
|
"WEBAPP_ICON",
|
|
),
|
|
(
|
|
"PAYMENT_YOOKASSA_TELEGRAM_LABEL_RU",
|
|
"string",
|
|
"Telegram button text (RU)",
|
|
"Custom Russian text shown in Telegram bot payment buttons.",
|
|
"",
|
|
"TELEGRAM_LABEL_RU",
|
|
),
|
|
(
|
|
"PAYMENT_YOOKASSA_TELEGRAM_LABEL_EN",
|
|
"string",
|
|
"Telegram button text (EN)",
|
|
"Custom English text shown in Telegram bot payment buttons.",
|
|
"",
|
|
"TELEGRAM_LABEL_EN",
|
|
),
|
|
(
|
|
"PAYMENT_YOOKASSA_TELEGRAM_EMOJI",
|
|
"string",
|
|
"Telegram button emoji",
|
|
"Emoji prepended to the Telegram bot payment button when customized.",
|
|
"💳",
|
|
"TELEGRAM_EMOJI",
|
|
),
|
|
)
|
|
)
|
|
|
|
_CONFIG_MANIFEST = (
|
|
ProviderManifestField(
|
|
"YOOKASSA_ENABLED", "bool", "Включена", subsection="YooKassa", attr="ENABLED"
|
|
),
|
|
ProviderManifestField(
|
|
"YOOKASSA_SHOP_ID", "string", "Shop ID", subsection="YooKassa", attr="SHOP_ID"
|
|
),
|
|
ProviderManifestField(
|
|
"YOOKASSA_SECRET_KEY",
|
|
"string",
|
|
"Secret key",
|
|
subsection="YooKassa",
|
|
secret=True,
|
|
attr="SECRET_KEY",
|
|
),
|
|
ProviderManifestField(
|
|
"YOOKASSA_RETURN_URL", "url", "Return URL", subsection="YooKassa", attr="RETURN_URL"
|
|
),
|
|
ProviderManifestField(
|
|
"YOOKASSA_DEFAULT_RECEIPT_EMAIL",
|
|
"string",
|
|
"Email для чека по умолчанию",
|
|
subsection="YooKassa",
|
|
attr="DEFAULT_RECEIPT_EMAIL",
|
|
),
|
|
ProviderManifestField(
|
|
"YOOKASSA_VAT_CODE",
|
|
"int",
|
|
"VAT code",
|
|
description="1..6 в зависимости от системы налогообложения",
|
|
subsection="YooKassa",
|
|
min=1,
|
|
max=6,
|
|
attr="VAT_CODE",
|
|
),
|
|
ProviderManifestField(
|
|
"YOOKASSA_AUTOPAYMENTS_ENABLED",
|
|
"bool",
|
|
"Автоплатежи (recurring)",
|
|
subsection="YooKassa",
|
|
attr="AUTOPAYMENTS_ENABLED",
|
|
),
|
|
ProviderManifestField(
|
|
"YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING",
|
|
"bool",
|
|
"Принудительная привязка карты",
|
|
subsection="YooKassa",
|
|
attr="AUTOPAYMENTS_REQUIRE_CARD_BINDING",
|
|
),
|
|
)
|
|
|
|
|
|
SPEC = PaymentProviderSpec(
|
|
id="yookassa",
|
|
provider_key="yookassa",
|
|
label="YooKassa",
|
|
webapp_label="ЮKassa",
|
|
webapp_labels={"ru": "ЮKassa", "en": "YooKassa"},
|
|
webapp_icon="CreditCard",
|
|
telegram_labels={"ru": "ЮKassa", "en": "YooKassa"},
|
|
telegram_emoji="💳",
|
|
pending_status="pending_yookassa",
|
|
enabled=lambda config: bool(getattr(config, "ENABLED", False)),
|
|
service_key="yookassa_service",
|
|
callback_prefix="pay_yk",
|
|
router=router,
|
|
create_service=create_service,
|
|
webhook_path=lambda source: "/webhook/yookassa",
|
|
webhook_route=yookassa_webhook_route,
|
|
webhook_requires_base_url=True,
|
|
create_webapp_payment=create_webapp_payment,
|
|
reuse_webapp_payment=reuse_webapp_payment,
|
|
config_class=YooKassaConfig,
|
|
presentation_class=YooKassaPresentation,
|
|
manifest_fields=_CONFIG_MANIFEST + _PRESENTATION_MANIFEST,
|
|
supported_currencies=("RUB",),
|
|
currency_support_note=(
|
|
"YooKassa public payment API examples and limits are RUB-based; "
|
|
"treat non-RUB as unsupported unless your YooKassa contract confirms otherwise."
|
|
),
|
|
currency_support_url="https://yookassa.ru/developers/payment-acceptance/integration-scenarios/smart-payment",
|
|
)
|