From 0444ff325490f24e9a8d35f83ba37a0bd95ab6c5 Mon Sep 17 00:00:00 2001 From: Tolmachev Igor Date: Mon, 23 Mar 2026 22:17:24 +0300 Subject: Add pay_invoice button --- alembic/versions/1627487324fd_init_database.py | 72 ----------- alembic/versions/c0c4d0fbcee2_init_database.py | 77 ++++++++++++ codebook.toml | 2 + handlers/admin/add_user.py | 2 +- handlers/user/__init__.py | 2 + handlers/user/pay_invoice.py | 160 +++++++++++++++++++++++++ libs/__init__.py | 3 +- libs/msg.py | 4 +- libs/user.py | 5 + models/__init__.py | 8 +- models/callback_data.py | 9 +- models/payment.py | 52 +++++++- models/suggest.py | 3 +- 13 files changed, 316 insertions(+), 83 deletions(-) delete mode 100644 alembic/versions/1627487324fd_init_database.py create mode 100644 alembic/versions/c0c4d0fbcee2_init_database.py create mode 100644 handlers/user/pay_invoice.py create mode 100644 libs/user.py diff --git a/alembic/versions/1627487324fd_init_database.py b/alembic/versions/1627487324fd_init_database.py deleted file mode 100644 index f25277f..0000000 --- a/alembic/versions/1627487324fd_init_database.py +++ /dev/null @@ -1,72 +0,0 @@ -"""init database - -Revision ID: 1627487324fd -Revises: -Create Date: 2026-03-23 18:33:08.493629 - -""" - -from typing import Sequence, Union - -import sqlalchemy as sa - -from alembic import op - -# revision identifiers, used by Alembic. -revision: str = "1627487324fd" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table( - "announcement", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("message", sa.JSON(), nullable=False), - sa.Column("datetime", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_announcement")), - ) - op.create_table( - "invoice", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("message", sa.JSON(), nullable=False), - sa.Column("datetime", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_invoice")), - ) - op.create_table( - "user", - sa.Column("id", sa.Integer(), autoincrement=False, nullable=False), - sa.Column("role", sa.Enum("REGULAR", "ADMIN", name="userrole"), nullable=False), - sa.Column("vpn_link", sa.String(), nullable=False), - sa.Column("datetime", sa.DateTime(), nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), - ) - op.create_table( - "payment", - sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), - sa.Column("user_id", sa.Integer(), nullable=False), - sa.Column("invoice_id", sa.Integer(), nullable=False), - sa.Column("receipt_file_id", sa.String(), nullable=False), - sa.Column("datetime", sa.DateTime(), nullable=False), - sa.ForeignKeyConstraint( - ["invoice_id"], ["invoice.id"], name=op.f("fk_payment_invoice_id_invoice") - ), - sa.ForeignKeyConstraint( - ["user_id"], ["user.id"], name=op.f("fk_payment_user_id_user") - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_payment")), - ) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_table("payment") - op.drop_table("user") - op.drop_table("invoice") - op.drop_table("announcement") - # ### end Alembic commands ### diff --git a/alembic/versions/c0c4d0fbcee2_init_database.py b/alembic/versions/c0c4d0fbcee2_init_database.py new file mode 100644 index 0000000..8f61846 --- /dev/null +++ b/alembic/versions/c0c4d0fbcee2_init_database.py @@ -0,0 +1,77 @@ +"""init database + +Revision ID: c0c4d0fbcee2 +Revises: +Create Date: 2026-03-23 21:19:28.195907 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "c0c4d0fbcee2" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "announcement", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("message", sa.JSON(), nullable=False), + sa.Column("datetime", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_announcement")), + ) + op.create_table( + "invoice", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("message", sa.JSON(), nullable=False), + sa.Column("datetime", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_invoice")), + ) + op.create_table( + "user", + sa.Column("id", sa.Integer(), autoincrement=False, nullable=False), + sa.Column("role", sa.Enum("REGULAR", "ADMIN", name="userrole"), nullable=False), + sa.Column("vpn_link", sa.String(), nullable=False), + sa.Column("datetime", sa.DateTime(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user")), + ) + op.create_table( + "payment", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("invoice_id", sa.Integer(), nullable=False), + sa.Column("receipt_file", sa.JSON(), nullable=False), + sa.Column( + "status", + sa.Enum("PENDING", "ACCEPTED", "REJECTED", name="paymentstatus"), + nullable=False, + ), + sa.Column("datetime", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint( + ["invoice_id"], ["invoice.id"], name=op.f("fk_payment_invoice_id_invoice") + ), + sa.ForeignKeyConstraint( + ["user_id"], ["user.id"], name=op.f("fk_payment_user_id_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_payment")), + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("payment") + op.drop_table("user") + op.drop_table("invoice") + op.drop_table("announcement") + # ### end Alembic commands ### diff --git a/codebook.toml b/codebook.toml index a599a2c..e1cb340 100644 --- a/codebook.toml +++ b/codebook.toml @@ -3,10 +3,12 @@ words = [ "aiohttp", "aiosqlite", "asyncio", + "clb", "isort", "pycache", "pydantic", "pyright", "tablename", "venv", + "скриншот", ] diff --git a/handlers/admin/add_user.py b/handlers/admin/add_user.py index c58af2b..1d19834 100644 --- a/handlers/admin/add_user.py +++ b/handlers/admin/add_user.py @@ -95,5 +95,5 @@ async def set_vpn_link( session.add(User(id=data.user_id, vpn_link=msg.text, datetime=datetime.now(UTC))) await session.flush() - await msg.answer("Пользователь добавлен.") + await msg.answer("Пользователь добавлен.", reply_markup=ReplyKeyboardRemove()) await state.clear() diff --git a/handlers/user/__init__.py b/handlers/user/__init__.py index ac4c0a3..4b43427 100644 --- a/handlers/user/__init__.py +++ b/handlers/user/__init__.py @@ -3,10 +3,12 @@ from aiogram import Router # isort: off from . import info from . import vpn_link +from . import pay_invoice # isort: on router = Router(name="user") router.include_routers( info.router, vpn_link.router, + pay_invoice.router, ) diff --git a/handlers/user/pay_invoice.py b/handlers/user/pay_invoice.py new file mode 100644 index 0000000..98f80a6 --- /dev/null +++ b/handlers/user/pay_invoice.py @@ -0,0 +1,160 @@ +from datetime import UTC, datetime + +from aiogram import Bot, F, Router +from aiogram.enums import ButtonStyle +from aiogram.exceptions import TelegramAPIError +from aiogram.fsm.context import FSMContext +from aiogram.fsm.state import State, StatesGroup +from aiogram.types import ( + CallbackQuery, + InlineKeyboardButton, + InlineKeyboardMarkup, + KeyboardButton, + Message, + ReplyKeyboardMarkup, + ReplyKeyboardRemove, +) +from pydantic import BaseModel +from sqlalchemy import and_, select +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.callback_data import PayInvoiceClb, PaymentStatusClb + +router = Router(name="pay_invoice") + + +class PayInvoiceStates(StatesGroup): + receipt = State() + + +class PayInvoiceData(BaseModel): + invoice_id: int + + +CANCEL_BUTTON = "Отмена оплаты" + + +@router.callback_query(PayInvoiceClb.filter()) +async def button( + clb: CallbackQuery, + bot: Bot, + state: FSMContext, + callback_data: PayInvoiceClb, + session: AsyncSession, +) -> None: + payment = await session.scalar( + select(Payment).where( + and_( + Payment.user_id == clb.from_user.id, + Payment.invoice_id == callback_data.invoice_id, + Payment.status != PaymentStatus.REJECTED, + ) + ) + ) + + if payment is not None: + await clb.answer( + "Вы уже оплатили данный счёт.", + show_alert=True, + ) + return + + await bot.send_message( + clb.from_user.id, + "Укажите подтверждение оплаты (скриншот, pdf чека и т.п.)", + reply_markup=ReplyKeyboardMarkup( + keyboard=[[KeyboardButton(text=CANCEL_BUTTON, style=ButtonStyle.DANGER)]], + resize_keyboard=True, + ), + ) + + await state.set_state(PayInvoiceStates.receipt) + await set_data(state, PayInvoiceData(invoice_id=callback_data.invoice_id)) + + await clb.answer() + + +@router.message(PayInvoiceStates.receipt, F.text == CANCEL_BUTTON) +async def cancel(msg: Message, state: FSMContext) -> None: + await msg.answer( + "Отправка подтверждений оплаты отменена.", + reply_markup=ReplyKeyboardRemove(), + ) + await state.clear() + + +@router.message(PayInvoiceStates.receipt) +async def receipt( + msg: Message, + bot: Bot, + state: FSMContext, + session: AsyncSession, +) -> None: + if msg.document is not None: + receipt_file = ReceiptFile( + type=ReceiptFileType.DOCUMENT, + file_id=msg.document.file_id, + ) + elif msg.photo is not None: + receipt_file = ReceiptFile( + type=ReceiptFileType.PHOTO, + file_id=max(msg.photo, key=lambda p: (p.width, p.height)).file_id, + ) + else: + await msg.answer("Вы должны прислать файл или фото.") + return + + data = await get_data(state, PayInvoiceData) + payment = Payment( + user_id=msg.chat.id, + invoice_id=data.invoice_id, + receipt_file=receipt_file, + datetime=datetime.now(UTC), + ) + session.add(payment) + await session.flush() + + await msg.answer( + "Файл подтверждения оплаты прикреплен.", + reply_markup=ReplyKeyboardRemove(), + ) + await state.clear() + + admin_ids = await session.scalars( + select(User.id).where(User.role == UserRole.ADMIN) + ) + reply_markup = InlineKeyboardMarkup( + inline_keyboard=[ + [ + InlineKeyboardButton( + text="Подтвердить", + callback_data=PaymentStatusClb( + payment_id=payment.id, + payment_status=PaymentStatus.ACCEPTED, + ).pack(), + style=ButtonStyle.SUCCESS, + ), + InlineKeyboardButton( + text="Отклонить", + callback_data=PaymentStatusClb( + payment_id=payment.id, + payment_status=PaymentStatus.REJECTED, + ).pack(), + style=ButtonStyle.DANGER, + ), + ] + ] + ) + + for admin_id in admin_ids: + try: + await bot.send_message( + admin_id, + f"Новое подтверждение оплаты:\nПользователь: {mention(msg.chat)}", + ) + await receipt_file.send(bot, admin_id, reply_markup=reply_markup) + except TelegramAPIError as e: + print(e) diff --git a/libs/__init__.py b/libs/__init__.py index d8ed122..65f8ab7 100644 --- a/libs/__init__.py +++ b/libs/__init__.py @@ -1,7 +1,8 @@ -from . import fsm, msg, storage +from . import fsm, msg, storage, user __all__ = [ "storage", "fsm", "msg", + "user", ] diff --git a/libs/msg.py b/libs/msg.py index 2e9e16b..a00dd0c 100644 --- a/libs/msg.py +++ b/libs/msg.py @@ -7,7 +7,7 @@ from aiogram.exceptions import TelegramAPIError, TelegramRetryAfter from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from models import RichText -from models.callback_data import PayInvoiceData +from models.callback_data import PayInvoiceClb async def publish_announcement( @@ -34,7 +34,7 @@ async def send_invoice( rich_text: RichText, invoice_id: int, ) -> AsyncGenerator[int]: - callback_data = PayInvoiceData(invoice_id=invoice_id).pack() + callback_data = PayInvoiceClb(invoice_id=invoice_id).pack() reply_markup = InlineKeyboardMarkup( inline_keyboard=[ [ diff --git a/libs/user.py b/libs/user.py new file mode 100644 index 0000000..b201ce9 --- /dev/null +++ b/libs/user.py @@ -0,0 +1,5 @@ +from aiogram.types import Chat, User + + +def mention(user: User | Chat) -> str: + return f'{user.full_name}' diff --git a/models/__init__.py b/models/__init__.py index 0547429..f26ee74 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,9 +1,9 @@ # isort: off from .base import BaseTable from .rich_text import RichText -from .user import User +from .user import User, UserRole from .invoce import Invoice -from .payment import Payment +from .payment import Payment, PaymentStatus, ReceiptFile, ReceiptFileType from .announcement import Announcement from . import callback_data # isort: on @@ -11,8 +11,12 @@ from . import callback_data __all__ = [ "BaseTable", "User", + "UserRole", "Invoice", "Payment", + "PaymentStatus", + "ReceiptFile", + "ReceiptFileType", "RichText", "Announcement", "callback_data", diff --git a/models/callback_data.py b/models/callback_data.py index d3e6d61..137c4fa 100644 --- a/models/callback_data.py +++ b/models/callback_data.py @@ -1,5 +1,12 @@ from aiogram.filters.callback_data import CallbackData +from models import PaymentStatus -class PayInvoiceData(CallbackData, prefix="pay_invoice"): + +class PayInvoiceClb(CallbackData, prefix="pay_invoice"): invoice_id: int + + +class PaymentStatusClb(CallbackData, prefix="payment_status"): + payment_id: int + payment_status: PaymentStatus diff --git a/models/payment.py b/models/payment.py index 2b1cb90..afae642 100644 --- a/models/payment.py +++ b/models/payment.py @@ -1,16 +1,64 @@ from datetime import datetime +from enum import StrEnum, auto -from sqlalchemy import ForeignKey +from aiogram import Bot +from aiogram.types import Message, ReplyMarkupUnion +from pydantic import BaseModel +from sqlalchemy import JSON, ForeignKey from sqlalchemy.orm import Mapped, mapped_column from models import BaseTable, Invoice, User +class ReceiptFileType(StrEnum): + PHOTO = auto() + DOCUMENT = auto() + + +class ReceiptFile(BaseModel): + type: ReceiptFileType + file_id: str + + async def send( + self, + bot: Bot, + user_id: int, + reply_markup: ReplyMarkupUnion | None = None, + ) -> Message: + if self.type == ReceiptFileType.DOCUMENT: + return await bot.send_document( + user_id, + document=self.file_id, + reply_markup=reply_markup, + ) + else: + return await bot.send_photo( + user_id, + photo=self.file_id, + reply_markup=reply_markup, + ) + + +class PaymentStatus(StrEnum): + PENDING = auto() + ACCEPTED = auto() + REJECTED = auto() + + class Payment(BaseTable): __tablename__ = "payment" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) user_id: Mapped[int] = mapped_column(ForeignKey(User.id)) invoice_id: Mapped[int] = mapped_column(ForeignKey(Invoice.id)) - receipt_file_id: Mapped[str] + __receipt_file: Mapped[str] = mapped_column("receipt_file", JSON()) + status: Mapped[PaymentStatus] = mapped_column(default=PaymentStatus.PENDING) datetime: Mapped[datetime] + + @property + def receipt_file(self) -> ReceiptFile: + return ReceiptFile.model_validate_json(self.__receipt_file) + + @receipt_file.setter + def receipt_file(self, value: ReceiptFile) -> None: + self.__receipt_file = value.model_dump_json() diff --git a/models/suggest.py b/models/suggest.py index a76a004..bd628bb 100644 --- a/models/suggest.py +++ b/models/suggest.py @@ -4,8 +4,7 @@ from sqlalchemy import JSON from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.sql.schema import ForeignKey -from models import RichText, User -from models.base import BaseTable +from models import BaseTable, RichText, User class Suggest(BaseTable): -- cgit v1.3