diff --git a/config.py b/config.py
index 6393a2d..16ea2d4 100644
--- a/config.py
+++ b/config.py
@@ -13,6 +13,11 @@ class settings(BaseSettings):
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
remnawave_url: str = Field(alias="REMNAWAVE_URL")
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
diff --git a/dto/__init__.py b/dto/__init__.py
deleted file mode 100644
index e69de29..0000000
diff --git a/dto/di.py b/dto/di.py
deleted file mode 100644
index 17ea5ec..0000000
--- a/dto/di.py
+++ /dev/null
@@ -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
diff --git a/dto/tickets.py b/dto/tickets.py
deleted file mode 100644
index fa33e84..0000000
--- a/dto/tickets.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from dataclasses import dataclass
-
-
-@dataclass
-class NewTicketDTO:
- creator_id: int
diff --git a/dto/users.py b/dto/users.py
deleted file mode 100644
index cd6466c..0000000
--- a/dto/users.py
+++ /dev/null
@@ -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
diff --git a/handlers/__init__.py b/handlers/__init__.py
index be3ff80..4a98d04 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -1,4 +1,5 @@
from .menus import router as menus_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]
diff --git a/handlers/buy.py b/handlers/buy.py
index e1bc233..d4e4aef 100644
--- a/handlers/buy.py
+++ b/handlers/buy.py
@@ -1,10 +1,153 @@
-from aiogram import Router
+from typing import Optional
+from aiogram import Router, F
from aiogram.types import CallbackQuery
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.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.")
+ ...
diff --git a/handlers/menus.py b/handlers/menus.py
index fd80459..0118289 100644
--- a/handlers/menus.py
+++ b/handlers/menus.py
@@ -4,7 +4,7 @@ from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
-from dto.di import DependenciesDTO
+from schemas.di import DependenciesDTO
from keyboards.client import main_menu
from texts import build_main_menu_text
diff --git a/handlers/support.py b/handlers/support.py
index fffa37a..98659c8 100644
--- a/handlers/support.py
+++ b/handlers/support.py
@@ -6,7 +6,7 @@ from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
-from dto.di import DependenciesDTO
+from schemas.di import DependenciesDTO
from services.user_service import UserServiceException
from states.support import SupportStorage
from texts import GENERAL_PROCESSING, STANDARD_FALLBACK, SUPPORT_INIT, SUPPORT_MSG_SENT
diff --git a/keyboards/client.py b/keyboards/client.py
index 0ebb909..fc92341 100644
--- a/keyboards/client.py
+++ b/keyboards/client.py
@@ -1,6 +1,10 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
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(
[
[
@@ -14,6 +18,71 @@ main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
]
).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(
- [[InlineKeyboardButton(text="⬅️ Назад", callback_data="menu:main")]]
+ [[_return_to_menu_btn]]
).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()
diff --git a/main.py b/main.py
index 33f6cb5..d89c2a8 100644
--- a/main.py
+++ b/main.py
@@ -6,7 +6,7 @@ from aiogram import Bot, Dispatcher
from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
-from dto.di import DependenciesDTO
+from schemas.di import DependenciesDTO
from handlers import routers
from middlewares import DIMiddleware
from config import settings
diff --git a/middlewares/di.py b/middlewares/di.py
index a1672c7..89219cd 100644
--- a/middlewares/di.py
+++ b/middlewares/di.py
@@ -3,7 +3,7 @@ from aiogram import BaseMiddleware
from aiogram.types import TelegramObject
from sqlalchemy.ext.asyncio import async_sessionmaker
-from dto.di import DependenciesDTO
+from schemas.di import DependenciesDTO
class DIMiddleware(BaseMiddleware):
diff --git a/misc/utils.py b/misc/utils.py
index ffad1b7..a4d96c0 100644
--- a/misc/utils.py
+++ b/misc/utils.py
@@ -1,7 +1,9 @@
from typing import Optional
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
QUOTES: list[str] = [
@@ -125,3 +127,45 @@ def format_username(username: Optional[str]) -> str:
if username:
return "@" + 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)
diff --git a/repositories/tickets.py b/repositories/tickets.py
index c05a181..8489eff 100644
--- a/repositories/tickets.py
+++ b/repositories/tickets.py
@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.tickets import Ticket, TicketStatus
-from dto.tickets import NewTicketDTO
+from schemas.tickets import NewTicketDTO
class TicketRepository:
diff --git a/repositories/users.py b/repositories/users.py
index f9968a7..ee35aa3 100644
--- a/repositories/users.py
+++ b/repositories/users.py
@@ -3,7 +3,7 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import User
-from dto.users import NewUserDTO
+from schemas.users import NewUserDTO
class UserRepository:
diff --git a/services/user_service.py b/services/user_service.py
index a6a9ca5..e2238c0 100644
--- a/services/user_service.py
+++ b/services/user_service.py
@@ -6,8 +6,8 @@ from remnawave import RemnawaveSDK
from config import settings
from db.models.tickets import TicketStatus
from db.models.users import User
-from dto.tickets import NewTicketDTO
-from dto.users import NewUserDTO, TicketCreator
+from schemas.tickets import NewTicketDTO
+from schemas.users import NewUserDTO, TicketCreator
from repositories import UserRepository
from repositories.tickets import TicketRepository
from services.rw import RWUserInfo, get_user_by_telegram_id
@@ -107,7 +107,6 @@ class UserService:
if ticket:
try:
- print(ticket.thread_id)
await msg.copy_to(
chat_id=settings.admin_group_id, message_thread_id=ticket.thread_id
)
diff --git a/texts.py b/texts.py
index 6da8ea1..eac494d 100644
--- a/texts.py
+++ b/texts.py
@@ -38,8 +38,19 @@ STANDARD_FALLBACK = (
"❌ Что-то пошло не так, прожмите /start и повторите попытку позже."
)
+CALLBACK_FALLBACK = "❌ Что-то пошло не так"
+CONTEXT_REINITIATED = "🔴 Контекст данного взаимодействия сброшен. Данные могут отличаться от указанных вами."
+
GENERAL_PROCESSING = "⏳ Выполняю..."
+SUBSCRIPTION_DEVICE_SELECTOR = (
+ "выбери колво устройств епта. тут ещё цена есть смотри опа: {price}"
+)
+WHITELISTS_ALREADY_ON = "уже включены!"
+SUBSCRIPTION_DURATION_SELECTOR = (
+ "тут короче сколько время. а ещё цена смотри ОПА {price}"
+)
+
SUPPORT_INIT = f"{MALENIA_SUPPORT} Что-то не работает? Жалоба? Предложение?\n— всё сюда, желательно со скриншотами. Чем детальнее описание, тем быстрее ответ.\n\n❌ Для отмены — /cancel"
SUPPORT_MSG_SENT = "⏳ Сообщение отправлено. Для выхода — /cancel, вы можете вернуться сюда из главного меню в любое время."
SUPPORT_SIGNATURE = "✍️ Сообщение от технической поддержки:"
@@ -122,3 +133,6 @@ TRAFFIC_WITH_LIMIT = "{used} / {limit}"
# Шаблон для отображения трафика без лимита: {used}
TRAFFIC_NO_LIMIT = "{used}"
+
+
+NOTHING_PLACEHOLDER = "🍃"