diff --git a/.env.example b/.env.example
index 60bc553..610a024 100644
--- a/.env.example
+++ b/.env.example
@@ -36,4 +36,10 @@ WHITELIST_DEVICE_THRESHOLD=6
# Squads
GENERAL_SQUAD=2de0b5ea-49b9-4181-916f-ae67b9c83b80
-WHITELIST_SQUAD=e4435baa-64d3-4b29-a18e-81ad1c60d977
\ No newline at end of file
+WHITELIST_SQUAD=e4435baa-64d3-4b29-a18e-81ad1c60d977
+
+# Autorenewal
+AUTORENEW_DAYS_BEFORE=1
+
+# Autorenewal
+AUTORENEW_DAYS_BEFORE=1
\ No newline at end of file
diff --git a/alembic/versions/0d71262de0d5_add_autorenew_to_balancetxtype.py b/alembic/versions/0d71262de0d5_add_autorenew_to_balancetxtype.py
new file mode 100644
index 0000000..8a5d257
--- /dev/null
+++ b/alembic/versions/0d71262de0d5_add_autorenew_to_balancetxtype.py
@@ -0,0 +1,40 @@
+"""add_autorenew_to_balancetxtype
+
+Revision ID: 0d71262de0d5
+Revises: f5fbc1975807
+Create Date: 2026-05-18 17:26:23.302066
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = '0d71262de0d5'
+down_revision: Union[str, Sequence[str], None] = 'f5fbc1975807'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ op.execute("ALTER TYPE balancetxtype ADD VALUE 'autorenew'")
+
+
+def downgrade() -> None:
+ # PostgreSQL не поддерживает удаление значений из ENUM напрямую.
+ # Для даунгрейда нужно пересоздать тип, как в миграции f0668f8e7310.
+ op.execute("""
+ CREATE TYPE balancetxtype_old AS ENUM (
+ 'deposit', 'purchase', 'refund', 'referral', 'manual'
+ )
+ """)
+ op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
+ op.execute("""
+ ALTER TABLE balance_transactions
+ ALTER COLUMN tx_type TYPE balancetxtype_old
+ USING (tx_type::text::balancetxtype_old)
+ """)
+ op.execute("DROP TYPE balancetxtype")
+ op.execute("ALTER TYPE balancetxtype_old RENAME TO balancetxtype")
diff --git a/alembic/versions/bb4199d71f9e_add_autorenew_to_balancetxtype.py b/alembic/versions/bb4199d71f9e_add_autorenew_to_balancetxtype.py
new file mode 100644
index 0000000..04ef02b
--- /dev/null
+++ b/alembic/versions/bb4199d71f9e_add_autorenew_to_balancetxtype.py
@@ -0,0 +1,28 @@
+"""add_autorenew_to_balancetxtype
+
+Revision ID: bb4199d71f9e
+Revises: 0d71262de0d5
+Create Date: 2026-05-18 17:26:23.454401
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision: str = 'bb4199d71f9e'
+down_revision: Union[str, Sequence[str], None] = '0d71262de0d5'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ pass
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ pass
diff --git a/alembic/versions/f5fbc1975807_id_unique_deleted_deprecated_tables.py b/alembic/versions/f5fbc1975807_id_unique_deleted_deprecated_tables.py
new file mode 100644
index 0000000..b66c1e4
--- /dev/null
+++ b/alembic/versions/f5fbc1975807_id_unique_deleted_deprecated_tables.py
@@ -0,0 +1,42 @@
+"""id->unique, deleted deprecated tables
+
+Revision ID: f5fbc1975807
+Revises: 92dfa9b572f1
+Create Date: 2026-05-18 17:08:56.792369
+
+"""
+from typing import Sequence, Union
+
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import postgresql
+
+# revision identifiers, used by Alembic.
+revision: str = 'f5fbc1975807'
+down_revision: Union[str, Sequence[str], None] = '92dfa9b572f1'
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ """Upgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('tickets')
+ op.create_unique_constraint(None, 'bills', ['id'])
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ """Downgrade schema."""
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_constraint(None, 'bills', type_='unique')
+ op.create_table('tickets',
+ sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
+ sa.Column('creator_id', sa.BIGINT(), autoincrement=False, nullable=False),
+ sa.Column('thread_id', sa.BIGINT(), autoincrement=False, nullable=True),
+ sa.Column('status', postgresql.ENUM('OPEN', 'CLOSED', 'PENDING', name='ticketstatus'), autoincrement=False, nullable=False),
+ sa.PrimaryKeyConstraint('id', name=op.f('tickets_pkey')),
+ sa.UniqueConstraint('id', name=op.f('tickets_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False),
+ sa.UniqueConstraint('thread_id', name=op.f('tickets_thread_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
+ )
+ # ### end Alembic commands ###
diff --git a/bot/handlers/client/menus.py b/bot/handlers/client/menus.py
index bdd24a9..0b1f6ed 100644
--- a/bot/handlers/client/menus.py
+++ b/bot/handlers/client/menus.py
@@ -166,9 +166,6 @@ async def toggle_autorenewal(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
- await cb.answer(NOTHING_PLACEHOLDER + " В разработке.")
- return
-
await cb.message.edit_text(GENERAL_PROCESSING)
sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id)
@@ -186,7 +183,24 @@ async def toggle_autorenewal(
session, cb.from_user.id, deps.rw_sdk
)
- await cb.answer(GENERAL_SUCCESS)
+ status_text = "включено" if new_autorenew else "отключено"
+ await cb.answer(f"Автопродление {status_text}")
+ await cb.message.edit_text(
+ deps.user_service.format_subscription_message(
+ rw_user, link=user.subscription_link, autorenew=sub.autorenew
+ ),
+ reply_markup=subscription_controls(
+ show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
+ ),
+ )
+
+ rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
+ user: User | None = await deps.user_service.update_user_link(
+ session, cb.from_user.id, deps.rw_sdk
+ )
+
+ status_text = "включено" if new_autorenew else "отключено"
+ await cb.answer(f"Автопродление {status_text}")
await cb.message.edit_text(
deps.user_service.format_subscription_message(
rw_user, link=user.subscription_link, autorenew=sub.autorenew
diff --git a/bot/main.py b/bot/main.py
index 4bebd49..3451ec4 100644
--- a/bot/main.py
+++ b/bot/main.py
@@ -8,6 +8,7 @@ from remnawave import RemnawaveSDK
from bot.handlers import admin_routers, client_routers, system_routers
from bot.middlewares import DIMiddleware
+from bot.scheduler import start_scheduler
from config import settings
from db.session import async_session
from misc.pally import PallyClient
@@ -16,6 +17,7 @@ from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository
from schemas.di import DependenciesDTO
from services.billing_service import BillingService
+from services.renewal_service import RenewalService
from services.user_service import UserService
logging.basicConfig(level=logging.DEBUG)
@@ -40,6 +42,17 @@ async def main():
token=settings.remnawave_token,
)
+ aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
+ default = DefaultBotProperties(parse_mode="HTML")
+ bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
+
+ renewal_service = RenewalService(
+ subscription_repository=subscriptions_repository,
+ user_repository=user_repository,
+ rw_sdk=rw_sdk,
+ bot=bot,
+ )
+
deps = DependenciesDTO(
user_repository=user_repository,
user_service=user_service,
@@ -48,13 +61,10 @@ async def main():
bills_repository=bills_repository,
billing_service=billing_service,
subscriptions_repository=subscriptions_repository,
+ renewal_service=renewal_service,
)
dp.update.middleware.register(DIMiddleware(deps, async_session))
- aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
- default = DefaultBotProperties(parse_mode="HTML")
- bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
-
for r in admin_routers:
dp.include_router(r)
for r in client_routers:
@@ -62,7 +72,16 @@ async def main():
for r in system_routers:
dp.include_router(r)
- await dp.start_polling(bot)
+ scheduler_task = asyncio.create_task(start_scheduler(renewal_service, async_session))
+
+ try:
+ await dp.start_polling(bot)
+ finally:
+ scheduler_task.cancel()
+ try:
+ await scheduler_task
+ except asyncio.CancelledError:
+ pass
if __name__ == "__main__":
diff --git a/bot/scheduler.py b/bot/scheduler.py
new file mode 100644
index 0000000..93d9d36
--- /dev/null
+++ b/bot/scheduler.py
@@ -0,0 +1,29 @@
+import asyncio
+import logging
+
+from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
+
+from services.renewal_service import RenewalService
+
+logger = logging.getLogger(__name__)
+
+
+async def run_autorenewal(renewal_service: RenewalService, session_factory: async_sessionmaker):
+ async with session_factory() as session:
+ try:
+ results = await renewal_service.process_autorenewals(session)
+ logger.info("Autorenewal job completed: %s", results)
+ except Exception:
+ logger.exception("Autorenewal job failed")
+ await session.rollback()
+
+
+async def start_scheduler(
+ renewal_service: RenewalService,
+ session_factory: async_sessionmaker,
+ interval_hours: int = 24,
+):
+ logger.info("Starting autorenewal scheduler with interval %dh", interval_hours)
+ while True:
+ await run_autorenewal(renewal_service, session_factory)
+ await asyncio.sleep(interval_hours * 3600)
diff --git a/bot/texts.py b/bot/texts.py
index 19d20f5..c46bb31 100644
--- a/bot/texts.py
+++ b/bot/texts.py
@@ -219,3 +219,12 @@ ADM_VERIFY_PROMOTION = (
"❓ Отправить рассылку? Это действие будет невозможно отменить."
)
SUCCESSFULLY_SENT_PROMO = "💚 Успешно отправлена рассылка.\n⚡ Успешных отправок: {successful}."
+
+AUTORENEWAL_INSUFFICIENT_FUNDS = (
+ "⚠️ Не удалось продлить подписку автоматически.\n"
+ "Недостаточно средств на балансе (нужно {price}₽, доступно {balance}₽). Автопродление отключено."
+)
+AUTORENEWAL_SUCCESS = (
+ "✅ Подписка продлена автоматически. Списано {price}₽."
+ "Новая дата окончания: {end_date}."
+)
\ No newline at end of file
diff --git a/config.py b/config.py
index 96d286b..695a8ee 100644
--- a/config.py
+++ b/config.py
@@ -34,5 +34,9 @@ class Settings(BaseSettings):
general_squad: str = Field("2de0b5ea-49b9-4181-916f-ae67b9c83b80")
whitelist_squad: str = Field("e4435baa-64d3-4b29-a18e-81ad1c60d977")
+ autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE")
+
+ autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE")
+
settings = Settings() # type: ignore
diff --git a/db/models/transactions.py b/db/models/transactions.py
index da1e16c..b4e71d1 100644
--- a/db/models/transactions.py
+++ b/db/models/transactions.py
@@ -17,6 +17,7 @@ class BalanceTxType(StrEnum):
REFUND = "refund"
REFERRAL_BONUS = "referral"
MANUAL = "manual"
+ AUTORENEW = "autorenew"
class BalanceTransaction(Base):
diff --git a/repositories/subscriptions.py b/repositories/subscriptions.py
index 67a990c..1e53b2e 100644
--- a/repositories/subscriptions.py
+++ b/repositories/subscriptions.py
@@ -1,4 +1,4 @@
-from datetime import datetime
+from datetime import datetime, timezone
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,6 +7,24 @@ from db.models.subscriptions import Subscription, SubscriptionStatus
class SubscriptionRepository:
+ async def get_subscriptions_for_autorenewal(
+ self, session: AsyncSession, *, days_before_expiry: int = 1
+ ) -> list[Subscription]:
+ from datetime import timedelta
+
+ threshold = datetime.now(timezone.utc) + timedelta(days=days_before_expiry)
+ stmt = (
+ select(Subscription)
+ .where(
+ Subscription.autorenew == True,
+ Subscription.status == SubscriptionStatus.ACTIVE,
+ Subscription.end_date <= threshold,
+ )
+ .with_for_update()
+ )
+ result = await session.execute(stmt)
+ return list(result.scalars().all())
+
async def get_subscription_by_user_id(
self, session: AsyncSession, user_id: int
) -> Subscription | None:
diff --git a/repositories/users.py b/repositories/users.py
index 0c7c83c..30f74b8 100644
--- a/repositories/users.py
+++ b/repositories/users.py
@@ -112,7 +112,7 @@ class UserRepository:
amount=amount,
tx_type=tx_type,
balance_before=user.balance,
- balance_after=user.balance + amount,
+ balance_after=user.balance - amount,
description=description,
created_at=created_at,
)
diff --git a/schemas/di.py b/schemas/di.py
index 38c1c52..9c269f0 100644
--- a/schemas/di.py
+++ b/schemas/di.py
@@ -1,5 +1,6 @@
from dataclasses import dataclass
+from aiogram import Bot
from remnawave import RemnawaveSDK
from misc.pally import PallyClient
@@ -7,6 +8,7 @@ from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository
from services.billing_service import BillingService
+from services.renewal_service import RenewalService
from services.user_service import UserService
@@ -19,6 +21,7 @@ class DependenciesDTO:
subscriptions_repository: SubscriptionRepository
pally_client: PallyClient
rw_sdk: RemnawaveSDK
+ renewal_service: RenewalService
@dataclass
diff --git a/services/renewal_service.py b/services/renewal_service.py
new file mode 100644
index 0000000..68eda61
--- /dev/null
+++ b/services/renewal_service.py
@@ -0,0 +1,186 @@
+import datetime
+import logging
+
+from aiogram import Bot
+from remnawave import RemnawaveSDK
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from bot.texts import AUTORENEWAL_INSUFFICIENT_FUNDS, AUTORENEWAL_SUCCESS
+from config import settings
+from db.models.subscriptions import Subscription
+from db.models.transactions import BalanceTxType
+from misc import utils
+from repositories.subscriptions import SubscriptionRepository
+from repositories.users import UserRepository
+from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
+
+logger = logging.getLogger(__name__)
+
+
+class RenewalServiceError(Exception):
+ """Base exception for renewal service errors."""
+
+
+class InsufficientBalanceError(RenewalServiceError):
+ """Raised when user has insufficient balance for renewal."""
+
+
+class RenewalService:
+ def __init__(
+ self,
+ subscription_repository: SubscriptionRepository,
+ user_repository: UserRepository,
+ rw_sdk: RemnawaveSDK,
+ bot: Bot,
+ ) -> None:
+ self.subscription_repository = subscription_repository
+ self.user_repository = user_repository
+ self.rw_sdk = rw_sdk
+ self.bot = bot
+
+ async def _calculate_renewal_price(self, subscription: Subscription) -> int:
+ duration = utils.convert_int_to_duration(subscription.duration_days)
+ plan = SubscriptionPlan(
+ devices=subscription.devices,
+ duration=duration,
+ whitelists=subscription.whitelists,
+ )
+ return utils.calculate_price(plan)
+
+ async def _renew_subscription_in_rw(self, subscription: Subscription) -> None:
+ from services import rw as rw_service
+
+ rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id)
+ if not rw_user:
+ raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}")
+
+ duration = datetime.timedelta(days=subscription.duration_days)
+ squads = [InternalSquads.DEFAULT]
+ if subscription.whitelists:
+ squads.append(InternalSquads.WHITELISTS)
+
+ if rw_user.expire_at > datetime.datetime.now(datetime.UTC):
+ new_expire = rw_user.expire_at + duration
+ else:
+ new_expire = datetime.datetime.now(datetime.UTC) + duration
+
+ await rw_service.add_days(self.rw_sdk, rw_user.uuid, subscription.duration_days)
+ await rw_service.update_squads(self.rw_sdk, rw_user.uuid, squads)
+ await rw_service.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices)
+
+ async def _notify_user(self, user_id: int, message: str) -> None:
+ try:
+ await self.bot.send_message(user_id, message)
+ except Exception:
+ logger.warning("Failed to notify user %d about renewal", user_id, exc_info=True)
+
+ async def _process_single_renewal(
+ self, session: AsyncSession, subscription: Subscription
+ ) -> bool:
+ user_id = subscription.user_id
+ price = await self._calculate_renewal_price(subscription)
+
+ user = await self.user_repository.get_user_by_id(session, user_id)
+ if not user:
+ logger.error("User %d not found for autorenewal", user_id)
+ return False
+
+ if user.balance < price:
+ logger.info(
+ "Insufficient balance for user %d: balance=%d, price=%d",
+ user_id,
+ user.balance,
+ price,
+ )
+ await self.subscription_repository.update_subscription_autorenew(
+ session, user_id, False
+ )
+ await self._notify_user(
+ user_id,
+ AUTORENEWAL_INSUFFICIENT_FUNDS
+ )
+ return False
+
+ result = await self.user_repository.decrease_user_balance(
+ session,
+ user_id,
+ price,
+ tx_type=BalanceTxType.AUTORENEW,
+ description=f"autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d, whitelists={subscription.whitelists}",
+ )
+
+ if not result:
+ logger.error("Failed to decrease balance for user %d", user_id)
+ return False
+
+ try:
+ await self._renew_subscription_in_rw(subscription)
+ except RenewalServiceError:
+ logger.exception("Failed to renew subscription in RW for user %d", user_id)
+ await self.user_repository.increase_user_balance(
+ session,
+ user_id,
+ price,
+ tx_type=BalanceTxType.REFUND,
+ description=f"refund for failed autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d",
+ )
+ return False
+
+ new_end_date = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
+ await self.subscription_repository.update_sub(
+ session,
+ user_id,
+ end_date=new_end_date,
+ autorenew=True,
+ status=SubscriptionStatus.ACTIVE,
+ devices=subscription.devices,
+ whitelists=subscription.whitelists,
+ duration_days=subscription.duration_days,
+ plan_price_paid=price,
+ )
+
+ await self._notify_user(
+ user_id,
+ AUTORENEWAL_SUCCESS.format(
+ price=price,
+ end_date=new_end_date.strftime('%d.%m.%Y'),
+ ),
+ )
+
+ logger.info("Successfully renewed subscription for user %d", user_id)
+ return True
+
+ async def process_autorenewals(self, session: AsyncSession) -> dict:
+ subscriptions = await self.subscription_repository.get_subscriptions_for_autorenewal(
+ session, days_before_expiry=settings.autorenew_days_before
+ )
+
+ logger.info("Found %d subscriptions for autorenewal", len(subscriptions))
+
+ results = {"success": 0, "failed": 0, "insufficient_balance": 0}
+
+ for subscription in subscriptions:
+ try:
+ success = await self._process_single_renewal(session, subscription)
+ if success:
+ results["success"] += 1
+ else:
+ user = await self.user_repository.get_user_by_id(session, subscription.user_id)
+ if user and user.balance < await self._calculate_renewal_price(subscription):
+ results["insufficient_balance"] += 1
+ else:
+ results["failed"] += 1
+ except Exception:
+ logger.exception(
+ "Error processing autorenewal for user %d", subscription.user_id
+ )
+ results["failed"] += 1
+
+ logger.info(
+ "Autorenewal completed: success=%d, failed=%d, insufficient_balance=%d",
+ results["success"],
+ results["failed"],
+ results["insufficient_balance"],
+ )
+
+ return results