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": "Ошибка платежного сервиса. Попробуйте позже.",