invoice payment! prerelease

This commit is contained in:
2026-02-15 15:04:50 +07:00
parent 50b1a8f80c
commit 36ffca460f
15 changed files with 240 additions and 41 deletions

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -1,10 +1,19 @@
from dataclasses import dataclass
import os import os
import logging import logging
from dotenv import load_dotenv from dotenv import load_dotenv
@dataclass
class CardInfo:
number: str
holder: str
load_dotenv(override=True) load_dotenv(override=True)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
### Environment Variables ### ### Environment Variables ###
BOT_TOKEN = os.getenv("BOT_TOKEN", "") BOT_TOKEN = os.getenv("BOT_TOKEN", "")
@@ -14,8 +23,7 @@ POSTGRES_URL = os.getenv(
"postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres", "postgresql+asyncpg://postgres:HEXDEVFUCKINGSUCKS!@localhost:5432/postgres",
) )
POST_TOKEN = os.getenv("POST_TOKEN", "") card_info = CardInfo(os.getenv("CARD_NUMBER", ""), os.getenv("CARD_HOLDER", ""))
POST_AUTH_KEY = os.getenv("POST_AUTH_KEY", "")
### Constants ### ### Constants ###

View File

@@ -9,6 +9,7 @@ from db.base import Base
class InvoiceStatus(E_cls): class InvoiceStatus(E_cls):
PENDING = "PENDING" PENDING = "PENDING"
RECEIVED = "RECEIVED"
PAID = "PAID" PAID = "PAID"
EXPIRED = "EXPIRED" EXPIRED = "EXPIRED"

View File

@@ -29,7 +29,7 @@ class OrderItem(Base):
__tablename__ = "order_items" __tablename__ = "order_items"
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) 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")) product_id: Mapped[int] = mapped_column(ForeignKey("products.id"))
quantity: Mapped[int] = mapped_column(default=1) quantity: Mapped[int] = mapped_column(default=1)

View File

@@ -7,7 +7,7 @@ if [[ "$VIRTUAL_ENV" != "" ]]
then then
echo "process is in venv, skipping." echo "process is in venv, skipping."
else else
echo "entering venv." echo "venv not found, entering..."
source $(pwd)/venv/bin/activate source $(pwd)/venv/bin/activate
fi fi

View File

@@ -137,6 +137,7 @@ async def payment_create(
state: FSMContext, state: FSMContext,
session: AsyncSession, session: AsyncSession,
order_service: OrderService, order_service: OrderService,
order_repo: OrderRepository,
): ):
data = await state.get_data() data = await state.get_data()
ctx: Optional[CheckoutContext] = data.get("ctx") ctx: Optional[CheckoutContext] = data.get("ctx")
@@ -150,6 +151,7 @@ async def payment_create(
await state.clear() await state.clear()
try:
await cb.message.edit_text( await cb.message.edit_text(
"<b>🟢 Ваш заказ успешно отправлен!</b>\n" "<b>🟢 Ваш заказ успешно отправлен!</b>\n"
"<i>📦 Мы свяжемся с вами для уточнения деталей в ближайшее время.</i>\n\n" "<i>📦 Мы свяжемся с вами для уточнения деталей в ближайшее время.</i>\n\n"
@@ -158,8 +160,6 @@ async def payment_create(
) )
cart = await order_service.build_full_cart_dto(session, cb.from_user.id) cart = await order_service.build_full_cart_dto(session, cb.from_user.id)
try:
await cb.bot.send_message( await cb.bot.send_message(
NOTIFICATION_CHANNEL, NOTIFICATION_CHANNEL,
"🟢 Новый заказ.\n\n" "🟢 Новый заказ.\n\n"
@@ -173,6 +173,11 @@ async def payment_create(
cb.from_user.id, bool(cb.from_user.username), cb.from_user.username 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: except Exception as e:
await cb.message.edit_text(
"🍃 Что-то пошло не так, повторите попытку позже...",
reply_markup=return_menu,
)
logger.exception(e) logger.exception(e)
return return

View File

@@ -1,13 +1,19 @@
import logging import logging
from aiogram import Router from aiogram import Router, F
from aiogram.filters import CommandObject, CommandStart from aiogram.filters import CommandObject, CommandStart
from aiogram.fsm.context import FSMContext
from aiogram.types import ( from aiogram.types import (
CallbackQuery,
Message, Message,
) )
from sqlalchemy.ext.asyncio import AsyncSession 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 repositories.invoices import InvoiceRepository
from config import card_info
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -24,6 +30,86 @@ async def activate_invoice(
invoice_id = int(payload.split(":")[1]) invoice_id = int(payload.split(":")[1])
invoice = await invoice_repo.get_invoice_by_id(session, invoice_id) invoice = await invoice_repo.get_invoice_by_id(session, invoice_id)
if not invoice:
await msg.answer( await msg.answer(
f"<b>🟢 Получен счёт на {invoice.amount}₽</b>" "⏳ Счёт истёк. Повторите попытку позже.", reply_markup=return_menu
) # TODO: Process payment )
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"<b>💎 Счёт на {invoice.amount}₽</b>\n\n"
"<i>🔸 Проведите оплату по реквизитам ниже:</i>\n"
f"🪪 Номер карты: <code>{card_info.number}</code>\n"
f"👤 Держатель карты: <b>{card_info.holder}</b>",
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(
"<b>💸 Вы отметили платёж как оплаченый.</b> Если вы сделали это ошибочно, проведите платёж в ближайшее время.",
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="<b><i>🟡 Счёт помечен как оплаченый, жду подтверждения...</i></b>",
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"<b>✅ Платёж на {invoice.amount}₽ успешно оплачен.</b>",
)

View File

@@ -37,6 +37,11 @@ async def main_menu_cb(
): ):
await state.clear() await state.clear()
try:
await cb.message.reply_to_message.delete()
except Exception:
...
cart_items = await order_items_repo.get_items_count_by_customer( cart_items = await order_items_repo.get_items_count_by_customer(
session, cb.from_user.id session, cb.from_user.id
) )

View File

@@ -146,7 +146,7 @@ async def add_to_cart(
else: else:
show_cart = False show_cart = False
quantity = 0 quantity = 0
await order_service.clear_cart_item( await order_service.clear_from_cart(
session, customer=cb.from_user.id, product_id=product_id session, customer=cb.from_user.id, product_id=product_id
) )

View File

@@ -32,3 +32,15 @@ def payment_link(url: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder( return InlineKeyboardBuilder(
[[InlineKeyboardButton(text="💸 Оплатить", url=url)]] [[InlineKeyboardButton(text="💸 Оплатить", url=url)]]
).as_markup() ).as_markup()
def verify_payment(invoice_id: int):
return InlineKeyboardBuilder(
[
[
InlineKeyboardButton(
text="🟢 Подтвердить", callback_data=f"verify_payment:{invoice_id}"
)
]
]
).as_markup()

View File

@@ -221,3 +221,16 @@ def order_specs_confirmation(order_id: int) -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="", callback_data="menu:main")], [InlineKeyboardButton(text="", callback_data="menu:main")],
] ]
).as_markup() ).as_markup()
def confirm_payment(invoice_id: int):
return InlineKeyboardBuilder(
[
[
InlineKeyboardButton(
text="✅ Подтвердить оплату",
callback_data=f"refresh_payment:{invoice_id}",
)
]
]
).as_markup()

View File

@@ -43,3 +43,13 @@ class InvoiceRepository:
.values(inline_message_id=inline_message_id) .values(inline_message_id=inline_message_id)
) )
await session.execute(stmt) 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)

View File

@@ -1,5 +1,5 @@
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import delete, select
from typing import Optional from typing import Optional
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@@ -46,3 +46,15 @@ class OrderRepository:
await session.commit() await session.commit()
return order 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()

View File

@@ -126,29 +126,6 @@ class OrderService:
return order_item.quantity 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( async def build_pagination_cart_dto(
self, self,
session: AsyncSession, session: AsyncSession,