From 9c905f22de4fa2e2f60ea9d473c14cb075a244e2 Mon Sep 17 00:00:00 2001 From: Tolmachev Igor Date: Thu, 26 Mar 2026 16:23:52 +0300 Subject: Add invoices command --- handlers/admin/new_invoice.py | 4 +- handlers/admin/payment_status.py | 8 +- handlers/user/__init__.py | 2 + handlers/user/announcements.py | 21 +++-- handlers/user/invoices.py | 179 +++++++++++++++++++++++++++++++++++++++ handlers/user/pay_invoice.py | 22 ++++- libs/__init__.py | 3 +- libs/invoice.py | 69 +++++++++++++++ main.py | 2 + models/callback_data.py | 9 ++ 10 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 handlers/user/invoices.py create mode 100644 libs/invoice.py 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 from libs.fsm import get_data, set_data from libs.msg import send_invoice -from models import Invoice, RichText, User +from models import Invoice, RichText, User, UserRole router = Router(name="new_invoice") @@ -59,7 +59,7 @@ async def create( state: FSMContext, session: AsyncSession, ) -> None: - users = await session.scalars(select(User.id).where(User.id != msg.chat.id)) + users = await session.scalars(select(User.id).where(User.role != UserRole.ADMIN)) data = await get_data(state, NewInvoiceData) 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") async def accept(clb: CallbackQuery, bot: Bot, payment: Payment) -> None: + assert clb.message is not None + payment.status = PaymentStatus.ACCEPTED - await bot.send_message(clb.from_user.id, "Оплата пользователя подтверждена.") + await clb.message.reply("Оплата пользователя подтверждена.") await bot.send_message(payment.user_id, "Файл подтверждения оплаты принят.") async def reject(clb: CallbackQuery, bot: Bot, payment: Payment) -> None: + assert clb.message is not None + payment.status = PaymentStatus.REJECTED - await bot.send_message(clb.from_user.id, "Оплата пользователя отклонена.") + await clb.message.reply("Оплата пользователя отклонена.") callback_data = PayInvoiceClb(invoice_id=payment.invoice_id).pack() 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 from . import vpn_link from . import pay_invoice from . import announcements +from . import invoices # isort: on router = Router(name="user") @@ -13,4 +14,5 @@ router.include_routers( vpn_link.router, pay_invoice.router, announcements.router, + invoices.router, ) 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") PAGE_SIZE = 5 -async def get_reply_markup(page: int, session: AsyncSession) -> InlineKeyboardMarkup: +async def get_reply_markup( + page: int, + session: AsyncSession, +) -> InlineKeyboardMarkup | None: total = await session.scalar(select(count()).select_from(Announcement)) assert total is not None total_pages = ceil(total / PAGE_SIZE) + if total == 0: + return None + page = max(0, min(page, total_pages - 1)) query = ( select(Announcement) @@ -64,10 +70,13 @@ async def get_reply_markup(page: int, session: AsyncSession) -> InlineKeyboardMa @router.message(Command("announcements")) async def command(msg: Message, bot: Bot, session: AsyncSession) -> None: - await msg.answer( - "Выберете анонс для просмотра.", - reply_markup=await get_reply_markup(0, session), - ) + reply_markup = await get_reply_markup(0, session) + + if reply_markup is None: + await msg.answer("Нету анонсов для просмотра.") + return + + await msg.answer("Выберете анонс для просмотра.", reply_markup=reply_markup) @router.callback_query(AnnouncePageClb.filter()) @@ -80,7 +89,7 @@ async def page( reply_markup = await get_reply_markup(callback_data.page, session) await clb.message.edit_text( - "Выберете анонс для просмотра.", + "Выберете анонс для просмотра:", reply_markup=reply_markup, ) 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 @@ +from math import ceil + +from aiogram import Bot, Router +from aiogram.filters import Command +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + Message, +) +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.sql.functions import count + +from libs.invoice import ( + InvoiceStatus, + get_invoice_payments, + get_payment_status, +) +from libs.msg import eclipse_text +from libs.user import mention +from models import Invoice, PaymentStatus, User +from models.callback_data import InvoiceItemClb, InvoicePageClb, PayInvoiceClb + +router = Router(name="invoices") +PAGE_SIZE = 5 +PAYMENT_STATUS = { + PaymentStatus.PENDING: "🟡", + PaymentStatus.ACCEPTED: "🟢", + PaymentStatus.REJECTED: "🔴", +} +INVOICE_STATUS = { + InvoiceStatus.PAID: "🟢", + InvoiceStatus.UNPAID: "🔴", +} + + +def get_text(user: User) -> str: + if user.is_admin(): + return "Выберете счёт для просмотра информации:" + else: + return "Выберете счёт для оплаты:" + + +async def get_reply_markup( + page: int, + user: User, + session: AsyncSession, +) -> InlineKeyboardMarkup | None: + total = await session.scalar( + select(count()).select_from(Invoice).where(Invoice.datetime >= user.datetime) + ) + assert total is not None + total_pages = ceil(total / PAGE_SIZE) + + if total == 0: + return None + + page = max(0, min(page, total_pages - 1)) + query = ( + select(Invoice) + .where(Invoice.datetime >= user.datetime) + .offset(PAGE_SIZE * page) + .limit(PAGE_SIZE) + .order_by(Invoice.id.desc()) + ) + invoices = await session.scalars(query) + + invoice_buttons = [] + for i in invoices: + if user.is_admin(): + invoice_payments = await get_invoice_payments(session, i) + status = INVOICE_STATUS[invoice_payments.status] + callback_data = InvoiceItemClb(page=page, invoice_id=i.id).pack() + else: + status = PAYMENT_STATUS[await get_payment_status(session, i.id, user.id)] + callback_data = PayInvoiceClb(invoice_id=i.id).pack() + + button = InlineKeyboardButton( + text=( + f"{status} " + f"{eclipse_text(i.message.text, 10)} " + f"({i.datetime.strftime('%d %b %y г.')})" + ), + callback_data=callback_data, + ) + + invoice_buttons.append([button]) + + page_buttons = [] + if page > 0: + page_buttons.append( + InlineKeyboardButton( + text="◀️", + callback_data=InvoicePageClb(page=page - 1).pack(), + ) + ) + if page < total_pages - 1: + page_buttons.append( + InlineKeyboardButton( + text="▶️", + callback_data=InvoicePageClb(page=page + 1).pack(), + ) + ) + + return InlineKeyboardMarkup(inline_keyboard=[*invoice_buttons, page_buttons]) + + +@router.message(Command("invoices")) +async def command(msg: Message, session: AsyncSession, user: User) -> None: + reply_markup = await get_reply_markup(0, user, session) + + if reply_markup is None: + await msg.answer("Нету счетов для оплаты.") + return + + await msg.answer(get_text(user), reply_markup=reply_markup) + + +@router.callback_query(InvoicePageClb.filter()) +async def page( + clb: CallbackQuery, + callback_data: InvoicePageClb, + session: AsyncSession, + user: User, +) -> None: + assert isinstance(clb.message, Message) + await clb.message.edit_text( + get_text(user), + reply_markup=await get_reply_markup(callback_data.page, user, session), + ) + + await clb.answer() + + +@router.callback_query(InvoiceItemClb.filter()) +async def item( + clb: CallbackQuery, + bot: Bot, + callback_data: InvoiceItemClb, + session: AsyncSession, + user: User, +) -> None: + assert isinstance(clb.message, Message) + if not user.is_admin(): + await clb.answer("У вас нет прав для данного действия.", show_alert=True) + return + + invoice = await session.get(Invoice, callback_data.invoice_id) + assert invoice is not None + invoice_payments = await get_invoice_payments(session, invoice) + + text_template = ( + f"Статус оплаты счёта: {INVOICE_STATUS[invoice_payments.status]}\n" + "Пользователи оплатившие счёт:\n" + "{}" + ) + reply_markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Назад к выбору", + callback_data=InvoicePageClb(page=callback_data.page).pack(), + ) + ] + ] + ) + + await clb.message.edit_text(text_template.format("..."), reply_markup=reply_markup) + user_status = [] + for user_id, s in invoice_payments.user_status.items(): + chat = await bot.get_chat(user_id) + user_status.append(f"{PAYMENT_STATUS[s]} - {mention(chat)}") + await clb.message.edit_text( + text_template.format("\n".join(user_status)), + reply_markup=reply_markup, + ) + + 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 from libs.fsm import get_data, set_data from libs.user import mention -from models import Payment, PaymentStatus, ReceiptFile, ReceiptFileType, User, UserRole +from models import ( + Invoice, + Payment, + PaymentStatus, + ReceiptFile, + ReceiptFileType, + User, + UserRole, +) from models.callback_data import PayInvoiceClb, PaymentStatusClb router = Router(name="pay_invoice") @@ -44,7 +52,19 @@ async def button( state: FSMContext, callback_data: PayInvoiceClb, session: AsyncSession, + user: User, ) -> None: + if user.is_admin(): + await clb.answer("Администраторы не могут оплачивать счета", show_alert=True) + return + + invoice = await session.get(Invoice, callback_data.invoice_id) + assert invoice is not None + + if user.datetime > invoice.datetime: + await clb.answer("Вы не можете оплатить данный счёт", show_alert=True) + return + payment = await session.scalar( select(Payment).where( and_( diff --git a/libs/__init__.py b/libs/__init__.py index 65f8ab7..55e6b19 100644 --- a/libs/__init__.py +++ b/libs/__init__.py @@ -1,8 +1,9 @@ -from . import fsm, msg, storage, user +from . import fsm, invoice, msg, storage, user __all__ = [ "storage", "fsm", "msg", "user", + "invoice", ] diff --git a/libs/invoice.py b/libs/invoice.py new file mode 100644 index 0000000..92b82f7 --- /dev/null +++ b/libs/invoice.py @@ -0,0 +1,69 @@ +from enum import StrEnum, auto + +from pydantic import BaseModel +from sqlalchemy import and_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from models import Invoice, Payment, PaymentStatus, User, UserRole + + +class InvoiceStatus(StrEnum): + PAID = auto() + UNPAID = auto() + + +class InvoicePayments(BaseModel): + user_status: dict[int, PaymentStatus] + status: InvoiceStatus + + +async def get_invoice_payments( + session: AsyncSession, + invoice: Invoice, +) -> InvoicePayments: + users = await session.scalars( + select(User).where( + and_( + User.role != UserRole.ADMIN, + User.datetime <= invoice.datetime, + ) + ) + ) + payments = await session.scalars( + select(Payment).where(Payment.invoice_id == invoice.id) + ) + + user_status = {u.id: PaymentStatus.REJECTED for u in users} + for p in payments: + if ( + p.status != PaymentStatus.REJECTED + and user_status[p.user_id] == PaymentStatus.REJECTED + ): + user_status[p.user_id] = p.status + + status = ( + InvoiceStatus.PAID + if all(s == PaymentStatus.ACCEPTED for s in user_status.values()) + else InvoiceStatus.UNPAID + ) + + return InvoicePayments(user_status=user_status, status=status) + + +async def get_payment_status( + session: AsyncSession, + invoice_id: int, + user_id: int, +) -> PaymentStatus: + payments = await session.scalars( + select(Payment).where( + and_( + Payment.invoice_id == invoice_id, + Payment.user_id == user_id, + ) + ) + ) + for p in payments: + if p.status != PaymentStatus.REJECTED: + return p.status + return PaymentStatus.REJECTED diff --git a/main.py b/main.py index 322e771..bb9c7ea 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ import asyncio +import locale import logging from aiogram.enums import UpdateType @@ -6,6 +7,7 @@ from aiogram.enums import UpdateType from handlers import router from shared import bot, dp +locale.setlocale(locale.LC_ALL, "ru_RU.UTF-8") logging.basicConfig(level=logging.INFO) dp.include_router(router) diff --git a/models/callback_data.py b/models/callback_data.py index 4d37226..f9c9a26 100644 --- a/models/callback_data.py +++ b/models/callback_data.py @@ -19,3 +19,12 @@ class AnnouncePageClb(CallbackData, prefix="a10t.p"): class AnnounceItemClb(CallbackData, prefix="a10t.i"): page: int announce_id: int + + +class InvoicePageClb(CallbackData, prefix="invoice.p"): + page: int + + +class InvoiceItemClb(CallbackData, prefix="invoice.i"): + page: int + invoice_id: int -- cgit v1.3