Implement buy flow and migrate DTOs to schemas

Move DTO definitions from dto/ to schemas/ and update imports.
Add buy handler with FSM state management for device selection,
whitelist toggling, and duration choice. Include pricing logic,
inline keyboards, and new configuration fields.
This commit is contained in:
2026-04-08 19:34:25 +07:00
parent fc45816eba
commit 930fabc028
17 changed files with 291 additions and 50 deletions

View File

@@ -13,6 +13,11 @@ class settings(BaseSettings):
admin_group_id: int = Field(alias="ADMIN_GROUP_ID") admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
remnawave_url: str = Field(alias="REMNAWAVE_URL") remnawave_url: str = Field(alias="REMNAWAVE_URL")
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN") remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
min_devices: int = Field(3, alias="MIN_DEVICES")
max_devices: int = Field(12, alias="MAX_DEVICES")
per_device_cost: int = Field(50, alias="PER_DEVICE_COST")
whitelist_cost: int = Field(100, alias="WHITELIST_COST")
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
settings = settings() # type: ignore settings = settings() # type: ignore

View File

View File

@@ -1,14 +0,0 @@
from dataclasses import dataclass
from repositories.tickets import TicketRepository
from repositories.users import UserRepository
from services.user_service import UserService
from remnawave import RemnawaveSDK
@dataclass
class DependenciesDTO:
ticket_repository: TicketRepository
user_repository: UserRepository
user_service: UserService
rw_sdk: RemnawaveSDK

View File

@@ -1,6 +0,0 @@
from dataclasses import dataclass
@dataclass
class NewTicketDTO:
creator_id: int

View File

@@ -1,14 +0,0 @@
from dataclasses import dataclass
from typing import Optional
@dataclass
class NewUserDTO:
id: int
referal: int
@dataclass
class TicketCreator:
id: int
username: Optional[str] = None

View File

@@ -1,4 +1,5 @@
from .menus import router as menus_router from .menus import router as menus_router
from .support import router as support_router from .support import router as support_router
from .buy import router as buy_router
routers = [menus_router, support_router] routers = [menus_router, support_router, buy_router]

View File

@@ -1,10 +1,153 @@
from aiogram import Router from typing import Optional
from aiogram import Router, F
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from dto.di import DependenciesDTO from keyboards.client import devices_selector, duration_selector
from misc.utils import calculate_price, convert_int_to_duration
from schemas.subscriptions import SubscriptionPlan
from states.buy import SubscriptionStorage
from texts import (
CALLBACK_FALLBACK,
CONTEXT_REINITIATED,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
)
from config import settings
router = Router() router = Router()
# @router.callback_query()
@router.callback_query(F.data == "buy")
async def buy_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
ctx = SubscriptionPlan()
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(settings.min_devices),
)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(F.data.startswith("devices:"))
async def device_count_select(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx", SubscriptionPlan())
cb_data = cb.data.split(":")
devices = int(cb_data[1]) if cb_data[1].isdigit() else settings.min_devices
ctx.devices = devices
ctx.whitelists = ctx.whitelists or devices >= settings.whitelist_device_threshold
await cb.message.edit_text(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(current=ctx.devices, whitelists=ctx.whitelists),
)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.devices, F.data == "whitelist:toggle")
async def toggle_whitelists(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
if not ctx:
await state.set_state(SubscriptionPlan())
await state.set_data({"ctx": ctx})
await cb.answer(CALLBACK_FALLBACK)
return
ctx.whitelists = (
not ctx.whitelists or ctx.devices >= settings.whitelist_device_threshold
)
try:
await cb.message.edit_reply_markup(
SUBSCRIPTION_DEVICE_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=devices_selector(
current=ctx.devices, whitelists=ctx.whitelists
),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
await state.set_state(SubscriptionStorage.devices)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.devices, F.data == "confirm")
async def select_duration_init(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
if not ctx:
await state.set_state(SubscriptionPlan())
await state.set_data({"ctx": ctx})
await cb.answer(CALLBACK_FALLBACK)
return
try:
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=duration_selector(
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
raise
await state.set_state(SubscriptionStorage.duration)
await state.set_data({"ctx": ctx})
@router.callback_query(F.data.startswith("duration:"))
async def select_duration(cb: CallbackQuery, state: FSMContext):
data = await state.get_data()
await state.clear()
ctx: Optional[SubscriptionPlan] = data.get("ctx")
if not ctx:
await state.set_state()
await state.set_data({"ctx": ctx})
await cb.answer(CONTEXT_REINITIATED)
ctx = SubscriptionPlan()
cb_data = cb.data.split(":")
duration = int(cb_data[1]) if cb_data[1].isdigit() else 1
ctx.duration = convert_int_to_duration(duration)
try:
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(price=calculate_price(ctx)),
reply_markup=duration_selector(
current=ctx.duration, back_cb=f"devices:{ctx.devices}"
),
)
except Exception as exc:
if "message is not modified:" in str(exc):
await cb.answer(NOTHING_PLACEHOLDER)
else:
await cb.answer(CALLBACK_FALLBACK)
raise
await state.set_state(SubscriptionStorage.duration)
await state.set_data({"ctx": ctx})
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
await cb.answer("yay!!!! fuck off.")
...

View File

@@ -4,7 +4,7 @@ from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from dto.di import DependenciesDTO from schemas.di import DependenciesDTO
from keyboards.client import main_menu from keyboards.client import main_menu
from texts import build_main_menu_text from texts import build_main_menu_text

View File

@@ -6,7 +6,7 @@ from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from config import settings
from dto.di import DependenciesDTO from schemas.di import DependenciesDTO
from services.user_service import UserServiceException from services.user_service import UserServiceException
from states.support import SupportStorage from states.support import SupportStorage
from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT

View File

@@ -1,6 +1,10 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from config import settings
from misc.utils import convert_duration_to_int
from schemas.subscriptions import SubscriptionDuration
main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[ [
[ [
@@ -14,6 +18,71 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
] ]
).as_markup() ).as_markup()
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
def _return_btn(
data: str = "menu:main", text: str = "⬅️ Назад"
) -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=data)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder( return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]] [[_return_to_menu_btn]]
).as_markup() ).as_markup()
def devices_selector(current: int = settings.min_devices, whitelists: bool = False):
builder = InlineKeyboardBuilder()
buttons = [
InlineKeyboardButton(
text=f"{'' if i == current else ''}{i}", callback_data=f"devices:{i}"
)
for i in range(settings.min_devices, settings.max_devices + 1)
]
builder.add(*buttons).adjust(6)
wl_icon = "" if whitelists else ""
builder.row(
InlineKeyboardButton(
text=f"{wl_icon}Обход Белых Списков (+100₽)",
callback_data="whitelist:toggle",
)
)
builder.row(InlineKeyboardButton(text="🟢", callback_data="confirm"))
builder.row(_return_to_menu_btn)
return builder.as_markup()
def duration_selector(
current: SubscriptionDuration = SubscriptionDuration.MONTH,
back_cb: str = "menu:main",
) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
duration_int = convert_duration_to_int(current)
durations = [
(1, "1 Месяц"),
(3, "3 Месяца"),
(6, "6 Месяцев"),
(12, "1 Год"),
]
for val, label in durations:
is_selected = val == duration_int
builder.button(
text=f"{'' if is_selected else ''}{label}",
callback_data=f"duration:{val}",
)
builder.row(
InlineKeyboardButton(text="🛒 Перейти к оплате", callback_data="confirm")
)
builder.row(_return_btn(back_cb))
return builder.as_markup()

View File

@@ -6,7 +6,7 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
from dto.di import DependenciesDTO from schemas.di import DependenciesDTO
from handlers import routers from handlers import routers
from middlewares import DIMiddleware from middlewares import DIMiddleware
from config import settings from config import settings

View File

@@ -3,7 +3,7 @@ from aiogram import BaseMiddleware
from aiogram.types import TelegramObject from aiogram.types import TelegramObject
from sqlalchemy.ext.asyncio import async_sessionmaker from sqlalchemy.ext.asyncio import async_sessionmaker
from dto.di import DependenciesDTO from schemas.di import DependenciesDTO
class DIMiddleware(BaseMiddleware): class DIMiddleware(BaseMiddleware):

View File

@@ -1,7 +1,9 @@
from typing import Optional from typing import Optional
from random import choice from random import choice
from datetime import datetime, timezone from datetime import datetime, timedelta, timezone
from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
import texts import texts
QUOTES: list[str] = [ QUOTES: list[str] = [
@@ -125,3 +127,45 @@ def format_username(username: Optional[str]) -> str:
if username: if username:
return "@" + username return "@" + username
return texts.NO_USERNAME return texts.NO_USERNAME
def convert_duration_to_int(duration: SubscriptionDuration) -> int:
mapping: dict[str, int] = {
SubscriptionDuration.MONTH.value: 1,
SubscriptionDuration.THREE_MONTHS.value: 3,
SubscriptionDuration.SIX_MONTHS.value: 6,
SubscriptionDuration.YEAR.value: 12,
}
return mapping.get(duration.value, 1)
def convert_int_to_duration(value: int) -> SubscriptionDuration:
mapping: dict[int, SubscriptionDuration] = {
1: SubscriptionDuration.MONTH,
3: SubscriptionDuration.THREE_MONTHS,
6: SubscriptionDuration.SIX_MONTHS,
12: SubscriptionDuration.YEAR,
}
return mapping.get(value, SubscriptionDuration.MONTH)
def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
MONTH_DELTA = timedelta(days=30)
mapping: dict[str, timedelta] = {
SubscriptionDuration.MONTH.value: MONTH_DELTA,
SubscriptionDuration.THREE_MONTHS.value: MONTH_DELTA * 3,
SubscriptionDuration.SIX_MONTHS.value: MONTH_DELTA * 6,
SubscriptionDuration.YEAR.value: MONTH_DELTA * 12,
}
return mapping.get(duration.value, MONTH_DELTA)
def calculate_price(subscription: SubscriptionPlan):
return (
(subscription.devices * settings.per_device_cost)
+ (
int(subscription.whitelists) * settings.whitelist_cost
if subscription.devices < settings.whitelist_device_threshold
else 0
)
) * convert_duration_to_int(subscription.duration)

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models.tickets import Ticket, TicketStatus from db.models.tickets import Ticket, TicketStatus
from dto.tickets import NewTicketDTO from schemas.tickets import NewTicketDTO
class TicketRepository: class TicketRepository:

View File

@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import User from db.models import User
from dto.users import NewUserDTO from schemas.users import NewUserDTO
class UserRepository: class UserRepository:

View File

@@ -6,8 +6,8 @@ from remnawave import RemnawaveSDK
from config import settings from config import settings
from db.models.tickets import TicketStatus from db.models.tickets import TicketStatus
from db.models.users import User from db.models.users import User
from dto.tickets import NewTicketDTO from schemas.tickets import NewTicketDTO
from dto.users import NewUserDTO, TicketCreator from schemas.users import NewUserDTO, TicketCreator
from repositories import UserRepository from repositories import UserRepository
from repositories.tickets import TicketRepository from repositories.tickets import TicketRepository
from services.rw import RWUserInfo, get_user_by_telegram_id from services.rw import RWUserInfo, get_user_by_telegram_id
@@ -107,7 +107,6 @@ class UserService:
if ticket: if ticket:
try: try:
print(ticket.thread_id)
await msg.copy_to( await msg.copy_to(
chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id
) )

View File

@@ -38,8 +38,19 @@ STANDARD_FALLBACK = (
"<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже." "<b>❌ Что-то пошло не так</b>, прожмите /start и повторите попытку позже."
) )
CALLBACK_FALLBACK = "❌ Что-то пошло не так"
CONTEXT_REINITIATED = "🔴 Контекст данного взаимодействия сброшен. Данные могут отличаться от указанных вами."
GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>" GENERAL_PROCESSING = "<i>⏳ Выполняю...</i>"
SUBSCRIPTION_DEVICE_SELECTOR = (
"выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
)
WHITELISTS_ALREADY_ON = "уже включены!"
SUBSCRIPTION_DURATION_SELECTOR = (
"тут короче сколько время. а ещё цена смотри ОПА {price}"
)
SUPPORT_INIT = f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n<i>❌ Для отмены — /cancel</i>" SUPPORT_INIT = f"<b>{MALENIA_SUPPORT} <i>Что-то не работает? Жалоба? Предложение?</i></b>\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n<i>❌ Для отмены — /cancel</i>"
SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время." SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время."
SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>" SUPPORT_SIGNATURE = "<b>✍️ Сообщение от технической поддержки:</b>"
@@ -122,3 +133,6 @@ TRAFFIC_WITH_LIMIT = "{used} / {limit}"
# Шаблон для отображения трафика без лимита: {used} # Шаблон для отображения трафика без лимита: {used}
TRAFFIC_NO_LIMIT = "{used}" TRAFFIC_NO_LIMIT = "{used}"
NOTHING_PLACEHOLDER = "🍃"