From b7a723a0888162ba91eeb99e502a3075443339cf Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Thu, 4 Sep 2025 20:03:47 +0300 Subject: [PATCH] Implement YooKassa autopayments feature toggle across payment and subscription handlers - Introduced a global setting for enabling or disabling YooKassa autopayments, allowing for better control over payment method management and subscription renewals. - Updated various handlers to check the autopayments setting before executing payment-related logic, ensuring that features are only available when enabled. - Enhanced error handling to notify users when autopayments are disabled, improving user experience and clarity in payment operations. - Refactored receipt generation logic to derive fields based on the autopayments setting, streamlining configuration management. --- bot/handlers/user/payment.py | 4 +- bot/handlers/user/subscription/core.py | 5 ++- .../user/subscription/payment_methods.py | 42 +++++++++++++++++++ bot/handlers/user/subscription/payments.py | 3 +- bot/services/subscription_service.py | 3 ++ bot/services/yookassa_service.py | 4 +- config/settings.py | 16 +++++++ 7 files changed, 70 insertions(+), 7 deletions(-) diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 5b5cbe8..2efcfbc 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -138,7 +138,7 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, # Try to capture and save payment method for future charges if available try: payment_method = payment_info_from_webhook.get("payment_method") - if isinstance(payment_method, dict) and payment_method.get("saved", False): + if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) 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") @@ -484,7 +484,7 @@ async def yookassa_webhook_route(request: web.Request): 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 metadata.get("bind_only") == "1": + if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False) and metadata.get("bind_only") == "1": try: user_id_str = metadata.get("user_id") if user_id_str and user_id_str.isdigit(): diff --git a/bot/handlers/user/subscription/core.py b/bot/handlers/user/subscription/core.py index 58cd8a1..32e6b64 100644 --- a/bot/handlers/user/subscription/core.py +++ b/bot/handlers/user/subscription/core.py @@ -151,7 +151,7 @@ async def my_subscription_command_handler( kb = base_markup.inline_keyboard try: local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) - if local_sub and local_sub.provider != "tribute": + if local_sub and local_sub.provider != "tribute" and getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): toggle_text = ( get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") ) @@ -163,7 +163,8 @@ async def my_subscription_command_handler( ) ] ] + kb - kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb + if getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + kb = [[InlineKeyboardButton(text=get_text("payment_methods_manage_button"), callback_data="pm:manage")]] + kb except Exception: pass markup = InlineKeyboardMarkup(inline_keyboard=kb) diff --git a/bot/handlers/user/subscription/payment_methods.py b/bot/handlers/user/subscription/payment_methods.py index 13972c4..5dec974 100644 --- a/bot/handlers/user/subscription/payment_methods.py +++ b/bot/handlers/user/subscription/payment_methods.py @@ -22,6 +22,13 @@ router = Router(name="user_subscription_payment_methods_router") 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + 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 @@ -68,6 +75,13 @@ async def payment_methods_manage(callback: types.CallbackQuery, settings: Settin 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + 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"} @@ -95,6 +109,13 @@ async def payment_method_bind(callback: types.CallbackQuery, settings: Settings, 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + 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 "" @@ -109,6 +130,13 @@ async def payment_method_delete_confirm(callback: types.CallbackQuery, settings: 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + 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 "" @@ -176,6 +204,13 @@ async def payment_method_delete(callback: types.CallbackQuery, settings: Setting 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + 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) @@ -290,6 +325,13 @@ async def payment_method_view(callback: types.CallbackQuery, settings: Settings, 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 getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + 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 diff --git a/bot/handlers/user/subscription/payments.py b/bot/handlers/user/subscription/payments.py index be09f26..482ce22 100644 --- a/bot/handlers/user/subscription/payments.py +++ b/bot/handlers/user/subscription/payments.py @@ -164,7 +164,8 @@ async def pay_yk_callback_handler(callback: types.CallbackQuery, settings: Setti description=payment_description, metadata=yookassa_metadata, receipt_email=receipt_email_for_yk, - save_payment_method=True, + # Save method only when autopayments are enabled + save_payment_method=bool(getattr(settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False)), ) if payment_response_yk and payment_response_yk.get("confirmation_url"): diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 4165cc6..fdbfc72 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -789,6 +789,9 @@ class SubscriptionService: """Attempt to charge user using saved payment method. Return True on initiated/handled, False on failure.""" if not sub.auto_renew_enabled: return True + # If autopayments are disabled globally, skip charging attempts + if not getattr(self.settings, 'YOOKASSA_AUTOPAYMENTS_ENABLED', False): + return True if sub.provider == "tribute": # Tribute is paid externally; we do not auto-charge here return True diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index d902f79..6df92af 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -136,9 +136,9 @@ class YooKassaService: "vat_code": str(self.settings.YOOKASSA_VAT_CODE), "payment_mode": - self.settings.YOOKASSA_PAYMENT_MODE, + getattr(self.settings, 'yk_receipt_payment_mode', self.settings.YOOKASSA_PAYMENT_MODE), "payment_subject": - self.settings.YOOKASSA_PAYMENT_SUBJECT + getattr(self.settings, 'yk_receipt_payment_subject', self.settings.YOOKASSA_PAYMENT_SUBJECT) }] receipt_data_dict: Dict[str, Any] = { diff --git a/config/settings.py b/config/settings.py index e65cdea..21878b4 100644 --- a/config/settings.py +++ b/config/settings.py @@ -30,8 +30,11 @@ class Settings(BaseSettings): YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = Field(default=None) YOOKASSA_VAT_CODE: int = Field(default=1) + # Deprecated: explicit receipt fields are now derived from YOOKASSA_AUTOPAYMENTS_ENABLED YOOKASSA_PAYMENT_MODE: str = Field(default="full_prepayment") YOOKASSA_PAYMENT_SUBJECT: str = Field(default="service") + # Single toggle to enable recurring payments (saving cards, managing payment methods, auto-renew) + YOOKASSA_AUTOPAYMENTS_ENABLED: bool = Field(default=False) WEBHOOK_BASE_URL: Optional[str] = None @@ -233,6 +236,19 @@ class Settings(BaseSettings): return f"{base.rstrip('/')}{self.cryptopay_webhook_path}" return None + # Computed YooKassa receipt fields based on recurring toggle + @computed_field + @property + def yk_receipt_payment_mode(self) -> str: + # If autopayments are enabled, use service; otherwise full prepayment + return "service" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "full_prepayment" + + @computed_field + @property + def yk_receipt_payment_subject(self) -> str: + # If autopayments are enabled, use full_payment; otherwise payment + return "full_payment" if self.YOOKASSA_AUTOPAYMENTS_ENABLED else "payment" + @computed_field @property def subscription_options(self) -> Dict[int, float]: