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.
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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]}")
|
||||
|
||||
|
||||
@@ -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
|
||||
):
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user