feat(subscription): improve subscription sync and renewal logic.

This commit is contained in:
2026-06-17 18:10:56 +07:00
parent 7ab0e71108
commit 245741b182
8 changed files with 88 additions and 32 deletions

View File

@@ -25,9 +25,18 @@ from bot.texts import (
) )
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from misc.utils import build_main_menu_text, format_subscription_link from misc.utils import (
build_main_menu_text,
convert_duration_to_int,
format_subscription_link,
)
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from schemas.subscriptions import InternalSquads, SubscriptionPlan from schemas.subscriptions import (
InternalSquads,
SubscriptionDuration,
SubscriptionPlan,
SubscriptionStatus,
)
from services.rw import delete_hwid, get_hwid_list, get_user_by_telegram_id from services.rw import delete_hwid, get_hwid_list, get_user_by_telegram_id
router = Router() router = Router()
@@ -140,25 +149,40 @@ async def sub_info(
await cb.answer(CALLBACK_FALLBACK) await cb.answer(CALLBACK_FALLBACK)
return return
dto = SubscriptionPlan(
devices=max(rw_user.hwid_device_limit, 3),
whitelists=bool(rw_user.active_squads.count(InternalSquads.WHITELISTS)),
)
if rw_user and not sub: if rw_user and not sub:
try: try:
dto = SubscriptionPlan(
devices=rw_user.hwid_device_limit,
whitelists=bool(rw_user.active_squads.count(InternalSquads.WHITELISTS)),
discount=0,
)
sub = await deps.subscriptions_repository.create_subscription( sub = await deps.subscriptions_repository.create_subscription(
session, session,
user_id=user.id, user_id=user.id,
end_date=rw_user.expire_at, end_date=rw_user.expire_at,
devices=rw_user.hwid_device_limit, devices=dto.devices,
duration_days=30, duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
whitelists=dto.whitelists, whitelists=dto.whitelists,
plan_price_paid=utils.calculate_price(dto), plan_price_paid=utils.calculate_price(dto),
) )
except Exception: except Exception:
await cb.answer(CALLBACK_FALLBACK) await cb.answer(CALLBACK_FALLBACK)
return return
else:
await deps.subscriptions_repository.update_sub(
session,
cb.from_user.id,
status=(
SubscriptionStatus.ACTIVE
if rw_user.status == "ACTIVE"
else SubscriptionStatus.EXPIRED
),
end_date=rw_user.expire_at,
devices=dto.devices,
whitelists=dto.whitelists,
duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
plan_price_paid=utils.calculate_price(dto),
autorenew=sub.autorenew
)
await cb.message.edit_text( await cb.message.edit_text(
deps.user_service.format_subscription_message( deps.user_service.format_subscription_message(

View File

@@ -64,7 +64,9 @@ def subscription_controls(
) )
builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal")) builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal"))
builder.row(InlineKeyboardButton(text="💻 Управление устройствами", callback_data="hwid_control")) builder.row(
InlineKeyboardButton(text="💻 Управление устройствами", callback_data="hwid_control")
)
builder.row(_return_to_menu_btn) builder.row(_return_to_menu_btn)
return builder.as_markup() return builder.as_markup()

View File

@@ -239,7 +239,7 @@ AUTORENEWAL_INSUFFICIENT_FUNDS = (
) )
AUTORENEWAL_SUCCESS = ( AUTORENEWAL_SUCCESS = (
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>" f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>\n"
"Новая дата окончания: <code>{end_date}</code>." "Новая дата окончания: <code>{end_date}</code>."
) )

View File

@@ -16,7 +16,6 @@ class SubscriptionRepository:
select(Subscription) select(Subscription)
.where( .where(
Subscription.autorenew == True, # noqa: E712 Subscription.autorenew == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE,
Subscription.end_date <= threshold, Subscription.end_date <= threshold,
) )
.with_for_update() .with_for_update()
@@ -69,7 +68,7 @@ class SubscriptionRepository:
whitelists: bool, whitelists: bool,
duration_days: int, duration_days: int,
plan_price_paid: int, plan_price_paid: int,
autorenew: bool = False, autorenew: bool,
status: SubscriptionStatus = SubscriptionStatus.ACTIVE, status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
) -> Subscription | None: ) -> Subscription | None:
stmt = ( stmt = (

View File

@@ -99,13 +99,13 @@ async def notify_subscription_renewal(
bot, bot,
settings.admin_log_subscriptions_topic_id, settings.admin_log_subscriptions_topic_id,
( (
"<b>Продление подписки</b>", "<b>Продление подписки</b>\n\n"
f"Пользователь: <code>{user_id}</code>", f"Пользователь: <code>{user_id}</code>\n"
f"Стоимость: <b>{amount}₽</b>", f"Стоимость: <b>{amount}₽</b>\n"
f"Баланс: {balance_before}₽ -> {balance_after}", f"Баланс: {balance_before}₽ -> {balance_after}\n"
f"Устройства: {devices}", f"Устройства: {devices}\n"
f"Срок: {duration_days} дн.", f"Срок: {duration_days} дн.\n"
f"Whitelist: {'да' if whitelists else 'нет'}", f"Whitelist: {'да' if whitelists else 'нет'}\n"
f"Новая дата окончания: {escape(end_date)}", f"Новая дата окончания: {escape(end_date)}"
), ),
) )

View File

@@ -49,7 +49,9 @@ class RenewalService:
) )
return utils.calculate_price(plan) return utils.calculate_price(plan)
async def _renew_subscription_in_rw(self, subscription: Subscription) -> None: async def _renew_subscription_in_rw(
self, subscription: Subscription, expire_at: datetime.datetime
) -> None:
rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id) rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id)
if not rw_user: if not rw_user:
raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}") raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}")
@@ -58,7 +60,7 @@ class RenewalService:
if subscription.whitelists: if subscription.whitelists:
squads.append(InternalSquads.WHITELISTS) squads.append(InternalSquads.WHITELISTS)
await rw_service.add_days(self.rw_sdk, rw_user.uuid, subscription.duration_days) await rw_service.update_expire_at(self.rw_sdk, rw_user.uuid, expire_at)
await rw_service.update_squads(self.rw_sdk, rw_user.uuid, squads) 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) await rw_service.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices)
@@ -89,7 +91,9 @@ class RenewalService:
await self.subscription_repository.update_subscription_autorenew( await self.subscription_repository.update_subscription_autorenew(
session, user_id, False session, user_id, False
) )
await self._notify_user(user_id, AUTORENEWAL_INSUFFICIENT_FUNDS) await self._notify_user(
user_id, AUTORENEWAL_INSUFFICIENT_FUNDS.format(price=price, balance=user.balance)
)
return False return False
renewed_user = await self.user_repository.decrease_user_balance( renewed_user = await self.user_repository.decrease_user_balance(
@@ -104,8 +108,14 @@ class RenewalService:
logger.error("Failed to decrease balance for user %d", user_id) logger.error("Failed to decrease balance for user %d", user_id)
return False return False
now_utc = datetime.datetime.now(datetime.UTC)
if subscription.end_date > now_utc:
new_expire = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
else:
new_expire = now_utc + datetime.timedelta(days=subscription.duration_days)
try: try:
await self._renew_subscription_in_rw(subscription) await self._renew_subscription_in_rw(subscription, new_expire)
except RenewalServiceError: except RenewalServiceError:
logger.exception("Failed to renew subscription in RW for user %d", user_id) logger.exception("Failed to renew subscription in RW for user %d", user_id)
await self.user_repository.increase_user_balance( await self.user_repository.increase_user_balance(
@@ -117,11 +127,10 @@ class RenewalService:
) )
return False return False
new_end_date = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
await self.subscription_repository.update_sub( await self.subscription_repository.update_sub(
session, session,
user_id, user_id,
end_date=new_end_date, end_date=new_expire,
autorenew=True, autorenew=True,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=subscription.devices, devices=subscription.devices,
@@ -134,7 +143,7 @@ class RenewalService:
user_id, user_id,
AUTORENEWAL_SUCCESS.format( AUTORENEWAL_SUCCESS.format(
price=price, price=price,
end_date=new_end_date.strftime("%d.%m.%Y"), end_date=new_expire.strftime("%d.%m.%Y"),
), ),
) )
await notify_subscription_renewal( await notify_subscription_renewal(
@@ -146,7 +155,7 @@ class RenewalService:
devices=subscription.devices, devices=subscription.devices,
duration_days=subscription.duration_days, duration_days=subscription.duration_days,
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
end_date=new_end_date.strftime("%d.%m.%Y"), end_date=new_expire.strftime("%d.%m.%Y"),
) )
logger.info("Successfully renewed subscription for user %d", user_id) logger.info("Successfully renewed subscription for user %d", user_id)

View File

@@ -372,5 +372,15 @@ async def delete_hwid(sdk: RemnawaveSDK, user_uuid: str, hwid: str) -> bool:
resp = await sdk.hwid.delete_hwid_to_user(body) resp = await sdk.hwid.delete_hwid_to_user(body)
return isinstance(resp, DeleteUserHwidDeviceResponseDto) return isinstance(resp, DeleteUserHwidDeviceResponseDto)
except Exception: except Exception:
logger.exception("failed to fetch hwid list for user=%s", user_uuid) logger.exception("failed to delete hwid=%s for user=%s", hwid, user_uuid)
return False
async def update_expire_at(sdk: RemnawaveSDK, user_uuid: str, expire_at: datetime):
dto = UpdateUserRequestDto(uuid=user_uuid, expire_at=expire_at) # type: ignore
try:
await sdk.users.update_user(body=dto)
return True
except Exception:
logger.exception("failed to update expire_at=%s for user=%s", str(expire_at), user_uuid)
return False return False

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION
from config import settings from config import settings
from db.models import Subscription
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
@@ -122,7 +123,7 @@ class UserService:
session, session,
user.id, user.id,
end_date=rw_user.expire_at + duration, end_date=rw_user.expire_at + duration,
autorenew=False, autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices), devices=int(subscription.devices),
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
@@ -179,7 +180,7 @@ class UserService:
session, session,
user.id, user.id,
end_date=expire, end_date=expire,
autorenew=False, autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices), devices=int(subscription.devices),
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
@@ -239,3 +240,14 @@ class UserService:
logger.exception("caught an exception while launching promo, skipping user...") logger.exception("caught an exception while launching promo, skipping user...")
return sent return sent
def validate_db_subscription(self, subscription: Subscription, rw_user: rw.RWUserInfo):
return all(
[
(rw_user.status != "ACTIVE" and subscription.status == SubscriptionStatus.EXPIRED)
or (
rw_user.status == "ACTIVE" and subscription.status == SubscriptionStatus.ACTIVE
),
rw_user.expire_at == subscription.end_date,
]
)