From db2dabed58ed1c5e95d571249a3c99a999ff650e Mon Sep 17 00:00:00 2001 From: hexdev Date: Wed, 29 Apr 2026 20:10:26 +0700 Subject: [PATCH] feat: implement subscription management and billing enhancements --- bot/handlers/buy.py | 16 ++++++- bot/handlers/deposit.py | 13 +++++- bot/handlers/menus.py | 82 ++++++++++++++++++++++++++++++++--- bot/keyboards/client.py | 14 ++++++ bot/main.py | 2 +- bot/texts.py | 7 +++ config.py | 1 + misc/pally.py | 45 ++++++++++++++----- misc/utils.py | 12 ++++- repositories/subscriptions.py | 15 +++++++ repositories/users.py | 2 +- services/user_service.py | 42 ++++++++++++++---- 12 files changed, 223 insertions(+), 28 deletions(-) diff --git a/bot/handlers/buy.py b/bot/handlers/buy.py index 6653a7c..73f69f4 100644 --- a/bot/handlers/buy.py +++ b/bot/handlers/buy.py @@ -169,7 +169,12 @@ async def proceed_to_checkout( amount = calculate_price(ctx) user = await deps.user_repository.get_user_by_id(session, cb.from_user.id) - if user.balance < amount: + credit = await deps.user_service.calculate_plan_credit( + session, cb.from_user.id, ctx, deps.rw_sdk + ) + effective_balance = user.balance + credit + + if effective_balance < amount: await cb.message.edit_text( INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), reply_markup=deposit_kb ) @@ -190,6 +195,15 @@ async def proceed_to_checkout( await cb.message.edit_text(CALLBACK_FALLBACK) return + if credit > 0: + await deps.user_repository.increase_user_balance( + session, + user.id, + credit, + tx_type=BalanceTxType.REFUND, + description="refund for unused subscription days", + ) + deducted_user = await deps.user_repository.decrease_user_balance( session, user.id, diff --git a/bot/handlers/deposit.py b/bot/handlers/deposit.py index f83703b..dc53f5f 100644 --- a/bot/handlers/deposit.py +++ b/bot/handlers/deposit.py @@ -8,8 +8,10 @@ from bot.states.deposit import DepositStorage from bot.texts import ( CHECKOUT_PAYMENT_CHOICE, DEPOSIT_AMOUNT, + DEPOSIT_AMOUNT_BELOW_MINIMAL, DEPOSIT_AMOUNT_FALLBACK, ) +from config import settings from schemas.billing import BillingContext from schemas.common import GeneralMessageContext @@ -22,7 +24,7 @@ async def deposit_init(cb: CallbackQuery, state: FSMContext): await cb.message.edit_text(DEPOSIT_AMOUNT, reply_markup=return_to_menu) - ctx = GeneralMessageContext(cb.message) + ctx = GeneralMessageContext(msg=cb.message) await state.set_state(DepositStorage.amount) await state.set_data({"ctx": ctx}) @@ -40,6 +42,15 @@ async def deposit_amount(msg: Message, state: FSMContext): await state.set_data(data) return + if int(msg.text) < settings.deposit_threshold: + await ctx.msg.edit_text( + DEPOSIT_AMOUNT_BELOW_MINIMAL.format(threshold=settings.deposit_threshold), + reply_markup=return_to_menu, + ) + await state.set_state(DepositStorage.amount) + await state.set_data(data) + return + await ctx.msg.edit_text(CHECKOUT_PAYMENT_CHOICE, reply_markup=payment_gateways) await state.set_state(BillingStorage.pending) billing_context = BillingContext(amount=int(msg.text)) diff --git a/bot/handlers/menus.py b/bot/handlers/menus.py index 43739a4..ebfb15d 100644 --- a/bot/handlers/menus.py +++ b/bot/handlers/menus.py @@ -4,10 +4,19 @@ from aiogram.fsm.context import FSMContext from aiogram.types import CallbackQuery, Message from sqlalchemy.ext.asyncio import AsyncSession -from bot.keyboards.client import close_kb, main_menu, return_to_menu -from bot.texts import FAQ_TEXT, GENERAL_PROCESSING +from bot.keyboards.client import close_kb, main_menu, return_to_menu, subscription_controls +from bot.texts import ( + CALLBACK_FALLBACK, + FAQ_TEXT, + GENERAL_PROCESSING, + GENERAL_SUCCESS, + NOTHING_PLACEHOLDER, +) +from db.models.users import User +from misc import utils from misc.utils import build_main_menu_text, format_subscription_link from schemas.di import DependenciesDTO +from schemas.subscriptions import InternalSquads, SubscriptionPlan from services.rw import get_user_by_telegram_id router = Router() @@ -107,9 +116,72 @@ async def sub_info( await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb) rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id) - user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk) + user: User | None = await deps.user_service.update_user_link( + session, cb.from_user.id, deps.rw_sdk + ) + sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id) + + if not user: + await cb.answer(CALLBACK_FALLBACK) + return + + if not sub: + 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( + session, + user_id=user.id, + end_date=rw_user.expire_at, + devices=rw_user.hwid_device_limit, + duration_days=30, + whitelists=dto.whitelists, + plan_price_paid=utils.calculate_price(dto), + ) + except Exception: + await cb.answer(CALLBACK_FALLBACK) + return await cb.message.edit_text( - deps.user_service.format_subscription_message(rw_user, link=user.subscription_link), - reply_markup=return_to_menu, + deps.user_service.format_subscription_message( + rw_user, link=user.subscription_link, autorenew=sub.autorenew + ), + reply_markup=subscription_controls(sub.autorenew), + ) + + +@router.callback_query(F.data == "toggle:autorenewal") +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) + + if not sub: + await cb.answer(CALLBACK_FALLBACK) + return + + new_autorenew = not sub.autorenew + sub = await deps.subscriptions_repository.update_subscription_autorenew( + session, sub.user_id, new_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 + ) + + await cb.answer(GENERAL_SUCCESS) + await cb.message.edit_text( + deps.user_service.format_subscription_message( + rw_user, link=user.subscription_link, autorenew=sub.autorenew + ), + reply_markup=subscription_controls(sub.autorenew), ) diff --git a/bot/keyboards/client.py b/bot/keyboards/client.py index a06cf79..5b7d722 100644 --- a/bot/keyboards/client.py +++ b/bot/keyboards/client.py @@ -54,6 +54,20 @@ deposit_kb: InlineKeyboardMarkup = InlineKeyboardBuilder( ).as_markup() +def subscription_controls(current_autorenewal_status: bool) -> InlineKeyboardMarkup: + autorenewal_text = ( + "🟢 Включить автопродление" + if not current_autorenewal_status + else "🔴 Отключить автопродление" + ) + return InlineKeyboardBuilder( + [ + [InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal")], + [_return_to_menu_btn], + ] + ).as_markup() + + def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton: return InlineKeyboardButton(text=text, callback_data=data) diff --git a/bot/main.py b/bot/main.py index 30532b9..240f765 100644 --- a/bot/main.py +++ b/bot/main.py @@ -24,7 +24,7 @@ logging.basicConfig(level=logging.DEBUG) async def main(): dp = Dispatcher() - pally_client = PallyClient(settings.pally_token) + pally_client = PallyClient(settings.pally_token, payer_pays_commission=True) user_repository = UserRepository() bills_repository = BillsRepository() diff --git a/bot/texts.py b/bot/texts.py index db93690..7ef7e0d 100644 --- a/bot/texts.py +++ b/bot/texts.py @@ -58,6 +58,8 @@ STATUS_EXPIRED = "истёк" STATUS_LIMITED = "трафик ограничен" STATUS_UNKNOWN = "неизвестный статус" +GENERAL_SUCCESS = "✅" + # ============================================================ # ФОРМАТИРОВАНИЕ ДАННЫХ @@ -114,6 +116,7 @@ FAQ_TEXT = ( "вам будет начисляться процент от их оплаченной подписки на баланс." ) +CALLBACK_PROCESSING = "⏳" GENERAL_PROCESSING = "⏳ Подождите..." STANDARD_FALLBACK = ( @@ -134,6 +137,7 @@ SUBSCRIPTION_INFORMATION = ( + "⏳ Осталось дней: {days_left}\n" + "📊 Израсходовано трафика: {used_traffic}\n" + "📱 Количество устройств: {hwid_limit}\n" + + "💸 Автопродление: {autorenew}" ) NO_SUBSCRIPTION = "⚠️ Подписка не найдена." @@ -188,6 +192,9 @@ SUCCESSFULLY_CREATED_SUBSCRIPTION = ( DEPOSIT_AMOUNT = "💸 Укажите сумму пополнения" DEPOSIT_AMOUNT_FALLBACK = "❌ Сумма пополнения указана неверно, повторите попытку." +DEPOSIT_AMOUNT_BELOW_MINIMAL = ( + "❌ Сумма пополнения ниже минимальной - {threshold}₽." +) # ============================================================ # ПОДДЕРЖКА diff --git a/config.py b/config.py index 469c062..467c115 100644 --- a/config.py +++ b/config.py @@ -16,6 +16,7 @@ class Settings(BaseSettings): pally_token: str = Field(alias="PALLY_TOKEN") referal_bonus: int = Field(10, alias="REFERAL_BONUS") + deposit_threshold: int = Field(50) proxy: str | None = Field(None, alias="PROXY") diff --git a/misc/pally.py b/misc/pally.py index 5b09262..b673594 100644 --- a/misc/pally.py +++ b/misc/pally.py @@ -220,6 +220,10 @@ class BaseService: class BillService(BaseService): """Service to handle all Bill-related operations.""" + def __init__(self, session: aiohttp.ClientSession, payer_pays_commission: bool | None = None): + super().__init__(session) + self._payer_pays_commission = payer_pays_commission + async def create( self, amount: float, @@ -229,9 +233,25 @@ class BillService(BaseService): bill_type: BillType = BillType.NORMAL, currency_in: Currency | None = None, ttl: int | None = None, + payer_pays_commission: bool | None = None, **kwargs: Any, ) -> BillCreateResponse: - """Creates a new bill for payment.""" + """ + Creates a new bill for payment. + + Args: + amount: The payment amount. + shop_id: Unique shop identifier. + payer_pays_commission: If True, the payer pays the commission. + Overrides the client-level default. + """ + # Определяем итоговое значение: переданное в метод или значение по умолчанию из сервиса + final_ppc = ( + payer_pays_commission + if payer_pays_commission is not None + else self._payer_pays_commission + ) + payload = { "amount": amount, "shop_id": shop_id, @@ -240,6 +260,7 @@ class BillService(BaseService): "type": bill_type.value, "currency_in": currency_in.value if currency_in else None, "ttl": ttl, + "payer_pays_commission": int(final_ppc) if final_ppc is not None else None, **kwargs, } return await self._post("bill/create", payload, BillCreateResponse) @@ -326,28 +347,34 @@ class PallyClient: Main asynchronous client for the Pally API. Usage: - async with PallyClient(api_token="your_token") as client: - balance = await client.balance.get() - print(balance) + async with PallyClient(api_token="your_token", payer_pays_commission=True) as client: + bill = await client.bills.create(amount=100.0, shop_id="my_shop") + print(bill.link_url) """ - def __init__(self, api_token: str, base_url: str = "https://pal24.pro/api/v1/"): + def __init__( + self, + api_token: str, + base_url: str = "https://pal24.pro/api/v1/", + payer_pays_commission: bool | None = None, + ): """ Initializes the Pally API client. Args: api_token (str): The bearer token provided by Pally. base_url (str): The base URL for the API. + payer_pays_commission (bool, optional): Global default for whether the payer pays the commission. """ self.api_token = api_token self.base_url = base_url + self.payer_pays_commission = payer_pays_commission self._session: aiohttp.ClientSession | None = None @property def session(self) -> aiohttp.ClientSession: """ Lazy-loads the aiohttp ClientSession. - This prevents creating a session outside of the asyncio event loop. """ if self._session is None or self._session.closed: headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"} @@ -357,10 +384,10 @@ class PallyClient: ) return self._session - # Using properties ensures that services always receive the active session context @property def bills(self) -> BillService: - return BillService(self.session) + # Передаем параметр плательщика комиссии в сервис счетов + return BillService(self.session, self.payer_pays_commission) @property def payments(self) -> PaymentService: @@ -376,8 +403,6 @@ class PallyClient: await self._session.close() async def __aenter__(self) -> "PallyClient": - # Accessing the property ensures the session is instantiated - # properly within the async context block. _ = self.session return self diff --git a/misc/utils.py b/misc/utils.py index e4ed046..22715ef 100644 --- a/misc/utils.py +++ b/misc/utils.py @@ -5,7 +5,8 @@ from random import choice from bot import texts from config import settings -from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan +from schemas.subscriptions import InternalSquads, SubscriptionDuration, SubscriptionPlan +from services.rw import RWUserInfo QUOTES: list[str] = [ "Верните себе свободный интернет.", @@ -233,3 +234,12 @@ def get_subscription_link(short_uuid: str): def build_main_menu_text(link: str, balance: int) -> str: return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance) + + +def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool: + if rw_user.hwid_device_limit != new_plan.devices: + return False + + active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads} + current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids + return current_has_whitelist == new_plan.whitelists diff --git a/repositories/subscriptions.py b/repositories/subscriptions.py index 9695608..67a990c 100644 --- a/repositories/subscriptions.py +++ b/repositories/subscriptions.py @@ -72,3 +72,18 @@ class SubscriptionRepository: result = await session.execute(stmt) await session.commit() return result.scalar_one_or_none() + + async def update_subscription_autorenew( + self, session: AsyncSession, user_id: int, autorenew: bool + ) -> Subscription | None: + stmt = ( + update(Subscription) + .where(Subscription.user_id == user_id) + .values( + autorenew=autorenew, + ) + .returning(Subscription) + ) + result = await session.execute(stmt) + await session.commit() + return result.scalar_one_or_none() diff --git a/repositories/users.py b/repositories/users.py index 70bdfb4..f4eb796 100644 --- a/repositories/users.py +++ b/repositories/users.py @@ -105,7 +105,7 @@ class UserRepository: if not user or user.balance < amount: return None - created_at = datetime.datetime.now(datetime.timezone.UTC) + created_at = datetime.datetime.now(datetime.UTC) transaction = BalanceTransaction( user_id=user_id, amount=amount, diff --git a/services/user_service.py b/services/user_service.py index 80651d5..3e09eba 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -1,15 +1,12 @@ import datetime import logging +import math from aiogram.types import Message, User as TelegramUser from remnawave import RemnawaveSDK 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 db.models.users import User from misc import utils from repositories import UserRepository @@ -33,8 +30,7 @@ class UserService: @staticmethod def format_subscription_message( - rw_user: rw.RWUserInfo | None, - link: str | None = None, + rw_user: rw.RWUserInfo | None, link: str | None = None, autorenew: bool = False ): if rw_user is None: # Пользователь не найден в Remnawave @@ -46,6 +42,7 @@ class UserService: used_traffic = utils.format_traffic(rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes) hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit) link = link or NOTHING_PLACEHOLDER + autorenew_text = "🟢" if autorenew else "🔴" return SUBSCRIPTION_INFORMATION.format( status_icon=status_icon, @@ -55,6 +52,7 @@ class UserService: used_traffic=used_traffic, hwid_limit=hwid_limit, link=link, + autorenew=autorenew_text, ) async def add_user( @@ -147,7 +145,13 @@ class UserService: duration_days=duration.days, ) - await rw.add_days(rw_sdk, rw_user.uuid, duration.days) + if not utils.is_same_plan(rw_user, subscription): + new_expire = datetime.datetime.now(datetime.UTC) + duration + delta_days = (new_expire - rw_user.expire_at).days + await rw.add_days(rw_sdk, rw_user.uuid, delta_days) + else: + await rw.add_days(rw_sdk, rw_user.uuid, duration.days) + await rw.update_squads(rw_sdk, rw_user.uuid, squads) await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) @@ -198,3 +202,25 @@ class UserService: await self.update_user_link(session, user.id, rw_sdk) return db_subscription + + async def calculate_plan_credit( + self, + session: AsyncSession, + user_id: int, + new_plan: SubscriptionPlan, + rw_sdk: RemnawaveSDK, + ) -> int: + rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id) + + if not rw_user or rw_user.expire_at <= datetime.datetime.now(datetime.UTC): + return 0 + + if utils.is_same_plan(rw_user, new_plan): + return 0 + + db_sub = await self.subscription_repository.get_subscription_by_user_id(session, user_id) + if not db_sub or not db_sub.plan_price_paid or not db_sub.duration_days: + return 0 + + remaining_days = max(0, (rw_user.expire_at - datetime.datetime.now(datetime.UTC)).days) + return math.floor(db_sub.plan_price_paid * remaining_days / db_sub.duration_days)