feat: implement billing system with Pally integration

- Added billing models and repository for handling bills.
- Introduced BillingService to manage payment initiation and bill creation.
- Updated user service to remove ticket-related functionality.
- Refactored settings to include new billing-related fields.
- Removed ticket-related handlers and schemas.
- Added new billing handlers for processing payments.
- Updated database schema with new bills table and status enum.
- Enhanced utility functions to calculate prices considering discounts.
- Introduced prehandling for non-private messages.
- Updated main application to initialize new billing services and repositories.
This commit is contained in:
2026-04-23 19:55:51 +07:00
parent 8290d18235
commit d98383285e
29 changed files with 705 additions and 305 deletions

View File

@@ -1,8 +1,19 @@
from .ads import router as ads_router
from .billing import router as billing_router
from .buy import router as buy_router
from .common import router as common_router
from .menus import router as menus_router
from .prehandling import router as prehandling_router
from .referals import router as referal_router
from .support import router as support_router
routers = [referal_router, menus_router, ads_router, support_router, buy_router, common_router]
routers = [
prehandling_router,
referal_router,
billing_router,
menus_router,
ads_router,
support_router,
buy_router,
common_router,
]

View File

@@ -1,69 +1,5 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.admins import promotion_confirmation
from keyboards.client import close_kb
from schemas.di import DependenciesDTO
from schemas.promotions import NewPromotionContext
from states.admins import AdminStorage
from texts import (
ADMIN_PROMOTION_CONFIRMATION,
ADMIN_PROMOTION_INIT,
ADMIN_STANDARD_FALLBACK,
SUCCESSFULLY_SENT_PROMOTION,
)
from aiogram import Router
router = Router()
@router.message(
F.chat.id == settings.admin_group_id, F.message_thread_id.is_(None), Command("push")
)
async def push_cmd(msg: Message, state: FSMContext):
await state.clear()
ctx = NewPromotionContext(creator_id=msg.from_user.id)
await msg.delete()
await msg.answer(ADMIN_PROMOTION_INIT, reply_markup=close_kb)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.message(F.chat.id == settings.admin_group_id, AdminStorage.promotion_msg)
async def send_promotion(msg: Message, state: FSMContext):
data = await state.get_data()
ctx: NewPromotionContext | None = data.get("ctx")
if not ctx:
await msg.answer(ADMIN_STANDARD_FALLBACK)
return
ctx.message = msg
await msg.reply(ADMIN_PROMOTION_CONFIRMATION, reply_markup=promotion_confirmation)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.callback_query(AdminStorage.promotion_msg, F.data.in_(["approve", "decline"]))
async def promotion_action(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
await cb.message.delete()
if cb.data == "decline":
return
ctx: NewPromotionContext | None = data.get("ctx")
if not (ctx and ctx.creator_id and ctx.message):
await cb.message.answer(ADMIN_STANDARD_FALLBACK)
return
sent = await deps.user_service.send_promotion_to_users(session, ctx.message)
await cb.message.answer(SUCCESSFULLY_SENT_PROMOTION.format(user_count=len(sent)))
# FIXME: Refine newsletter system

47
handlers/billing.py Normal file
View File

@@ -0,0 +1,47 @@
import logging
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import pay_url, return_to_menu
from schemas.billing import BillingContext, BillingProviders
from schemas.di import DependenciesDTO
from states.buy import BillingStorage
from texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
router = Router()
logger = logging.getLogger(__name__)
@router.callback_query(BillingStorage.pending, F.data == "billing:pally")
async def pally_init(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
ctx: BillingContext | None = data.get("ctx")
if not ctx:
logger.warning("ctx not found on billing choice")
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
return
await state.clear()
ctx.provider = BillingProviders.PALLY
await cb.message.edit_text(BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title()))
bill = await deps.billing_service.pally_initiate_payment(
session, deps.pally_client, user_id=cb.from_user.id, amount=ctx.amount
)
if not bill:
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
logger.critical("bill creation prompted, but bill is None")
return
await cb.message.edit_text(
BILL_CREATED.format(amount=ctx.amount, provider=ctx.provider.value.title()),
reply_markup=pay_url(bill.link_page_url),
)

View File

@@ -18,8 +18,9 @@ from misc.utils import (
convert_int_to_duration,
get_discount,
)
from schemas.billing import BillingContext
from schemas.subscriptions import SubscriptionPlan
from states.buy import SubscriptionStorage
from states.buy import BillingStorage, SubscriptionStorage
from texts import (
CALLBACK_FALLBACK,
CHECKOUT_PAYMENT_CHOICE,
@@ -102,9 +103,10 @@ async def select_duration_init(cb: CallbackQuery, state: FSMContext):
try:
discount = get_discount(convert_duration_to_int(ctx.duration))
ctx.discount = 1 - discount / 100
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(
price=math.ceil(calculate_price(ctx) * (1 - discount / 100)),
price=calculate_price(ctx),
discount=math.floor(discount),
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
@@ -135,9 +137,10 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
ctx.duration = convert_int_to_duration(duration)
try:
discount = get_discount(convert_duration_to_int(ctx.duration))
ctx.discount = 1 - discount / 100
await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format(
price=math.ceil(calculate_price(ctx) * (1 - discount / 100)),
price=calculate_price(ctx),
discount=math.floor(discount),
),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"),
@@ -168,3 +171,6 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
reply_markup=payment_gateways,
) # FIXME: temp solution that's incredibly stupid
await state.set_state(BillingStorage.pending)
billing_context = BillingContext(amount=calculate_price(ctx))
await state.set_data({"ctx": billing_context})

View File

@@ -2,13 +2,12 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from config import settings
from texts import UNDEFINED_MESSAGE
router = Router()
@router.message(F.chat.id != settings.admin_group_id)
@router.message()
async def undefined_cmd(msg: Message):
await msg.delete()
await msg.answer(UNDEFINED_MESSAGE)

9
handlers/prehandling.py Normal file
View File

@@ -0,0 +1,9 @@
from aiogram import F, Router
from aiogram.types import Message
router = Router()
@router.message(F.chat.type != "private")
async def prehandle_non_private(msg: Message):
return

View File

@@ -1,23 +1,14 @@
from aiogram import F, Router
from aiogram.filters import Command, CommandObject
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from aiogram.types import CallbackQuery
from aiogram.utils.deep_linking import create_start_link
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.client import close_kb, referals_kb
from keyboards.client import referals_kb
from misc.utils import format_share_deep_link
from schemas.common import GeneralMessageContext
from schemas.di import DependenciesDTO
from states.admins import AdminStorage
from texts import (
GENERAL_PROCESSING,
INVALID_ARGUMENT_REFPAY,
REFERALS_MENU,
SPECIFY_REFERAL_PAYMENT_AMOUNT,
SUCCESSFUL_REFPAY,
USER_NO_REFERAL,
)
router = Router()
@@ -37,85 +28,3 @@ async def referal_menu(
REFERALS_MENU.format(balance=user.balance, link=referal_link),
reply_markup=referals_kb(share_link),
)
@router.message(F.chat.id == settings.admin_group_id, Command("refpay"))
async def refpay(
msg: Message,
command: CommandObject,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
await msg.delete()
status_msg = await msg.answer(GENERAL_PROCESSING)
user = await deps.user_service.fetch_user_by_thread(session, msg.message_thread_id)
referal = await deps.user_repository.get_user_by_id(session, user.referal_id)
if not referal:
await status_msg.edit_text(USER_NO_REFERAL)
return
amount = command.args
if not amount.isdigit():
await status_msg.edit_text(INVALID_ARGUMENT_REFPAY)
return
amount = int(amount)
await deps.user_repository.increase_user_balance(session, referal.id, amount)
await status_msg.edit_text(SUCCESSFUL_REFPAY.format(referal_id=referal.id, amount=amount))
@router.callback_query(F.message.chat.id == settings.admin_group_id, F.data == "refpay")
async def refpay_cb(
cb: CallbackQuery,
state: FSMContext,
session: AsyncSession,
deps: DependenciesDTO,
):
await state.clear()
user = await deps.user_service.fetch_user_by_thread(session, cb.message.message_thread_id)
referal = await deps.user_repository.get_user_by_id(session, user.referal_id)
if not referal:
await cb.message.answer(USER_NO_REFERAL, reply_markup=close_kb)
return
msg = await cb.message.answer(SPECIFY_REFERAL_PAYMENT_AMOUNT, reply_markup=close_kb)
ctx = GeneralMessageContext(msg=msg)
await state.set_state(AdminStorage.refpay_amount)
await state.set_data({"ctx": ctx})
@router.message(
AdminStorage.refpay_amount,
F.chat.id == settings.admin_group_id,
F.from_user.is_bot == False, # noqa: E712
)
async def refpay_execute(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
await msg.delete()
ctx: GeneralMessageContext | None = data.get("ctx")
if ctx:
status_msg = ctx.msg
else:
status_msg = await msg.answer(GENERAL_PROCESSING)
amount = msg.text
if not amount.isdigit():
await status_msg.edit_text(INVALID_ARGUMENT_REFPAY, reply_markup=close_kb)
return
user = await deps.user_service.fetch_user_by_thread(session, msg.message_thread_id)
referal = await deps.user_repository.get_user_by_id(session, user.referal_id)
amount = int(amount)
await deps.user_repository.increase_user_balance(session, referal.id, amount)
await status_msg.edit_text(SUCCESSFUL_REFPAY.format(referal_id=referal.id, amount=amount))