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:
@@ -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,
|
||||
]
|
||||
|
||||
@@ -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
47
handlers/billing.py
Normal 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),
|
||||
)
|
||||
@@ -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})
|
||||
|
||||
@@ -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
9
handlers/prehandling.py
Normal 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
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user