invoices, order handling, db updates

This commit is contained in:
2026-02-14 23:09:21 +07:00
parent a3aaafd7ac
commit 50b1a8f80c
28 changed files with 508 additions and 44 deletions

View File

@@ -0,0 +1,44 @@
"""invoice id -> text fs
Revision ID: 536f700c6d2b
Revises: f7372a4db67d
Create Date: 2026-02-14 22:54:39.201321
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "536f700c6d2b"
down_revision: Union[str, Sequence[str], None] = "f7372a4db67d"
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.alter_column(
"invoices",
"inline_message_id",
existing_type=sa.INTEGER(),
type_=sa.String(),
existing_nullable=True,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"invoices",
"inline_message_id",
existing_type=sa.String(),
type_=sa.INTEGER(),
existing_nullable=True,
)
# ### end Alembic commands ###

View File

@@ -0,0 +1,47 @@
"""invoices creation
Revision ID: ac19c319304a
Revises: 2c9553f99e03
Create Date: 2026-02-14 22:20:47.308942
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "ac19c319304a"
down_revision: Union[str, Sequence[str], None] = "2c9553f99e03"
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(
"invoices",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("creator_id", sa.BIGINT(), nullable=False),
sa.Column("amount", sa.BIGINT(), nullable=False),
sa.Column(
"status",
sa.Enum("PENDING", "PAID", "EXPIRED", name="invoicestatus"),
nullable=False,
),
sa.Column("inline_message_id", sa.BIGINT(), nullable=True),
sa.Column(
"created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=False
),
sa.PrimaryKeyConstraint("id"),
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("invoices")
# ### end Alembic commands ###

View File

@@ -0,0 +1,44 @@
"""invoice id -> text
Revision ID: f7372a4db67d
Revises: ac19c319304a
Create Date: 2026-02-14 22:54:03.047715
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "f7372a4db67d"
down_revision: Union[str, Sequence[str], None] = "ac19c319304a"
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.alter_column(
"invoices",
"inline_message_id",
existing_type=sa.BIGINT(),
type_=sa.Integer(),
existing_nullable=True,
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column(
"invoices",
"inline_message_id",
existing_type=sa.Integer(),
type_=sa.BIGINT(),
existing_nullable=True,
)
# ### end Alembic commands ###

View File

@@ -14,7 +14,11 @@ 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", "")
### Constants ###
PAGE_SIZE = 4
VERIFIED_ACCOUNTS = [1026030711]
VERIFIED_ACCOUNTS = [1026030711, 8480400744]
NOTIFICATION_CHANNEL = -1003849564110

View File

@@ -1,4 +1,13 @@
from .orders import Order, OrderItem, OrderStatus
from .products import Product, Category
from .invoices import Invoice, InvoiceStatus
__all__ = ["Order", "OrderItem", "OrderStatus", "Product", "Category"]
__all__ = [
"Order",
"OrderItem",
"OrderStatus",
"Product",
"Category",
"Invoice",
"InvoiceStatus",
]

24
db/models/invoices.py Normal file
View File

@@ -0,0 +1,24 @@
from datetime import datetime
from enum import Enum as E_cls
from sqlalchemy import DateTime, func, Enum
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import BIGINT
from db.base import Base
class InvoiceStatus(E_cls):
PENDING = "PENDING"
PAID = "PAID"
EXPIRED = "EXPIRED"
class Invoice(Base):
__tablename__ = "invoices"
id: Mapped[int] = mapped_column(autoincrement=True, primary_key=True)
creator_id: Mapped[int] = mapped_column(BIGINT)
amount: Mapped[int] = mapped_column(BIGINT)
status: Mapped[InvoiceStatus] = mapped_column(Enum(InvoiceStatus))
inline_message_id: Mapped[str] = mapped_column(nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

View File

@@ -11,8 +11,3 @@ class CheckoutContext:
name: Optional[str] = None
phone: Optional[str] = None
address: Optional[str] = None
@dataclass
class PaymentContext:
notification_msg: Message

View File

@@ -2,10 +2,21 @@
set -e
echo "checking venv..."
if [[ "$VIRTUAL_ENV" != "" ]]
then
echo "process is in venv, skipping."
else
echo "entering venv."
source $(pwd)/venv/bin/activate
fi
echo
echo "formatting..."
ruff check . --fix
black .
echo
echo "checking psql..."
if ! pg_isready -h localhost -p 5432; then
echo "psql is down, try again" >&2
@@ -13,8 +24,10 @@ if ! pg_isready -h localhost -p 5432; then
fi
echo "psql is up and is recieving connections."
echo
echo "running migrations..."
alembic upgrade head
echo
echo "starting the bot..."
exec python main.py

View File

@@ -1 +1,3 @@
routers = []
from .inline_mode import router as inline_router
routers = [inline_router]

View File

@@ -0,0 +1,71 @@
import logging
from aiogram import Router, F
from aiogram.types import (
ChosenInlineResult,
InlineQuery,
InlineQueryResultArticle,
InputTextMessageContent,
)
from aiogram.utils.deep_linking import create_start_link
from sqlalchemy.ext.asyncio import AsyncSession
from misc.filters import IsVerified
from misc.kb.admins import payment_link, placeholder_kb
from misc.utils import b64_to_dict, dict_to_b64
from repositories.invoices import InvoiceRepository
router = Router()
logger = logging.getLogger(__name__)
@router.inline_query(IsVerified(), F.query.cast(int).as_("amount"))
async def send_invoice(iq: InlineQuery, amount: int):
results = [
InlineQueryResultArticle(
id=dict_to_b64({"a": amount}),
title=f"💸 Счёт на {amount}",
input_message_content=InputTextMessageContent(
message_text="⏳ Создаю счёт..."
),
reply_markup=placeholder_kb,
description="Нажмите, чтобы создать счёт.",
),
]
await iq.answer(results, is_personal=True)
@router.chosen_inline_result()
async def chosen_inline(
ev: ChosenInlineResult, session: AsyncSession, invoice_repo: InvoiceRepository
):
data = b64_to_dict(ev.result_id)
amount = data.get("a")
if not amount:
await ev.bot.edit_message_text(
inline_message_id=ev.inline_message_id, text="🍃 Что-то пошло не так..."
)
logger.critical("amount is null during the invoice creation.")
return
try:
amount = int(amount)
except Exception as e:
await ev.bot.edit_message_text(
inline_message_id=ev.inline_message_id, text="🍃 Что-то пошло не так..."
)
logger.exception(e)
return
invoice = await invoice_repo.create_invoice(
session,
amount=amount,
creator_id=ev.from_user.id,
inline_message_id=ev.inline_message_id,
)
invoice_link = await create_start_link(ev.bot, f"invoice:{invoice.id}", encode=True)
await ev.bot.edit_message_text(
inline_message_id=ev.inline_message_id,
text=f"<b>💸 Счёт на {amount}₽.</b>",
reply_markup=payment_link(invoice_link),
)

View File

@@ -1,3 +1,4 @@
from .invoices import router as invoice_router
from .menu import router as menu_router
from .catalogue import router as catalogue_router
from .products import router as products_router
@@ -6,6 +7,7 @@ from .checkout import router as checkout_router
from .security import router as security_router
routers = [
invoice_router,
menu_router,
catalogue_router,
products_router,

View File

@@ -1,9 +1,13 @@
import logging
from typing import Optional
from aiogram import Router, F
from aiogram.types import CallbackQuery, Message
from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from config import NOTIFICATION_CHANNEL
from dto.checkout import CheckoutContext
from misc.kb.admins import customer_contacts
from misc.kb.client import (
order_confirmation,
order_specs_confirmation,
@@ -16,6 +20,7 @@ from repositories.orders import OrderRepository
from services.orders import OrderService
router = Router()
logger = logging.getLogger(__name__)
@router.callback_query(F.data == "checkout")
@@ -119,4 +124,55 @@ async def checkout_address(msg: Message, state: FSMContext):
f"<i>📍 Ваш адрес: {ctx.address}</i>\n\n"
"━━━━━━━━━━━━━━",
reply_markup=order_specs_confirmation(ctx.order_id),
) # TODO: State-driven Payment System.
)
await state.set_state(CheckoutStorage.confirmation)
await state.set_data({"ctx": ctx})
@router.callback_query(CheckoutStorage.confirmation)
@router.callback_query(F.data.startswith("payment:"))
async def payment_create(
cb: CallbackQuery,
state: FSMContext,
session: AsyncSession,
order_service: OrderService,
):
data = await state.get_data()
ctx: Optional[CheckoutContext] = data.get("ctx")
if not ctx:
await cb.message.edit_text(
"🍃 Что-то пошло не так, повторите попытку позже...",
reply_markup=return_menu,
)
logger.warning("ctx not found on confirmation screen")
return
await state.clear()
await cb.message.edit_text(
"<b>🟢 Ваш заказ успешно отправлен!</b>\n"
"<i>📦 Мы свяжемся с вами для уточнения деталей в ближайшее время.</i>\n\n"
"<b>‼️ Чтобы убедиться, что вы общаетесь с доверенным контактом ШВЕЙТЕХЦЕНТР, перешлите любое сообщение человека в этот чат.</b>",
reply_markup=return_menu,
)
cart = await order_service.build_full_cart_dto(session, cb.from_user.id)
try:
await cb.bot.send_message(
NOTIFICATION_CHANNEL,
"🟢 Новый заказ.\n\n"
"━━━━━━━━━━━━━━\n\n"
f"<i>👤 Имя: {ctx.name}</i>\n"
f"<i>📱 Номер телефона: {ctx.phone}</i>\n"
f"<i>📍 Адрес: {ctx.address}</i>\n\n"
"━━━━━━━━━━━━━━\n"
f"{get_order_item_list(cart)}",
reply_markup=customer_contacts(
cb.from_user.id, bool(cb.from_user.username), cb.from_user.username
),
)
except Exception as e:
logger.exception(e)
return

View File

@@ -0,0 +1,29 @@
import logging
from aiogram import Router
from aiogram.filters import CommandObject, CommandStart
from aiogram.types import (
Message,
)
from sqlalchemy.ext.asyncio import AsyncSession
from repositories.invoices import InvoiceRepository
router = Router()
logger = logging.getLogger(__name__)
@router.message(CommandStart(deep_link=True, deep_link_encoded=True))
async def activate_invoice(
msg: Message,
command: CommandObject,
session: AsyncSession,
invoice_repo: InvoiceRepository,
):
payload = command.args
invoice_id = int(payload.split(":")[1])
invoice = await invoice_repo.get_invoice_by_id(session, invoice_id)
await msg.answer(
f"<b>🟢 Получен счёт на {invoice.amount}₽</b>"
) # TODO: Process payment

View File

@@ -5,6 +5,7 @@ from aiogram.fsm.context import FSMContext
from sqlalchemy.ext.asyncio import AsyncSession
from misc.kb import main_menu_kb
from repositories.order_items import OrderItemRepository
from services.orders import OrderService
router = Router()
@@ -12,11 +13,16 @@ router = Router()
@router.message(Command("start"))
async def main_menu(
msg: Message, state: FSMContext, session: AsyncSession, order_service: OrderService
msg: Message,
state: FSMContext,
session: AsyncSession,
order_items_repo: OrderItemRepository,
):
await state.clear()
cart_items = await order_service.get_cart_items_count(session, msg.from_user.id)
cart_items = await order_items_repo.get_items_count_by_customer(
session, msg.from_user.id
)
await msg.answer(
"hii!", reply_markup=main_menu_kb(cart_items)
) # TODO: Write a welcome message
@@ -27,11 +33,13 @@ async def main_menu_cb(
cb: CallbackQuery,
state: FSMContext,
session: AsyncSession,
order_service: OrderService,
order_items_repo: OrderService,
):
await state.clear()
cart_items = await order_service.get_cart_items_count(session, cb.from_user.id)
cart_items = await order_items_repo.get_items_count_by_customer(
session, cb.from_user.id
)
await cb.message.edit_text(
"hii!", reply_markup=main_menu_kb(cart_items)
) # TODO: Write a welcome message

21
main.py
View File

@@ -11,6 +11,7 @@ from middlewares.di import DIMiddleware
from middlewares.repository import RepositoryMiddleware
from middlewares.session import DBSessionMiddleware
from repositories.categories import CategoriesRepository
from repositories.invoices import InvoiceRepository
from repositories.order_items import OrderItemRepository
from repositories.orders import OrderRepository
from repositories.products import ProductRepository
@@ -38,26 +39,18 @@ async def main():
product_repository = ProductRepository()
# DI Middleware Init
dp.message.middleware(DIMiddleware(order_service))
dp.message.middleware(DBSessionMiddleware(async_session))
dp.message.middleware(
RepositoryMiddleware(
order_repository,
category_repository,
product_repository,
order_items_repository,
)
)
invoice_repository = InvoiceRepository()
dp.callback_query.middleware(DIMiddleware(order_service))
dp.callback_query.middleware(DBSessionMiddleware(async_session))
dp.callback_query.middleware(
# DI Middleware Init
dp.update.middleware(DIMiddleware(order_service))
dp.update.middleware(DBSessionMiddleware(async_session))
dp.update.middleware(
RepositoryMiddleware(
order_repository,
category_repository,
product_repository,
order_items_repository,
invoice_repository,
)
)

View File

@@ -1,6 +1,7 @@
from aiogram import BaseMiddleware
from repositories import OrderRepository, CategoriesRepository
from repositories.invoices import InvoiceRepository
from repositories.order_items import OrderItemRepository
from repositories.products import ProductRepository
@@ -12,15 +13,19 @@ class RepositoryMiddleware(BaseMiddleware):
categories_repo: CategoriesRepository,
products_repo: ProductRepository,
order_items_repo: OrderItemRepository,
invoice_repo: InvoiceRepository,
) -> None:
self._order_repo = order_repo
self._categories_repo = categories_repo
self._products_repo = products_repo
self._order_items_repo = order_items_repo
self._invoice_repo = invoice_repo
async def __call__(self, handler, event, data):
data["order_repo"] = self._order_repo
data["categories_repo"] = self._categories_repo
data["products_repo"] = self._products_repo
data["order_items_repo"] = self._order_items_repo
data["invoice_repo"] = self._invoice_repo
return await handler(event, data)

9
misc/filters.py Normal file
View File

@@ -0,0 +1,9 @@
from aiogram.filters import BaseFilter
from aiogram.types import InlineQuery
from config import VERIFIED_ACCOUNTS
class IsVerified(BaseFilter):
async def __call__(self, inline_query: InlineQuery) -> bool:
return inline_query.from_user.id in VERIFIED_ACCOUNTS

View File

@@ -0,0 +1,34 @@
from typing import Optional
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
placeholder_kb = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="🍃", callback_data="...")]]
).as_markup()
def customer_contacts(
user_id: int, has_mention: bool, mention: Optional[str] = None
) -> InlineKeyboardMarkup:
btns: list[list[InlineKeyboardButton]] = []
(
btns.append(
[InlineKeyboardButton(text="👤 Перейти", url=f"https://t.me/{mention}")]
)
if has_mention
else None
)
btns.append(
[
InlineKeyboardButton(text="✍️", url=f"tg://openmessage?user_id={user_id}"),
InlineKeyboardButton(text="✍️", url=f"tg://user?id={user_id}"),
]
)
return InlineKeyboardBuilder(btns).as_markup()
def payment_link(url: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder(
[[InlineKeyboardButton(text="💸 Оплатить", url=url)]]
).as_markup()

View File

@@ -214,7 +214,7 @@ def order_specs_confirmation(order_id: int) -> InlineKeyboardMarkup:
[
[
InlineKeyboardButton(
text="🟢 Перейти к оплате",
text="🟢 Подтвердить",
callback_data=f"payment:{order_id}",
)
],

View File

@@ -5,3 +5,8 @@ class CheckoutStorage(StatesGroup):
name = State()
phone = State()
address = State()
confirmation = State()
class PaymentStorage(StatesGroup):
refresh = State()

View File

@@ -18,8 +18,3 @@ def get_order_item_list(cart: CartDTO):
text += f"\n━━━━━━━━━━━━━━\n💰 Итого: {cart.total}"
return text
# def get_order_notification(cart: CartDTO):
# text
# FIXME

View File

@@ -1,3 +1,6 @@
import base64
import json
from typing import Any
import phonenumbers
from phonenumbers import NumberParseException
@@ -8,3 +11,17 @@ def is_valid_phone(phone: str, region="RU") -> bool:
return phonenumbers.is_valid_number(parsed)
except NumberParseException:
return False
def dict_to_b64(payload: dict) -> str:
json_str = json.dumps(payload, separators=(",", ":"))
json_bytes = json_str.encode("utf-8")
b64 = base64.urlsafe_b64encode(json_bytes).decode("ascii")
return b64.rstrip("=")
def b64_to_dict(payload: str) -> dict["str", Any]:
padding = "=" * (-len(payload) % 4)
payload += padding
json_bytes = base64.urlsafe_b64decode(payload)
return json.loads(json_bytes.decode("utf-8"))

View File

@@ -1 +0,0 @@
docker run --name test-postgres -e POSTGRES_PASSWORD=HEXDEVFUCKINGSUCKS! -p 5432:5432 -d postgres

View File

@@ -1,5 +1,11 @@
from .orders import OrderRepository
from .categories import CategoriesRepository
from .products import ProductRepository
from .invoices import InvoiceRepository
__all__ = ["OrderRepository", "CategoriesRepository", "ProductRepository"]
__all__ = [
"OrderRepository",
"CategoriesRepository",
"ProductRepository",
"InvoiceRepository",
]

45
repositories/invoices.py Normal file
View File

@@ -0,0 +1,45 @@
from typing import Optional
from sqlalchemy import select, update
from db.models import Invoice
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.invoices import InvoiceStatus
class InvoiceRepository:
async def create_invoice(
self,
session: AsyncSession,
*,
amount: int,
creator_id: int,
inline_message_id: Optional[int] = None,
status: InvoiceStatus = InvoiceStatus.PENDING,
) -> Invoice:
invoice = Invoice(
creator_id=creator_id,
amount=amount,
status=status,
inline_message_id=inline_message_id,
)
session.add(invoice)
await session.commit()
return invoice
async def get_invoice_by_id(
self, session: AsyncSession, invoice_id: int
) -> Optional[Invoice]:
stmt = select(Invoice).where(Invoice.id == invoice_id)
return await session.scalar(stmt)
async def update_invoice_msg(
self, session: AsyncSession, *, invoice_id: int, inline_message_id: int
):
stmt = (
update(Invoice.__table__)
.where(Invoice.id == invoice_id)
.values(inline_message_id=inline_message_id)
)
await session.execute(stmt)

View File

@@ -2,7 +2,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import delete, func, select, update
from typing import Optional
from db.models.orders import OrderItem
from db.models.orders import Order, OrderItem, OrderStatus
class OrderItemRepository:
@@ -29,6 +29,19 @@ class OrderItemRepository:
stmt = select(func.count(OrderItem.id)).where(OrderItem.order_id == order_id)
return await session.scalar(stmt) or 0
async def get_items_count_by_customer(
self, session: AsyncSession, customer: int
) -> int:
count = await session.scalar(
select(func.count(OrderItem.id))
.join(Order)
.where(
Order.customer == customer,
Order.status == OrderStatus.DRAFT,
)
)
return int(count)
async def get_item_by_order_and_product(
self, session: AsyncSession, *, order_id: int, product_id: int
) -> Optional[OrderItem]:

View File

@@ -4,4 +4,6 @@ python-dotenv
black
asyncpg
sqlalchemy[asyncio]
psycopg2
psycopg2
phonenumbers
ruff

View File

@@ -149,13 +149,6 @@ class OrderService:
session, order_item_id=order_item.id, quantity=0
)
async def get_cart_items_count(self, session: AsyncSession, customer: int) -> int:
order = await self.order_repo.get_draft_order_by_user(session, customer)
if not order:
return 0
return await self.order_items_repo.get_items_count(session, order_id=order.id)
async def build_pagination_cart_dto(
self,
session: AsyncSession,