diff options
| author | Tolmachev Igor <me@igorek.dev> | 2026-03-26 16:23:52 +0300 |
|---|---|---|
| committer | Tolmachev Igor <me@igorek.dev> | 2026-03-26 16:23:52 +0300 |
| commit | 9c905f22de4fa2e2f60ea9d473c14cb075a244e2 (patch) | |
| tree | 366fa6c5fb5bbe1d29e383effa7df816cadf22a4 /handlers | |
| parent | 4d0f8a48502dfa6bc7e9b39444573fe7377bdfce (diff) | |
| download | vpn_manager_bot-9c905f22de4fa2e2f60ea9d473c14cb075a244e2.tar.gz vpn_manager_bot-9c905f22de4fa2e2f60ea9d473c14cb075a244e2.zip | |
Add invoices command
Diffstat (limited to 'handlers')
| -rw-r--r-- | handlers/admin/new_invoice.py | 4 | ||||
| -rw-r--r-- | handlers/admin/payment_status.py | 8 | ||||
| -rw-r--r-- | handlers/user/__init__.py | 2 | ||||
| -rw-r--r-- | handlers/user/announcements.py | 21 | ||||
| -rw-r--r-- | handlers/user/invoices.py | 179 | ||||
| -rw-r--r-- | handlers/user/pay_invoice.py | 22 |
6 files changed, 225 insertions, 11 deletions
diff --git a/handlers/admin/new_invoice.py b/handlers/admin/new_invoice.py index 43e47cb..f532bff 100644 --- a/handlers/admin/new_invoice.py +++ b/handlers/admin/new_invoice.py | |||
| @@ -18,7 +18,7 @@ from sqlalchemy.ext.asyncio import AsyncSession | |||
| 18 | 18 | ||
| 19 | from libs.fsm import get_data, set_data | 19 | from libs.fsm import get_data, set_data |
| 20 | from libs.msg import send_invoice | 20 | from libs.msg import send_invoice |
| 21 | from models import Invoice, RichText, User | 21 | from models import Invoice, RichText, User, UserRole |
| 22 | 22 | ||
| 23 | router = Router(name="new_invoice") | 23 | router = Router(name="new_invoice") |
| 24 | 24 | ||
| @@ -59,7 +59,7 @@ async def create( | |||
| 59 | state: FSMContext, | 59 | state: FSMContext, |
| 60 | session: AsyncSession, | 60 | session: AsyncSession, |
| 61 | ) -> None: | 61 | ) -> None: |
| 62 | users = await session.scalars(select(User.id).where(User.id != msg.chat.id)) | 62 | users = await session.scalars(select(User.id).where(User.role != UserRole.ADMIN)) |
| 63 | data = await get_data(state, NewInvoiceData) | 63 | data = await get_data(state, NewInvoiceData) |
| 64 | 64 | ||
| 65 | if data.rich_text is None: | 65 | if data.rich_text is None: |
diff --git a/handlers/admin/payment_status.py b/handlers/admin/payment_status.py index 23bde15..8784ac4 100644 --- a/handlers/admin/payment_status.py +++ b/handlers/admin/payment_status.py | |||
| @@ -11,14 +11,18 @@ router = Router(name="payment_status") | |||
| 11 | 11 | ||
| 12 | 12 | ||
| 13 | async def accept(clb: CallbackQuery, bot: Bot, payment: Payment) -> None: | 13 | async def accept(clb: CallbackQuery, bot: Bot, payment: Payment) -> None: |
| 14 | assert clb.message is not None | ||
| 15 | |||
| 14 | payment.status = PaymentStatus.ACCEPTED | 16 | payment.status = PaymentStatus.ACCEPTED |
| 15 | await bot.send_message(clb.from_user.id, "Оплата пользователя подтверждена.") | 17 | await clb.message.reply("Оплата пользователя подтверждена.") |
| 16 | await bot.send_message(payment.user_id, "Файл подтверждения оплаты принят.") | 18 | await bot.send_message(payment.user_id, "Файл подтверждения оплаты принят.") |
| 17 | 19 | ||
| 18 | 20 | ||
| 19 | async def reject(clb: CallbackQuery, bot: Bot, payment: Payment) -> None: | 21 | async def reject(clb: CallbackQuery, bot: Bot, payment: Payment) -> None: |
| 22 | assert clb.message is not None | ||
| 23 | |||
| 20 | payment.status = PaymentStatus.REJECTED | 24 | payment.status = PaymentStatus.REJECTED |
| 21 | await bot.send_message(clb.from_user.id, "Оплата пользователя отклонена.") | 25 | await clb.message.reply("Оплата пользователя отклонена.") |
| 22 | 26 | ||
| 23 | callback_data = PayInvoiceClb(invoice_id=payment.invoice_id).pack() | 27 | callback_data = PayInvoiceClb(invoice_id=payment.invoice_id).pack() |
| 24 | await bot.send_message( | 28 | await bot.send_message( |
diff --git a/handlers/user/__init__.py b/handlers/user/__init__.py index a0e719f..7eee69a 100644 --- a/handlers/user/__init__.py +++ b/handlers/user/__init__.py | |||
| @@ -5,6 +5,7 @@ from . import info | |||
| 5 | from . import vpn_link | 5 | from . import vpn_link |
| 6 | from . import pay_invoice | 6 | from . import pay_invoice |
| 7 | from . import announcements | 7 | from . import announcements |
| 8 | from . import invoices | ||
| 8 | # isort: on | 9 | # isort: on |
| 9 | 10 | ||
| 10 | router = Router(name="user") | 11 | router = Router(name="user") |
| @@ -13,4 +14,5 @@ router.include_routers( | |||
| 13 | vpn_link.router, | 14 | vpn_link.router, |
| 14 | pay_invoice.router, | 15 | pay_invoice.router, |
| 15 | announcements.router, | 16 | announcements.router, |
| 17 | invoices.router, | ||
| 16 | ) | 18 | ) |
diff --git a/handlers/user/announcements.py b/handlers/user/announcements.py index 8f4aa43..dc85b54 100644 --- a/handlers/user/announcements.py +++ b/handlers/user/announcements.py | |||
| @@ -16,11 +16,17 @@ router = Router(name="announcements") | |||
| 16 | PAGE_SIZE = 5 | 16 | PAGE_SIZE = 5 |
| 17 | 17 | ||
| 18 | 18 | ||
| 19 | async def get_reply_markup(page: int, session: AsyncSession) -> InlineKeyboardMarkup: | 19 | async def get_reply_markup( |
| 20 | page: int, | ||
| 21 | session: AsyncSession, | ||
| 22 | ) -> InlineKeyboardMarkup | None: | ||
| 20 | total = await session.scalar(select(count()).select_from(Announcement)) | 23 | total = await session.scalar(select(count()).select_from(Announcement)) |
| 21 | assert total is not None | 24 | assert total is not None |
| 22 | total_pages = ceil(total / PAGE_SIZE) | 25 | total_pages = ceil(total / PAGE_SIZE) |
| 23 | 26 | ||
| 27 | if total == 0: | ||
| 28 | return None | ||
| 29 | |||
| 24 | page = max(0, min(page, total_pages - 1)) | 30 | page = max(0, min(page, total_pages - 1)) |
| 25 | query = ( | 31 | query = ( |
| 26 | select(Announcement) | 32 | select(Announcement) |
| @@ -64,10 +70,13 @@ async def get_reply_markup(page: int, session: AsyncSession) -> InlineKeyboardMa | |||
| 64 | 70 | ||
| 65 | @router.message(Command("announcements")) | 71 | @router.message(Command("announcements")) |
| 66 | async def command(msg: Message, bot: Bot, session: AsyncSession) -> None: | 72 | async def command(msg: Message, bot: Bot, session: AsyncSession) -> None: |
| 67 | await msg.answer( | 73 | reply_markup = await get_reply_markup(0, session) |
| 68 | "Выберете анонс для просмотра.", | 74 | |
| 69 | reply_markup=await get_reply_markup(0, session), | 75 | if reply_markup is None: |
| 70 | ) | 76 | await msg.answer("Нету анонсов для просмотра.") |
| 77 | return | ||
| 78 | |||
| 79 | await msg.answer("Выберете анонс для просмотра.", reply_markup=reply_markup) | ||
| 71 | 80 | ||
| 72 | 81 | ||
| 73 | @router.callback_query(AnnouncePageClb.filter()) | 82 | @router.callback_query(AnnouncePageClb.filter()) |
| @@ -80,7 +89,7 @@ async def page( | |||
| 80 | 89 | ||
| 81 | reply_markup = await get_reply_markup(callback_data.page, session) | 90 | reply_markup = await get_reply_markup(callback_data.page, session) |
| 82 | await clb.message.edit_text( | 91 | await clb.message.edit_text( |
| 83 | "Выберете анонс для просмотра.", | 92 | "Выберете анонс для просмотра:", |
| 84 | reply_markup=reply_markup, | 93 | reply_markup=reply_markup, |
| 85 | ) | 94 | ) |
| 86 | 95 | ||
diff --git a/handlers/user/invoices.py b/handlers/user/invoices.py new file mode 100644 index 0000000..cc071bb --- /dev/null +++ b/handlers/user/invoices.py | |||
| @@ -0,0 +1,179 @@ | |||
| 1 | from math import ceil | ||
| 2 | |||
| 3 | from aiogram import Bot, Router | ||
| 4 | from aiogram.filters import Command | ||
| 5 | from aiogram.types import ( | ||
| 6 | CallbackQuery, | ||
| 7 | InlineKeyboardButton, | ||
| 8 | InlineKeyboardMarkup, | ||
| 9 | Message, | ||
| 10 | ) | ||
| 11 | from sqlalchemy import select | ||
| 12 | from sqlalchemy.ext.asyncio import AsyncSession | ||
| 13 | from sqlalchemy.sql.functions import count | ||
| 14 | |||
| 15 | from libs.invoice import ( | ||
| 16 | InvoiceStatus, | ||
| 17 | get_invoice_payments, | ||
| 18 | get_payment_status, | ||
| 19 | ) | ||
| 20 | from libs.msg import eclipse_text | ||
| 21 | from libs.user import mention | ||
| 22 | from models import Invoice, PaymentStatus, User | ||
| 23 | from models.callback_data import InvoiceItemClb, InvoicePageClb, PayInvoiceClb | ||
| 24 | |||
| 25 | router = Router(name="invoices") | ||
| 26 | PAGE_SIZE = 5 | ||
| 27 | PAYMENT_STATUS = { | ||
| 28 | PaymentStatus.PENDING: "🟡", | ||
| 29 | PaymentStatus.ACCEPTED: "🟢", | ||
| 30 | PaymentStatus.REJECTED: "🔴", | ||
| 31 | } | ||
| 32 | INVOICE_STATUS = { | ||
| 33 | InvoiceStatus.PAID: "🟢", | ||
| 34 | InvoiceStatus.UNPAID: "🔴", | ||
| 35 | } | ||
| 36 | |||
| 37 | |||
| 38 | def get_text(user: User) -> str: | ||
| 39 | if user.is_admin(): | ||
| 40 | return "Выберете счёт для просмотра информации:" | ||
| 41 | else: | ||
| 42 | return "Выберете счёт для оплаты:" | ||
| 43 | |||
| 44 | |||
| 45 | async def get_reply_markup( | ||
| 46 | page: int, | ||
| 47 | user: User, | ||
| 48 | session: AsyncSession, | ||
| 49 | ) -> InlineKeyboardMarkup | None: | ||
| 50 | total = await session.scalar( | ||
| 51 | select(count()).select_from(Invoice).where(Invoice.datetime >= user.datetime) | ||
| 52 | ) | ||
| 53 | assert total is not None | ||
| 54 | total_pages = ceil(total / PAGE_SIZE) | ||
| 55 | |||
| 56 | if total == 0: | ||
| 57 | return None | ||
| 58 | |||
| 59 | page = max(0, min(page, total_pages - 1)) | ||
| 60 | query = ( | ||
| 61 | select(Invoice) | ||
| 62 | .where(Invoice.datetime >= user.datetime) | ||
| 63 | .offset(PAGE_SIZE * page) | ||
| 64 | .limit(PAGE_SIZE) | ||
| 65 | .order_by(Invoice.id.desc()) | ||
| 66 | ) | ||
| 67 | invoices = await session.scalars(query) | ||
| 68 | |||
| 69 | invoice_buttons = [] | ||
| 70 | for i in invoices: | ||
| 71 | if user.is_admin(): | ||
| 72 | invoice_payments = await get_invoice_payments(session, i) | ||
| 73 | status = INVOICE_STATUS[invoice_payments.status] | ||
| 74 | callback_data = InvoiceItemClb(page=page, invoice_id=i.id).pack() | ||
| 75 | else: | ||
| 76 | status = PAYMENT_STATUS[await get_payment_status(session, i.id, user.id)] | ||
| 77 | callback_data = PayInvoiceClb(invoice_id=i.id).pack() | ||
| 78 | |||
| 79 | button = InlineKeyboardButton( | ||
| 80 | text=( | ||
| 81 | f"{status} " | ||
| 82 | f"{eclipse_text(i.message.text, 10)} " | ||
| 83 | f"({i.datetime.strftime('%d %b %y г.')})" | ||
| 84 | ), | ||
| 85 | callback_data=callback_data, | ||
| 86 | ) | ||
| 87 | |||
| 88 | invoice_buttons.append([button]) | ||
| 89 | |||
| 90 | page_buttons = [] | ||
| 91 | if page > 0: | ||
| 92 | page_buttons.append( | ||
| 93 | InlineKeyboardButton( | ||
| 94 | text="◀️", | ||
| 95 | callback_data=InvoicePageClb(page=page - 1).pack(), | ||
| 96 | ) | ||
| 97 | ) | ||
| 98 | if page < total_pages - 1: | ||
| 99 | page_buttons.append( | ||
| 100 | InlineKeyboardButton( | ||
| 101 | text="▶️", | ||
| 102 | callback_data=InvoicePageClb(page=page + 1).pack(), | ||
| 103 | ) | ||
| 104 | ) | ||
| 105 | |||
| 106 | return InlineKeyboardMarkup(inline_keyboard=[*invoice_buttons, page_buttons]) | ||
| 107 | |||
| 108 | |||
| 109 | @router.message(Command("invoices")) | ||
| 110 | async def command(msg: Message, session: AsyncSession, user: User) -> None: | ||
| 111 | reply_markup = await get_reply_markup(0, user, session) | ||
| 112 | |||
| 113 | if reply_markup is None: | ||
| 114 | await msg.answer("Нету счетов для оплаты.") | ||
| 115 | return | ||
| 116 | |||
| 117 | await msg.answer(get_text(user), reply_markup=reply_markup) | ||
| 118 | |||
| 119 | |||
| 120 | @router.callback_query(InvoicePageClb.filter()) | ||
| 121 | async def page( | ||
| 122 | clb: CallbackQuery, | ||
| 123 | callback_data: InvoicePageClb, | ||
| 124 | session: AsyncSession, | ||
| 125 | user: User, | ||
| 126 | ) -> None: | ||
| 127 | assert isinstance(clb.message, Message) | ||
| 128 | await clb.message.edit_text( | ||
| 129 | get_text(user), | ||
| 130 | reply_markup=await get_reply_markup(callback_data.page, user, session), | ||
| 131 | ) | ||
| 132 | |||
| 133 | await clb.answer() | ||
| 134 | |||
| 135 | |||
| 136 | @router.callback_query(InvoiceItemClb.filter()) | ||
| 137 | async def item( | ||
| 138 | clb: CallbackQuery, | ||
| 139 | bot: Bot, | ||
| 140 | callback_data: InvoiceItemClb, | ||
| 141 | session: AsyncSession, | ||
| 142 | user: User, | ||
| 143 | ) -> None: | ||
| 144 | assert isinstance(clb.message, Message) | ||
| 145 | if not user.is_admin(): | ||
| 146 | await clb.answer("У вас нет прав для данного действия.", show_alert=True) | ||
| 147 | return | ||
| 148 | |||
| 149 | invoice = await session.get(Invoice, callback_data.invoice_id) | ||
| 150 | assert invoice is not None | ||
| 151 | invoice_payments = await get_invoice_payments(session, invoice) | ||
| 152 | |||
| 153 | text_template = ( | ||
| 154 | f"Статус оплаты счёта: {INVOICE_STATUS[invoice_payments.status]}\n" | ||
| 155 | "Пользователи оплатившие счёт:\n" | ||
| 156 | "{}" | ||
| 157 | ) | ||
| 158 | reply_markup = InlineKeyboardMarkup( | ||
| 159 | inline_keyboard=[ | ||
| 160 | [ | ||
| 161 | InlineKeyboardButton( | ||
| 162 | text="Назад к выбору", | ||
| 163 | callback_data=InvoicePageClb(page=callback_data.page).pack(), | ||
| 164 | ) | ||
| 165 | ] | ||
| 166 | ] | ||
| 167 | ) | ||
| 168 | |||
| 169 | await clb.message.edit_text(text_template.format("..."), reply_markup=reply_markup) | ||
| 170 | user_status = [] | ||
| 171 | for user_id, s in invoice_payments.user_status.items(): | ||
| 172 | chat = await bot.get_chat(user_id) | ||
| 173 | user_status.append(f"{PAYMENT_STATUS[s]} - {mention(chat)}") | ||
| 174 | await clb.message.edit_text( | ||
| 175 | text_template.format("\n".join(user_status)), | ||
| 176 | reply_markup=reply_markup, | ||
| 177 | ) | ||
| 178 | |||
| 179 | await clb.answer() | ||
diff --git a/handlers/user/pay_invoice.py b/handlers/user/pay_invoice.py index 98f80a6..db75f47 100644 --- a/handlers/user/pay_invoice.py +++ b/handlers/user/pay_invoice.py | |||
| @@ -20,7 +20,15 @@ from sqlalchemy.ext.asyncio import AsyncSession | |||
| 20 | 20 | ||
| 21 | from libs.fsm import get_data, set_data | 21 | from libs.fsm import get_data, set_data |
| 22 | from libs.user import mention | 22 | from libs.user import mention |
| 23 | from models import Payment, PaymentStatus, ReceiptFile, ReceiptFileType, User, UserRole | 23 | from models import ( |
| 24 | Invoice, | ||
| 25 | Payment, | ||
| 26 | PaymentStatus, | ||
| 27 | ReceiptFile, | ||
| 28 | ReceiptFileType, | ||
| 29 | User, | ||
| 30 | UserRole, | ||
| 31 | ) | ||
| 24 | from models.callback_data import PayInvoiceClb, PaymentStatusClb | 32 | from models.callback_data import PayInvoiceClb, PaymentStatusClb |
| 25 | 33 | ||
| 26 | router = Router(name="pay_invoice") | 34 | router = Router(name="pay_invoice") |
| @@ -44,7 +52,19 @@ async def button( | |||
| 44 | state: FSMContext, | 52 | state: FSMContext, |
| 45 | callback_data: PayInvoiceClb, | 53 | callback_data: PayInvoiceClb, |
| 46 | session: AsyncSession, | 54 | session: AsyncSession, |
| 55 | user: User, | ||
| 47 | ) -> None: | 56 | ) -> None: |
| 57 | if user.is_admin(): | ||
| 58 | await clb.answer("Администраторы не могут оплачивать счета", show_alert=True) | ||
| 59 | return | ||
| 60 | |||
| 61 | invoice = await session.get(Invoice, callback_data.invoice_id) | ||
| 62 | assert invoice is not None | ||
| 63 | |||
| 64 | if user.datetime > invoice.datetime: | ||
| 65 | await clb.answer("Вы не можете оплатить данный счёт", show_alert=True) | ||
| 66 | return | ||
| 67 | |||
| 48 | payment = await session.scalar( | 68 | payment = await session.scalar( |
| 49 | select(Payment).where( | 69 | select(Payment).where( |
| 50 | and_( | 70 | and_( |
