feat: implement subscription management and billing enhancements

This commit is contained in:
2026-04-29 20:10:26 +07:00
parent 120e19e667
commit db2dabed58
12 changed files with 223 additions and 28 deletions

View File

@@ -169,7 +169,12 @@ async def proceed_to_checkout(
amount = calculate_price(ctx) amount = calculate_price(ctx)
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id) 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( await cb.message.edit_text(
INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), reply_markup=deposit_kb 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) await cb.message.edit_text(CALLBACK_FALLBACK)
return 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( deducted_user = await deps.user_repository.decrease_user_balance(
session, session,
user.id, user.id,

View File

@@ -8,8 +8,10 @@ from bot.states.deposit import DepositStorage
from bot.texts import ( from bot.texts import (
CHECKOUT_PAYMENT_CHOICE, CHECKOUT_PAYMENT_CHOICE,
DEPOSIT_AMOUNT, DEPOSIT_AMOUNT,
DEPOSIT_AMOUNT_BELOW_MINIMAL,
DEPOSIT_AMOUNT_FALLBACK, DEPOSIT_AMOUNT_FALLBACK,
) )
from config import settings
from schemas.billing import BillingContext from schemas.billing import BillingContext
from schemas.common import GeneralMessageContext 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) 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_state(DepositStorage.amount)
await state.set_data({"ctx": ctx}) await state.set_data({"ctx": ctx})
@@ -40,6 +42,15 @@ async def deposit_amount(msg: Message, state: FSMContext):
await state.set_data(data) await state.set_data(data)
return 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 ctx.msg.edit_text(CHECKOUT_PAYMENT_CHOICE, reply_markup=payment_gateways)
await state.set_state(BillingStorage.pending) await state.set_state(BillingStorage.pending)
billing_context = BillingContext(amount=int(msg.text)) billing_context = BillingContext(amount=int(msg.text))

View File

@@ -4,10 +4,19 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import close_kb, main_menu, return_to_menu from bot.keyboards.client import close_kb, main_menu, return_to_menu, subscription_controls
from bot.texts import FAQ_TEXT, GENERAL_PROCESSING 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 misc.utils import build_main_menu_text, format_subscription_link
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from schemas.subscriptions import InternalSquads, SubscriptionPlan
from services.rw import get_user_by_telegram_id from services.rw import get_user_by_telegram_id
router = Router() router = Router()
@@ -107,9 +116,72 @@ async def sub_info(
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb) 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) 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( await cb.message.edit_text(
deps.user_service.format_subscription_message(rw_user, link=user.subscription_link), deps.user_service.format_subscription_message(
reply_markup=return_to_menu, 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),
) )

View File

@@ -54,6 +54,20 @@ deposit_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
).as_markup() ).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: def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=data) return InlineKeyboardButton(text=text, callback_data=data)

View File

@@ -24,7 +24,7 @@ logging.basicConfig(level=logging.DEBUG)
async def main(): async def main():
dp = Dispatcher() dp = Dispatcher()
pally_client = PallyClient(settings.pally_token) pally_client = PallyClient(settings.pally_token, payer_pays_commission=True)
user_repository = UserRepository() user_repository = UserRepository()
bills_repository = BillsRepository() bills_repository = BillsRepository()

View File

@@ -58,6 +58,8 @@ STATUS_EXPIRED = "истёк"
STATUS_LIMITED = "трафик ограничен" STATUS_LIMITED = "трафик ограничен"
STATUS_UNKNOWN = "неизвестный статус" STATUS_UNKNOWN = "неизвестный статус"
GENERAL_SUCCESS = ""
# ============================================================ # ============================================================
# ФОРМАТИРОВАНИЕ ДАННЫХ # ФОРМАТИРОВАНИЕ ДАННЫХ
@@ -114,6 +116,7 @@ FAQ_TEXT = (
"<b>вам будет начисляться процент от их оплаченной подписки</b> на баланс." "<b>вам будет начисляться процент от их оплаченной подписки</b> на баланс."
) )
CALLBACK_PROCESSING = ""
GENERAL_PROCESSING = "<i>⏳ Подождите...</i>" GENERAL_PROCESSING = "<i>⏳ Подождите...</i>"
STANDARD_FALLBACK = ( STANDARD_FALLBACK = (
@@ -134,6 +137,7 @@ SUBSCRIPTION_INFORMATION = (
+ "⏳ <b>Осталось дней:</b> {days_left}\n" + "⏳ <b>Осталось дней:</b> {days_left}\n"
+ "📊 <b>Израсходовано трафика:</b> {used_traffic}\n" + "📊 <b>Израсходовано трафика:</b> {used_traffic}\n"
+ "📱 <b>Количество устройств:</b> {hwid_limit}\n" + "📱 <b>Количество устройств:</b> {hwid_limit}\n"
+ "<b>💸 Автопродление:</b> {autorenew}"
) )
NO_SUBSCRIPTION = "⚠️ <b>Подписка не найдена.</b>" NO_SUBSCRIPTION = "⚠️ <b>Подписка не найдена.</b>"
@@ -188,6 +192,9 @@ SUCCESSFULLY_CREATED_SUBSCRIPTION = (
DEPOSIT_AMOUNT = "<b>💸 Укажите сумму пополнения</b>" DEPOSIT_AMOUNT = "<b>💸 Укажите сумму пополнения</b>"
DEPOSIT_AMOUNT_FALLBACK = "<b>❌ Сумма пополнения указана неверно, повторите попытку.</b>" DEPOSIT_AMOUNT_FALLBACK = "<b>❌ Сумма пополнения указана неверно, повторите попытку.</b>"
DEPOSIT_AMOUNT_BELOW_MINIMAL = (
"<b>❌ Сумма пополнения ниже минимальной - <code>{threshold}₽</code>.</b>"
)
# ============================================================ # ============================================================
# ПОДДЕРЖКА # ПОДДЕРЖКА

View File

@@ -16,6 +16,7 @@ class Settings(BaseSettings):
pally_token: str = Field(alias="PALLY_TOKEN") pally_token: str = Field(alias="PALLY_TOKEN")
referal_bonus: int = Field(10, alias="REFERAL_BONUS") referal_bonus: int = Field(10, alias="REFERAL_BONUS")
deposit_threshold: int = Field(50)
proxy: str | None = Field(None, alias="PROXY") proxy: str | None = Field(None, alias="PROXY")

View File

@@ -220,6 +220,10 @@ class BaseService:
class BillService(BaseService): class BillService(BaseService):
"""Service to handle all Bill-related operations.""" """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( async def create(
self, self,
amount: float, amount: float,
@@ -229,9 +233,25 @@ class BillService(BaseService):
bill_type: BillType = BillType.NORMAL, bill_type: BillType = BillType.NORMAL,
currency_in: Currency | None = None, currency_in: Currency | None = None,
ttl: int | None = None, ttl: int | None = None,
payer_pays_commission: bool | None = None,
**kwargs: Any, **kwargs: Any,
) -> BillCreateResponse: ) -> 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 = { payload = {
"amount": amount, "amount": amount,
"shop_id": shop_id, "shop_id": shop_id,
@@ -240,6 +260,7 @@ class BillService(BaseService):
"type": bill_type.value, "type": bill_type.value,
"currency_in": currency_in.value if currency_in else None, "currency_in": currency_in.value if currency_in else None,
"ttl": ttl, "ttl": ttl,
"payer_pays_commission": int(final_ppc) if final_ppc is not None else None,
**kwargs, **kwargs,
} }
return await self._post("bill/create", payload, BillCreateResponse) return await self._post("bill/create", payload, BillCreateResponse)
@@ -326,28 +347,34 @@ class PallyClient:
Main asynchronous client for the Pally API. Main asynchronous client for the Pally API.
Usage: Usage:
async with PallyClient(api_token="your_token") as client: async with PallyClient(api_token="your_token", payer_pays_commission=True) as client:
balance = await client.balance.get() bill = await client.bills.create(amount=100.0, shop_id="my_shop")
print(balance) 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. Initializes the Pally API client.
Args: Args:
api_token (str): The bearer token provided by Pally. api_token (str): The bearer token provided by Pally.
base_url (str): The base URL for the API. 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.api_token = api_token
self.base_url = base_url self.base_url = base_url
self.payer_pays_commission = payer_pays_commission
self._session: aiohttp.ClientSession | None = None self._session: aiohttp.ClientSession | None = None
@property @property
def session(self) -> aiohttp.ClientSession: def session(self) -> aiohttp.ClientSession:
""" """
Lazy-loads the 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: if self._session is None or self._session.closed:
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"} headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
@@ -357,10 +384,10 @@ class PallyClient:
) )
return self._session return self._session
# Using properties ensures that services always receive the active session context
@property @property
def bills(self) -> BillService: def bills(self) -> BillService:
return BillService(self.session) # Передаем параметр плательщика комиссии в сервис счетов
return BillService(self.session, self.payer_pays_commission)
@property @property
def payments(self) -> PaymentService: def payments(self) -> PaymentService:
@@ -376,8 +403,6 @@ class PallyClient:
await self._session.close() await self._session.close()
async def __aenter__(self) -> "PallyClient": async def __aenter__(self) -> "PallyClient":
# Accessing the property ensures the session is instantiated
# properly within the async context block.
_ = self.session _ = self.session
return self return self

View File

@@ -5,7 +5,8 @@ from random import choice
from bot import texts from bot import texts
from config import settings 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] = [ QUOTES: list[str] = [
"Верните себе свободный интернет.", "Верните себе свободный интернет.",
@@ -233,3 +234,12 @@ def get_subscription_link(short_uuid: str):
def build_main_menu_text(link: str, balance: int) -> str: def build_main_menu_text(link: str, balance: int) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance) 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

View File

@@ -72,3 +72,18 @@ class SubscriptionRepository:
result = await session.execute(stmt) result = await session.execute(stmt)
await session.commit() await session.commit()
return result.scalar_one_or_none() 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()

View File

@@ -105,7 +105,7 @@ class UserRepository:
if not user or user.balance < amount: if not user or user.balance < amount:
return None return None
created_at = datetime.datetime.now(datetime.timezone.UTC) created_at = datetime.datetime.now(datetime.UTC)
transaction = BalanceTransaction( transaction = BalanceTransaction(
user_id=user_id, user_id=user_id,
amount=amount, amount=amount,

View File

@@ -1,15 +1,12 @@
import datetime import datetime
import logging import logging
import math
from aiogram.types import Message, User as TelegramUser from aiogram.types import Message, User as TelegramUser
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import ( from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION
NO_SUBSCRIPTION,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_INFORMATION,
)
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
@@ -33,8 +30,7 @@ class UserService:
@staticmethod @staticmethod
def format_subscription_message( def format_subscription_message(
rw_user: rw.RWUserInfo | None, rw_user: rw.RWUserInfo | None, link: str | None = None, autorenew: bool = False
link: str | None = None,
): ):
if rw_user is None: if rw_user is None:
# Пользователь не найден в Remnawave # Пользователь не найден в Remnawave
@@ -46,6 +42,7 @@ class UserService:
used_traffic = utils.format_traffic(rw_user.used_traffic_bytes, rw_user.traffic_limit_bytes) 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) hwid_limit = utils.format_hwid_limit(rw_user.hwid_device_limit)
link = link or NOTHING_PLACEHOLDER link = link or NOTHING_PLACEHOLDER
autorenew_text = "🟢" if autorenew else "🔴"
return SUBSCRIPTION_INFORMATION.format( return SUBSCRIPTION_INFORMATION.format(
status_icon=status_icon, status_icon=status_icon,
@@ -55,6 +52,7 @@ class UserService:
used_traffic=used_traffic, used_traffic=used_traffic,
hwid_limit=hwid_limit, hwid_limit=hwid_limit,
link=link, link=link,
autorenew=autorenew_text,
) )
async def add_user( async def add_user(
@@ -147,7 +145,13 @@ class UserService:
duration_days=duration.days, duration_days=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.add_days(rw_sdk, rw_user.uuid, duration.days)
await rw.update_squads(rw_sdk, rw_user.uuid, squads) await rw.update_squads(rw_sdk, rw_user.uuid, squads)
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) 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) await self.update_user_link(session, user.id, rw_sdk)
return db_subscription 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)