From 36ffca460f0ee8a070d584d502988a072ae7abe8 Mon Sep 17 00:00:00 2001 From: hexdev Date: Sun, 15 Feb 2026 15:04:50 +0700 Subject: [PATCH] invoice payment! prerelease --- ...1a6b57158ea_added_new_enums_for_invoice.py | 31 +++++++ ...ae4c5060c4_order_items_ondelete_cascade.py | 39 ++++++++ config.py | 12 ++- db/models/invoices.py | 1 + db/models/orders.py | 2 +- entrypoints/startup.sh | 2 +- handlers/client/checkout.py | 23 +++-- handlers/client/invoices.py | 92 ++++++++++++++++++- handlers/client/menu.py | 5 + handlers/client/products.py | 2 +- misc/kb/admins.py | 12 +++ misc/kb/client.py | 13 +++ repositories/invoices.py | 10 ++ repositories/orders.py | 14 ++- services/orders.py | 23 ----- 15 files changed, 240 insertions(+), 41 deletions(-) create mode 100644 alembic/versions/41a6b57158ea_added_new_enums_for_invoice.py create mode 100644 alembic/versions/92ae4c5060c4_order_items_ondelete_cascade.py diff --git a/alembic/versions/41a6b57158ea_added_new_enums_for_invoice.py b/alembic/versions/41a6b57158ea_added_new_enums_for_invoice.py new file mode 100644 index 0000000..777dfe0 --- /dev/null +++ b/alembic/versions/41a6b57158ea_added_new_enums_for_invoice.py @@ -0,0 +1,31 @@ +"""added new enums for invoice + +Revision ID: 41a6b57158ea +Revises: 92ae4c5060c4 +Create Date: 2026-02-15 13:44:11.676492 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "41a6b57158ea" +down_revision: Union[str, Sequence[str], None] = "92ae4c5060c4" +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.execute("ALTER TYPE invoicestatus ADD VALUE 'RECEIVED'") + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + pass + # ### end Alembic commands ### diff --git a/alembic/versions/92ae4c5060c4_order_items_ondelete_cascade.py b/alembic/versions/92ae4c5060c4_order_items_ondelete_cascade.py new file mode 100644 index 0000000..79ff4d4 --- /dev/null +++ b/alembic/versions/92ae4c5060c4_order_items_ondelete_cascade.py @@ -0,0 +1,39 @@ +"""order_items -> ondelete=cascade + +Revision ID: 92ae4c5060c4 +Revises: 536f700c6d2b +Create Date: 2026-02-15 13:03:35.591725 + +""" + +from typing import Sequence, Union + +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "92ae4c5060c4" +down_revision: Union[str, Sequence[str], None] = "536f700c6d2b" +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.drop_constraint( + op.f("order_items_order_id_fkey"), "order_items", type_="foreignkey" + ) + op.create_foreign_key( + None, "order_items", "orders", ["order_id"], ["id"], ondelete="CASCADE" + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, "order_items", type_="foreignkey") + op.create_foreign_key( + op.f("order_items_order_id_fkey"), "order_items", "orders", ["order_id"], ["id"] + ) + # ### end Alembic commands ### diff --git a/config.py b/config.py index d45c8e8..16a3b1f 100644 --- a/config.py +++ b/config.py @@ -1,10 +1,19 @@ +from dataclasses import dataclass import os import logging from dotenv import load_dotenv + +@dataclass +class CardInfo: + number: str + holder: str + + load_dotenv(override=True) logger = logging.getLogger(__name__) + ### Environment Variables ### BOT_TOKEN = os.getenv("BOT_TOKEN", "") @@ -14,8 +23,7 @@ POSTGRES_URL = os.getenv( "postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres", ) -POST_TOKEN = os.getenv("POST_TOKEN", "") -POST_AUTH_KEY = os.getenv("POST_AUTH_KEY", "") +card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", "")) ### Constants ### diff --git a/db/models/invoices.py b/db/models/invoices.py index 53f0ed9..c27ca7a 100644 --- a/db/models/invoices.py +++ b/db/models/invoices.py @@ -9,6 +9,7 @@ from db.base import Base class InvoiceStatus(E_cls): PENDING = "PENDING" + RECEIVED = "RECEIVED" PAID = "PAID" EXPIRED = "EXPIRED" diff --git a/db/models/orders.py b/db/models/orders.py index 5914ef9..7000f82 100644 --- a/db/models/orders.py +++ b/db/models/orders.py @@ -29,7 +29,7 @@ class OrderItem(Base): __tablename__ = "order_items" id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) - order_id: Mapped[int] = mapped_column(ForeignKey("orders.id")) + order_id: Mapped[int] = mapped_column(ForeignKey("orders.id", ondelete="CASCADE")) product_id: Mapped[int] = mapped_column(ForeignKey("products.id")) quantity: Mapped[int] = mapped_column(default=1) diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh index e3344a1..0c27bf3 100644 --- a/entrypoints/startup.sh +++ b/entrypoints/startup.sh @@ -7,7 +7,7 @@ if [[ "$VIRTUAL_ENV" != "" ]] then echo "process is in venv, skipping." else - echo "entering venv." + echo "venv not found, entering..." source $(pwd)/venv/bin/activate fi diff --git a/handlers/client/checkout.py b/handlers/client/checkout.py index d121eb9..0907cf5 100644 --- a/handlers/client/checkout.py +++ b/handlers/client/checkout.py @@ -137,6 +137,7 @@ async def payment_create( state: FSMContext, session: AsyncSession, order_service: OrderService, + order_repo: OrderRepository, ): data = await state.get_data() ctx: Optional[CheckoutContext] = data.get("ctx") @@ -150,16 +151,15 @@ async def payment_create( await state.clear() - await cb.message.edit_text( - "🟢 Ваш заказ успешно отправлен!\n" - "📦 Мы свяжемся с вами для уточнения деталей в ближайшее время.\n\n" - "‼️ Чтобы убедиться, что вы общаетесь с доверенным контактом ШВЕЙТЕХЦЕНТР, перешлите любое сообщение человека в этот чат.", - reply_markup=return_menu, - ) - - cart = await order_service.build_full_cart_dto(session, cb.from_user.id) - try: + await cb.message.edit_text( + "🟢 Ваш заказ успешно отправлен!\n" + "📦 Мы свяжемся с вами для уточнения деталей в ближайшее время.\n\n" + "‼️ Чтобы убедиться, что вы общаетесь с доверенным контактом ШВЕЙТЕХЦЕНТР, перешлите любое сообщение человека в этот чат.", + reply_markup=return_menu, + ) + + cart = await order_service.build_full_cart_dto(session, cb.from_user.id) await cb.bot.send_message( NOTIFICATION_CHANNEL, "🟢 Новый заказ.\n\n" @@ -173,6 +173,11 @@ async def payment_create( cb.from_user.id, bool(cb.from_user.username), cb.from_user.username ), ) + await order_repo.remove_order_by_customer(session, cb.from_user.id) except Exception as e: + await cb.message.edit_text( + "🍃 Что-то пошло не так, повторите попытку позже...", + reply_markup=return_menu, + ) logger.exception(e) return diff --git a/handlers/client/invoices.py b/handlers/client/invoices.py index b421334..d29c134 100644 --- a/handlers/client/invoices.py +++ b/handlers/client/invoices.py @@ -1,13 +1,19 @@ import logging -from aiogram import Router +from aiogram import Router, F from aiogram.filters import CommandObject, CommandStart +from aiogram.fsm.context import FSMContext from aiogram.types import ( + CallbackQuery, Message, ) from sqlalchemy.ext.asyncio import AsyncSession +from db.models.invoices import InvoiceStatus +from misc.kb.admins import verify_payment +from misc.kb.client import confirm_payment, return_menu from repositories.invoices import InvoiceRepository +from config import card_info router = Router() logger = logging.getLogger(__name__) @@ -24,6 +30,86 @@ async def activate_invoice( invoice_id = int(payload.split(":")[1]) invoice = await invoice_repo.get_invoice_by_id(session, invoice_id) + + if not invoice: + await msg.answer( + "⏳ Счёт истёк. Повторите попытку позже.", reply_markup=return_menu + ) + return + + if invoice.status != InvoiceStatus.PENDING: + await msg.answer("⏳ Этот счёт недоступен.", reply_markup=return_menu) + return + + if not (card_info.number and card_info.holder): + await msg.answer( + "🍃 Оплата недоступна, повторите попытку позже.", reply_markup=return_menu + ) + return + await msg.answer( - f"🟢 Получен счёт на {invoice.amount}₽" - ) # TODO: Process payment + f"💎 Счёт на {invoice.amount}₽\n\n" + "🔸 Проведите оплату по реквизитам ниже:\n" + f"🪪 Номер карты: {card_info.number}\n" + f"👤 Держатель карты: {card_info.holder}", + reply_markup=confirm_payment(invoice_id), + ) + + +@router.callback_query(F.data.startswith("refresh_payment:")) +async def refresh_payment( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + invoice_repo: InvoiceRepository, +): + await state.clear() + + invoice_id = int(cb.data.split(":")[1]) + invoice = await invoice_repo.get_invoice_by_id(session, invoice_id) + + if not invoice: + await cb.message.edit_text("🍃 Оплата недоступна.") + return + + await cb.message.edit_reply_markup() + await cb.message.reply( + "💸 Вы отметили платёж как оплаченый. Если вы сделали это ошибочно, проведите платёж в ближайшее время.", + reply_markup=return_menu, + ) + + await invoice_repo.update_invoice_status( + session, invoice_id=invoice.id, status=InvoiceStatus.PAID + ) + + await cb.bot.edit_message_text( + inline_message_id=invoice.inline_message_id, + text="🟡 Счёт помечен как оплаченый, жду подтверждения...", + reply_markup=verify_payment(invoice.id), + ) + + +@router.callback_query(F.data.startswith("verify_payment:")) +async def verify_payment_cb( + cb: CallbackQuery, + state: FSMContext, + session: AsyncSession, + invoice_repo: InvoiceRepository, +): + await state.clear() + + invoice_id = int(cb.data.split(":")[1]) + invoice = await invoice_repo.get_invoice_by_id(session, invoice_id) + + if cb.from_user.id != invoice.creator_id: + await cb.answer("❌ Подтвердить может только создатель счёта") + return + + await invoice_repo.update_invoice_status( + session, invoice_id=invoice_id, status=InvoiceStatus.RECEIVED + ) + + await cb.bot.edit_message_text( + inline_message_id=invoice.inline_message_id, + text=f"✅ Платёж на {invoice.amount}₽ успешно оплачен.", + ) diff --git a/handlers/client/menu.py b/handlers/client/menu.py index 2b55301..b0425fc 100644 --- a/handlers/client/menu.py +++ b/handlers/client/menu.py @@ -37,6 +37,11 @@ async def main_menu_cb( ): await state.clear() + try: + await cb.message.reply_to_message.delete() + except Exception: + ... + cart_items = await order_items_repo.get_items_count_by_customer( session, cb.from_user.id ) diff --git a/handlers/client/products.py b/handlers/client/products.py index 3af58cb..e06514d 100644 --- a/handlers/client/products.py +++ b/handlers/client/products.py @@ -146,7 +146,7 @@ async def add_to_cart( else: show_cart = False quantity = 0 - await order_service.clear_cart_item( + await order_service.clear_from_cart( session, customer=cb.from_user.id, product_id=product_id ) diff --git a/misc/kb/admins.py b/misc/kb/admins.py index 4c44b57..d70db04 100644 --- a/misc/kb/admins.py +++ b/misc/kb/admins.py @@ -32,3 +32,15 @@ def payment_link(url: str) -> InlineKeyboardMarkup: return InlineKeyboardBuilder( [[InlineKeyboardButton(text="💸 Оплатить", url=url)]] ).as_markup() + + +def verify_payment(invoice_id: int): + return InlineKeyboardBuilder( + [ + [ + InlineKeyboardButton( + text="🟢 Подтвердить", callback_data=f"verify_payment:{invoice_id}" + ) + ] + ] + ).as_markup() diff --git a/misc/kb/client.py b/misc/kb/client.py index d36bfa4..6f1e502 100644 --- a/misc/kb/client.py +++ b/misc/kb/client.py @@ -221,3 +221,16 @@ def order_specs_confirmation(order_id: int) -> InlineKeyboardMarkup: [InlineKeyboardButton(text="❌", callback_data="menu:main")], ] ).as_markup() + + +def confirm_payment(invoice_id: int): + return InlineKeyboardBuilder( + [ + [ + InlineKeyboardButton( + text="✅ Подтвердить оплату", + callback_data=f"refresh_payment:{invoice_id}", + ) + ] + ] + ).as_markup() diff --git a/repositories/invoices.py b/repositories/invoices.py index a939a36..bb3afac 100644 --- a/repositories/invoices.py +++ b/repositories/invoices.py @@ -43,3 +43,13 @@ class InvoiceRepository: .values(inline_message_id=inline_message_id) ) await session.execute(stmt) + + async def update_invoice_status( + self, session: AsyncSession, *, invoice_id: int, status: InvoiceStatus + ): + stmt = ( + update(Invoice.__table__) + .where(Invoice.id == invoice_id) + .values(status=status) + ) + await session.execute(stmt) diff --git a/repositories/orders.py b/repositories/orders.py index 7e174d3..286bacc 100644 --- a/repositories/orders.py +++ b/repositories/orders.py @@ -1,5 +1,5 @@ from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select +from sqlalchemy import delete, select from typing import Optional from sqlalchemy.orm import selectinload @@ -46,3 +46,15 @@ class OrderRepository: await session.commit() return order + + async def remove_order_by_id(self, session: AsyncSession, order_id: int): + stmt = delete(Order.__table__).where(Order.id == order_id) + + await session.execute(stmt) + await session.commit() + + async def remove_order_by_customer(self, session: AsyncSession, customer: int): + stmt = delete(Order.__table__).where(Order.customer == customer) + + await session.execute(stmt) + await session.commit() diff --git a/services/orders.py b/services/orders.py index c08a6d6..90a4b08 100644 --- a/services/orders.py +++ b/services/orders.py @@ -126,29 +126,6 @@ class OrderService: return order_item.quantity - async def clear_cart_item( - self, session: AsyncSession, *, customer: int, product_id: int - ) -> None: - order = await self.order_repo.get_draft_order_by_user(session, customer) - - if not order: - raise OrderServiceException( - "attempt of clearing a non-existing cart, potential callback_query exploit, ignoring..." - ) - - order_item = await self.order_items_repo.get_item_by_order_and_product( - session, order_id=order.id, product_id=product_id - ) - - if not order_item: - raise OrderServiceException( - "attempt of clearing an order_item that isn't in a cart, potential callback_query exploit, ignoring..." - ) - - await self.order_items_repo.update_order_item_quantity( - session, order_item_id=order_item.id, quantity=0 - ) - async def build_pagination_cart_dto( self, session: AsyncSession,