From d6f707d386aa69c3679133292bdab4002d50dba4 Mon Sep 17 00:00:00 2001 From: machka-pasla Date: Wed, 3 Sep 2025 09:40:42 +0300 Subject: [PATCH] Implement auto-renew subscription feature and enhance payment method handling - Added a recurring billing task to automatically charge users one day before subscription expiry, improving subscription management. - Introduced a new UserBilling model to store saved payment methods for off-session charges, enhancing user experience. - Updated YooKassa service to support saving payment methods and capturing payments for auto-renewals. - Enhanced subscription handling to toggle auto-renew settings and provide user feedback through localized messages. - Improved error handling and logging for payment method persistence and subscription renewal processes. --- bot/handlers/user/payment.py | 19 +++++- bot/handlers/user/subscription.py | 94 ++++++++++++++++++++++++++-- bot/main_bot.py | 31 +++++++++ bot/services/subscription_service.py | 54 +++++++++++++++- bot/services/yookassa_service.py | 16 ++++- db/dal/subscription_dal.py | 5 ++ db/dal/user_billing_dal.py | 41 ++++++++++++ db/models.py | 14 +++++ docker-compose.yml | 8 +++ locales/en.json | 6 ++ locales/ru.json | 6 ++ 11 files changed, 284 insertions(+), 10 deletions(-) create mode 100644 db/dal/user_billing_dal.py diff --git a/bot/handlers/user/payment.py b/bot/handlers/user/payment.py index 050ace8..1d6c00a 100644 --- a/bot/handlers/user/payment.py +++ b/bot/handlers/user/payment.py @@ -12,7 +12,7 @@ from sqlalchemy.orm import sessionmaker from yookassa.domain.notification import WebhookNotification from yookassa.domain.models.amount import Amount as YooKassaAmount -from db.dal import payment_dal, user_dal +from db.dal import payment_dal, user_dal, user_billing_dal from bot.services.subscription_service import SubscriptionService from bot.services.referral_service import ReferralService @@ -89,6 +89,21 @@ async def process_successful_payment(session: AsyncSession, bot: Bot, try: yk_payment_id_from_hook = payment_info_from_webhook.get("id") + # 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): + pm_id = payment_method.get("id") + card = payment_method.get("card") or {} + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm_id, + card_last4=card.get("last4"), + card_network=card.get("card_type"), + ) + except Exception: + logging.exception("Failed to persist YooKassa payment method from webhook") updated_payment_record = await payment_dal.update_payment_status_by_db_id( session, payment_db_id=payment_db_id, @@ -329,6 +344,8 @@ async def yookassa_webhook_route(request: web.Request): "description": str(payment_data_from_notification.description) if payment_data_from_notification.description else None, + "payment_method": payment_data_from_notification.payment_method.to_dict() + if getattr(payment_data_from_notification, 'payment_method', None) else None, } async with payment_processing_lock: diff --git a/bot/handlers/user/subscription.py b/bot/handlers/user/subscription.py index 433169c..0202757 100644 --- a/bot/handlers/user/subscription.py +++ b/bot/handlers/user/subscription.py @@ -12,12 +12,14 @@ from bot.keyboards.inline.user_keyboards import ( get_subscription_options_keyboard, get_payment_method_keyboard, get_payment_url_keyboard, get_back_to_main_menu_markup) from bot.services.yookassa_service import YooKassaService +from db.dal import user_billing_dal from bot.services.stars_service import StarsService from bot.services.crypto_pay_service import CryptoPayService from bot.services.subscription_service import SubscriptionService from bot.services.panel_api_service import PanelApiService from bot.services.referral_service import ReferralService from bot.middlewares.i18n import JsonI18n +from db.dal import subscription_dal router = Router(name="user_subscription_router") @@ -297,9 +299,25 @@ async def pay_yk_callback_handler( currency=currency_code_for_yk, description=payment_description, metadata=yookassa_metadata, - receipt_email=receipt_email_for_yk) + receipt_email=receipt_email_for_yk, + save_payment_method=True) if payment_response_yk and payment_response_yk.get("confirmation_url"): + # If YooKassa already provided a payment_method (rare on redirect), store it + pm = payment_response_yk.get("payment_method") + try: + if pm and pm.get('id'): + await user_billing_dal.upsert_yk_payment_method( + session, + user_id=user_id, + payment_method_id=pm['id'], + card_last4=pm.get('last4'), + card_network=pm.get('card', {}).get('card_type') if isinstance(pm.get('card'), dict) else None, + ) + 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, @@ -469,6 +487,24 @@ async def my_subscription_command_handler( (end_date.date() - datetime.now().date()).days if end_date else 0 ) + # Auto-renew toggle hint and Tribute notice + tribute_hint = "" + if active.get("status_from_panel", "").lower() == "active": + # Try to infer provider; fetch local sub for flags + # NOTE: Lightweight lookup by user_id + local_sub = await subscription_dal.get_active_subscription_by_user_id(session, event.from_user.id) + auto_renew_state = None + if local_sub: + auto_renew_state = local_sub.auto_renew_enabled + if local_sub.provider == "tribute": + link = None + link = (settings.tribute_payment_links.get(local_sub.duration_months or 1) + if hasattr(settings, 'tribute_payment_links') else None) + if link: + tribute_hint = "\n\n" + get_text("subscription_tribute_notice_with_link", link=link) + else: + tribute_hint = "\n\n" + get_text("subscription_tribute_notice") + text = get_text( "my_subscription_details", end_date=end_date.strftime("%Y-%m-%d") if end_date else "N/A", @@ -486,7 +522,16 @@ async def my_subscription_command_handler( else get_text("traffic_na") ) ) - markup = get_back_to_main_menu_markup(current_lang, i18n) + # Build markup with auto-renew toggle if available + base_markup = get_back_to_main_menu_markup(current_lang, i18n) + kb = base_markup.inline_keyboard + try: + if 'local_sub' in locals() and local_sub and local_sub.provider != 'tribute': + toggle_text = get_text("autorenew_disable_button") if local_sub.auto_renew_enabled else get_text("autorenew_enable_button") + kb = [[InlineKeyboardButton(text=toggle_text, callback_data=f"toggle_autorenew:{local_sub.subscription_id}:{1 if not local_sub.auto_renew_enabled else 0}")]] + kb + except Exception: + pass + markup = InlineKeyboardMarkup(inline_keyboard=kb) if isinstance(event, types.CallbackQuery): try: @@ -494,11 +539,50 @@ async def my_subscription_command_handler( except Exception: pass try: - await event.message.edit_text(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await event.message.edit_text(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) except: - await bot.send_message(chat_id=target.chat.id, text=text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await bot.send_message(chat_id=target.chat.id, text=text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) else: - await target.answer(text, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + await target.answer(text + tribute_hint, reply_markup=markup, parse_mode="HTML", disable_web_page_preview=True) + + +@router.callback_query(F.data.startswith("toggle_autorenew:")) +async def toggle_autorenew_handler(callback: types.CallbackQuery, settings: Settings, i18n_data: dict, session: AsyncSession, subscription_service: SubscriptionService, panel_service: PanelApiService, bot: Bot): + 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 + + try: + _, payload = callback.data.split(":", 1) + sub_id_str, enable_str = payload.split(":") + sub_id = int(sub_id_str) + enable = bool(int(enable_str)) + except Exception: + try: + await callback.answer(get_text("error_try_again"), show_alert=True) + except Exception: + pass + return + + sub = await session.get(type(subscription_service).__annotations__.get('sub', Subscription), sub_id) # fallback avoids import cycle + # Better: direct DAL fetch + sub = await session.get(Subscription, sub_id) + if not sub or sub.user_id != callback.from_user.id: + await callback.answer(get_text("error_try_again"), show_alert=True) + return + if sub.provider == 'tribute': + await callback.answer(get_text("subscription_autorenew_not_supported_for_tribute"), show_alert=True) + return + + await subscription_dal.update_subscription(session, sub.subscription_id, {"auto_renew_enabled": enable}) + await session.commit() + + try: + await callback.answer(get_text("subscription_autorenew_updated")) + except Exception: + pass + # Refresh panel info screen + await my_subscription_command_handler(callback, i18n_data, settings, panel_service, subscription_service, session, bot) @router.pre_checkout_query() diff --git a/bot/main_bot.py b/bot/main_bot.py index 86981b8..3f1f11b 100644 --- a/bot/main_bot.py +++ b/bot/main_bot.py @@ -293,6 +293,37 @@ async def run_bot(settings_param: Settings): main_tasks.append(asyncio.create_task(web_server_task(), name="AIOHTTPServerTask")) + async def recurring_billing_task(): + # Run periodic check to bill 1 day before expiry + async_session_factory = dp.get("async_session_factory") + subscription_service = dp.get("subscription_service") + if not async_session_factory or not subscription_service: + logging.warning("Recurring billing task: dependencies missing; task not started") + return + while True: + try: + async with async_session_factory() as session: + # Find subscriptions ending in 1 day + subs = await subscription_service.get_subscriptions_ending_soon(session, 1) + # We need actual Subscription objects; reuse DAL directly + from db.dal import subscription_dal + subs_models = await subscription_dal.get_subscriptions_near_expiration(session, 1) + handled = 0 + for sub in subs_models: + try: + ok = await subscription_service.charge_subscription_renewal(session, sub) + handled += 1 if ok else 0 + except Exception: + logging.exception("Auto-renew attempt failed") + if handled: + await session.commit() + except Exception: + logging.exception("Recurring billing iteration failed") + # Sleep 1 hour between scans + await asyncio.sleep(3600) + + main_tasks.append(asyncio.create_task(recurring_billing_task(), name="RecurringBillingTask")) + logging.info("Starting bot in Webhook mode with AIOHTTP server...") logging.info(f"Starting bot with main tasks: {[task.get_name() for task in main_tasks]}") diff --git a/bot/services/subscription_service.py b/bot/services/subscription_service.py index 78cf15a..3fb6c46 100644 --- a/bot/services/subscription_service.py +++ b/bot/services/subscription_service.py @@ -5,7 +5,7 @@ from typing import Optional, Dict, Any, List, Tuple from aiogram import Bot from bot.middlewares.i18n import JsonI18n -from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal +from db.dal import user_dal, subscription_dal, promo_code_dal, payment_dal, user_billing_dal from bot.utils.date_utils import add_months from db.models import User, Subscription @@ -780,6 +780,58 @@ class SubscriptionService: ) return results + async def charge_subscription_renewal( + self, + session: AsyncSession, + sub: Subscription, + ) -> bool: + """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 sub.provider == "tribute": + # Tribute is paid externally; we do not auto-charge here + return True + + billing = await user_billing_dal.get_user_billing(session, sub.user_id) + if not billing or not billing.yookassa_payment_method_id: + logging.info(f"Auto-renew skipped: no saved payment method for user {sub.user_id}") + return False + + try: + from .yookassa_service import YooKassaService # local import to avoid cycles + yk: YooKassaService = self.yookassa_service # type: ignore[attr-defined] + except Exception: + yk = None # type: ignore + if not yk or not getattr(yk, 'configured', False): + logging.warning("YooKassa unavailable for auto-renew") + return False + + months = sub.duration_months or 1 + amount = self.settings.subscription_options.get(months) + if not amount: + logging.error(f"Auto-renew price missing for {months} months") + return False + + metadata = { + "user_id": str(sub.user_id), + "auto_renew_for_subscription_id": str(sub.subscription_id), + "subscription_months": str(months), + } + resp = await yk.create_payment( + amount=float(amount), + currency="RUB", + description=f"Auto-renewal for {months} months", + metadata=metadata, + payment_method_id=billing.yookassa_payment_method_id, + save_payment_method=False, + capture=True, + ) + if not resp or resp.get("status") not in {"pending", "waiting_for_capture", "succeeded"}: + logging.error(f"Auto-renew create_payment failed: {resp}") + return False + logging.info(f"Auto-renew initiated for user {sub.user_id} payment_id={resp.get('id')}") + return True + async def update_last_notification_sent( self, session: AsyncSession, user_id: int, subscription_end_date: datetime ): diff --git a/bot/services/yookassa_service.py b/bot/services/yookassa_service.py index 43aaeae..1d98734 100644 --- a/bot/services/yookassa_service.py +++ b/bot/services/yookassa_service.py @@ -61,7 +61,10 @@ class YooKassaService: description: str, metadata: Dict[str, Any], receipt_email: Optional[str] = None, - receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]: + receipt_phone: Optional[str] = None, + save_payment_method: bool = False, + payment_method_id: Optional[str] = None, + capture: bool = True) -> Optional[Dict[str, Any]]: if not self.configured: logging.error("YooKassa is not configured. Cannot create payment.") return None @@ -102,13 +105,19 @@ class YooKassaService: "value": str(round(amount, 2)), "currency": currency.upper() }) - builder.set_capture(True) + builder.set_capture(capture) 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) + 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": @@ -178,7 +187,8 @@ class YooKassaService: "description_from_yk": response.description, "test_mode": - response.test if hasattr(response, 'test') else None + response.test if hasattr(response, 'test') else None, + "payment_method": getattr(response, 'payment_method', None), } except Exception as e: logging.error(f"YooKassa payment creation failed: {e}", diff --git a/db/dal/subscription_dal.py b/db/dal/subscription_dal.py index 6a0b0e6..1785c82 100644 --- a/db/dal/subscription_dal.py +++ b/db/dal/subscription_dal.py @@ -53,6 +53,11 @@ async def update_subscription( return sub +async def set_auto_renew(session: AsyncSession, subscription_id: int, enabled: bool) -> Optional[Subscription]: + """Toggle auto_renew_enabled for a subscription.""" + return await update_subscription(session, subscription_id, {"auto_renew_enabled": enabled}) + + async def set_user_subscriptions_cancelled_with_grace( session: AsyncSession, user_id: int, grace_days: int = 1) -> int: """Mark all active user subscriptions as cancelled with a short grace period. diff --git a/db/dal/user_billing_dal.py b/db/dal/user_billing_dal.py new file mode 100644 index 0000000..597fd75 --- /dev/null +++ b/db/dal/user_billing_dal.py @@ -0,0 +1,41 @@ +from typing import Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update +from sqlalchemy.sql import func + +from db.models import UserBilling + + +async def get_user_billing(session: AsyncSession, user_id: int) -> Optional[UserBilling]: + stmt = select(UserBilling).where(UserBilling.user_id == user_id) + result = await session.execute(stmt) + return result.scalar_one_or_none() + + +async def upsert_yk_payment_method( + session: AsyncSession, + *, + user_id: int, + payment_method_id: str, + card_last4: Optional[str] = None, + card_network: Optional[str] = None, +) -> UserBilling: + existing = await get_user_billing(session, user_id) + if existing: + existing.yookassa_payment_method_id = payment_method_id + existing.card_last4 = card_last4 + existing.card_network = card_network + existing.updated_at = func.now() + await session.flush() + await session.refresh(existing) + return existing + record = UserBilling( + user_id=user_id, + yookassa_payment_method_id=payment_method_id, + card_last4=card_last4, + card_network=card_network, + ) + session.add(record) + await session.flush() + await session.refresh(record) + return record diff --git a/db/models.py b/db/models.py index c8e2c4a..c53e23a 100644 --- a/db/models.py +++ b/db/models.py @@ -72,6 +72,7 @@ class Subscription(Base): last_notification_sent = Column(DateTime(timezone=True), nullable=True) provider = Column(String, nullable=True) skip_notifications = Column(Boolean, default=False) + auto_renew_enabled = Column(Boolean, default=False, index=True) user = relationship("User", back_populates="subscriptions") @@ -112,6 +113,19 @@ class Payment(Base): back_populates="payments_where_used") +class UserBilling(Base): + __tablename__ = "user_billing" + + user_id = Column(BigInteger, ForeignKey("users.user_id"), primary_key=True) + # Saved payment method for off-session recurring charges (YooKassa) + yookassa_payment_method_id = Column(String, nullable=True, unique=True) + card_last4 = Column(String, nullable=True) + card_network = Column(String, nullable=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now(), nullable=True) + + user = relationship("User") + class PromoCode(Base): __tablename__ = "promo_codes" diff --git a/docker-compose.yml b/docker-compose.yml index 4282534..4fbdbaf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,6 +11,9 @@ services: volumes: - ./locales:/app/locales restart: unless-stopped + depends_on: + remnawave-tg-shop-db: + condition: service_healthy remnawave-tg-shop-db: image: postgres:17 @@ -23,6 +26,11 @@ services: networks: - remnawave-network restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 20 networks: remnawave-network: diff --git a/locales/en.json b/locales/en.json index 9e511fe..cec81e0 100644 --- a/locales/en.json +++ b/locales/en.json @@ -371,6 +371,12 @@ "admin_sync_not_found_in_db": "\n❌ Not found in DB: {count}", "admin_payments_pagination_info": "📊 Showing {shown} of {total} payments (page {current_page}/{total_pages})", "my_subscription_details": "🔐 My Subscription\n\n⏰ Status: {status}\n📅 Active until: {end_date}\n📆 Days left: {days_left}\n\n🔗 Configuration link:\n{config_link}\n\n📊 Traffic:\nLimit: {traffic_limit}\nUsed: {traffic_used}", + "autorenew_enable_button": "Enable auto-renew", + "autorenew_disable_button": "Disable auto-renew", + "subscription_autorenew_updated": "Auto-renew settings updated.", + "subscription_tribute_notice": "Paid via Tribute. Renew using your Tribute link.", + "subscription_tribute_notice_with_link": "Paid via Tribute. Renew: {link}", + "subscription_autorenew_not_supported_for_tribute": "Auto-renew is handled by Tribute. Manage renewal in the Tribute app/link.", "subscription_not_active": "You don't have an active subscription.", "error_service_unavailable": "Service unavailable. Please try again later.", "error_payment_gateway": "Payment service error. Please try again later.", diff --git a/locales/ru.json b/locales/ru.json index b58b394..fe28c90 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -370,6 +370,12 @@ "admin_sync_not_found_in_db": "\n❌ Не найдено в БД: {count}", "admin_payments_pagination_info": "📊 Показано {shown} из {total} платежей (стр. {current_page}/{total_pages})", "my_subscription_details": "🔐 Моя подписка\n\n⏰ Статус: {status}\n📅 Действует до: {end_date}\n📆 Осталось дней: {days_left}\n\n🔗 Ссылка на конфигурацию:\n{config_link}\n\n📊 Трафик:\nЛимит: {traffic_limit}\nИспользовано: {traffic_used}", + "autorenew_enable_button": "Включить автопродление", + "autorenew_disable_button": "Выключить автопродление", + "subscription_autorenew_updated": "Настройки автопродления обновлены.", + "subscription_tribute_notice": "Оплачено через Tribute. Продление делайте по ссылке Tribute.", + "subscription_tribute_notice_with_link": "Оплачено через Tribute. Продлить: {link}", + "subscription_autorenew_not_supported_for_tribute": "Автопродление управляется Tribute. Управляйте продлением в приложении/ссылке Tribute.", "subscription_not_active": "У вас нет активной подписки.", "error_service_unavailable": "Сервис недоступен. Попробуйте позже.", "error_payment_gateway": "Ошибка платежного сервиса. Попробуйте позже.",