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

@@ -2,10 +2,6 @@
# Get it from @BotFather on Telegram # Get it from @BotFather on Telegram
BOT_TOKEN=your_bot_token_here BOT_TOKEN=your_bot_token_here
# Telegram Admin Group ID (Required)
# The numeric ID of the group where admin notifications will be sent
ADMIN_GROUP_ID=your_admin_group_id_here
# Remnawave Configuration (Required) # Remnawave Configuration (Required)
REMNAWAVE_URL=https://your-remnawave-instance.com REMNAWAVE_URL=https://your-remnawave-instance.com
REMNAWAVE_TOKEN=your_remnawave_api_token_here REMNAWAVE_TOKEN=your_remnawave_api_token_here

View File

@@ -0,0 +1,41 @@
"""billing
Revision ID: d21c5f9364ea
Revises: f40cab941564
Create Date: 2026-04-23 19:22:45.686671
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd21c5f9364ea'
down_revision: Union[str, Sequence[str], None] = 'f40cab941564'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# 1. Сначала создаем тип в базе данных
# Имя 'billstatus' должно совпадать с тем, что в ошибке
bind = op.get_bind()
if bind.dialect.name == 'postgresql':
op.execute("CREATE TYPE billstatus AS ENUM ('NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL')")
# 2. Теперь добавляем колонку, которая использует этот тип
op.add_column('bills', sa.Column('status', sa.Enum('NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL', name='billstatus'), nullable=False))
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('bills', 'status')
bind = op.get_bind()
if bind.dialect.name == 'postgresql':
op.execute("DROP TYPE billstatus")
# ### end Alembic commands ###

View File

@@ -7,12 +7,18 @@ load_dotenv(override=True)
class Settings(BaseSettings): class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN") bot_token: str = Field(alias="BOT_TOKEN")
postgres_url: str = Field(alias="POSTGRES_URL") postgres_url: str = Field(
"postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL"
)
proxy: str | None = Field(None, alias="PROXY") proxy: str | None = Field(None, alias="PROXY")
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
remnawave_url: str = Field(alias="REMNAWAVE_URL") remnawave_url: str = Field(alias="REMNAWAVE_URL")
subscription_url: str = Field(alias="SUBSCRIPTION_URL") subscription_url: str = Field(alias="SUBSCRIPTION_URL")
remnawave_token: str = Field(alias="REMNAWAVE_TOKEN") remnawave_token: str = Field(alias="REMNAWAVE_TOKEN")
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
pally_token: str = Field(alias="PALLY_TOKEN")
min_devices: int = Field(3, alias="MIN_DEVICES") min_devices: int = Field(3, alias="MIN_DEVICES")
max_devices: int = Field(12, alias="MAX_DEVICES") max_devices: int = Field(12, alias="MAX_DEVICES")
per_device_cost: int = Field(50, alias="PER_DEVICE_COST") per_device_cost: int = Field(50, alias="PER_DEVICE_COST")

View File

@@ -1,4 +1,4 @@
from .tickets import Ticket from .bills import Bill
from .users import User from .users import User
__all__ = ["Ticket", "User"] __all__ = ["Bill", "User"]

28
db/models/bills.py Normal file
View File

@@ -0,0 +1,28 @@
from typing import TYPE_CHECKING
from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
from misc.pally import BillStatus
if TYPE_CHECKING:
from db.models.users import User
class Bill(Base):
__tablename__ = "bills"
id: Mapped[int] = mapped_column(
BIGINT, autoincrement=True, unique=True, nullable=False, primary_key=True
)
creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False)
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
status: Mapped[BillStatus] = mapped_column(
Enum(BillStatus), nullable=False, default=BillStatus.NEW
)
user: Mapped["User"] = relationship(
"User",
back_populates="bills",
)

View File

@@ -1,25 +0,0 @@
from enum import Enum as VanillaEnum
from sqlalchemy import BIGINT, INTEGER, Enum
from sqlalchemy.orm import Mapped, mapped_column
from db.base import Base
class TicketStatus(VanillaEnum):
OPEN = "OPEN"
CLOSED = "CLOSED"
PENDING = "PENDING"
class Ticket(Base):
__tablename__ = "tickets"
id: Mapped[int] = mapped_column(
INTEGER, autoincrement=True, unique=True, nullable=False, primary_key=True
)
creator_id: Mapped[int] = mapped_column(BIGINT, nullable=False)
status: Mapped[TicketStatus] = mapped_column(
Enum(TicketStatus), nullable=False, default=TicketStatus.PENDING
)
thread_id: Mapped[int] = mapped_column(BIGINT, nullable=True, unique=True)

View File

@@ -1,8 +1,13 @@
from typing import TYPE_CHECKING
from sqlalchemy import BIGINT, INTEGER, TEXT from sqlalchemy import BIGINT, INTEGER, TEXT
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base from db.base import Base
if TYPE_CHECKING:
from db.models.bills import Bill
class User(Base): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -11,3 +16,9 @@ class User(Base):
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True) referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True) subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True)
balance: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0) balance: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0)
bills: Mapped[list["Bill"]] = relationship(
"Bill",
back_populates="user",
cascade="all, delete",
)

View File

@@ -31,7 +31,6 @@ services:
BOT_TOKEN: ${BOT_TOKEN} BOT_TOKEN: ${BOT_TOKEN}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db} POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-} PROXY: ${PROXY:-}
ADMIN_GROUP_ID: ${ADMIN_GROUP_ID}
REMNAWAVE_URL: ${REMNAWAVE_URL} REMNAWAVE_URL: ${REMNAWAVE_URL}
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL} SUBSCRIPTION_URL: ${SUBSCRIPTION_URL}
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN} REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN}
@@ -40,6 +39,8 @@ services:
PER_DEVICE_COST: ${PER_DEVICE_COST:-50} PER_DEVICE_COST: ${PER_DEVICE_COST:-50}
WHITELIST_COST: ${WHITELIST_COST:-100} WHITELIST_COST: ${WHITELIST_COST:-100}
WHITELIST_DEVICE_THRESHOLD: ${WHITELIST_DEVICE_THRESHOLD:-6} WHITELIST_DEVICE_THRESHOLD: ${WHITELIST_DEVICE_THRESHOLD:-6}
PALLY_TOKEN: ${PALLY_TOKEN}
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
restart: unless-stopped restart: unless-stopped
volumes: volumes:

View File

@@ -1,8 +1,19 @@
from .ads import router as ads_router from .ads import router as ads_router
from .billing import router as billing_router
from .buy import router as buy_router from .buy import router as buy_router
from .common import router as common_router from .common import router as common_router
from .menus import router as menus_router from .menus import router as menus_router
from .prehandling import router as prehandling_router
from .referals import router as referal_router from .referals import router as referal_router
from .support import router as support_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 import 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,
)
router = Router() router = Router()
# FIXME: Refine newsletter system
@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)))

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, convert_int_to_duration,
get_discount, get_discount,
) )
from schemas.billing import BillingContext
from schemas.subscriptions import SubscriptionPlan from schemas.subscriptions import SubscriptionPlan
from states.buy import SubscriptionStorage from states.buy import BillingStorage, SubscriptionStorage
from texts import ( from texts import (
CALLBACK_FALLBACK, CALLBACK_FALLBACK,
CHECKOUT_PAYMENT_CHOICE, CHECKOUT_PAYMENT_CHOICE,
@@ -102,9 +103,10 @@ async def select_duration_init(cb: CallbackQuery, state: FSMContext):
try: try:
discount = get_discount(convert_duration_to_int(ctx.duration)) discount = get_discount(convert_duration_to_int(ctx.duration))
ctx.discount = 1 - discount / 100
await cb.message.edit_text( await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format( SUBSCRIPTION_DURATION_SELECTOR.format(
price=math.ceil(calculate_price(ctx) * (1 - discount / 100)), price=calculate_price(ctx),
discount=math.floor(discount), discount=math.floor(discount),
), ),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"), 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) ctx.duration = convert_int_to_duration(duration)
try: try:
discount = get_discount(convert_duration_to_int(ctx.duration)) discount = get_discount(convert_duration_to_int(ctx.duration))
ctx.discount = 1 - discount / 100
await cb.message.edit_text( await cb.message.edit_text(
SUBSCRIPTION_DURATION_SELECTOR.format( SUBSCRIPTION_DURATION_SELECTOR.format(
price=math.ceil(calculate_price(ctx) * (1 - discount / 100)), price=calculate_price(ctx),
discount=math.floor(discount), discount=math.floor(discount),
), ),
reply_markup=duration_selector(current=ctx.duration, back_cb=f"devices:{ctx.devices}"), 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, reply_markup=payment_gateways,
) # FIXME: temp solution that's incredibly stupid ) # 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.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from config import settings
from texts import UNDEFINED_MESSAGE from texts import UNDEFINED_MESSAGE
router = Router() router = Router()
@router.message(F.chat.id != settings.admin_group_id) @router.message()
async def undefined_cmd(msg: Message): async def undefined_cmd(msg: Message):
await msg.delete() await msg.delete()
await msg.answer(UNDEFINED_MESSAGE) 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 import F, Router
from aiogram.filters import Command, CommandObject
from aiogram.fsm.context import FSMContext 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 aiogram.utils.deep_linking import create_start_link
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from config import settings from keyboards.client import referals_kb
from keyboards.client import close_kb, referals_kb
from misc.utils import format_share_deep_link from misc.utils import format_share_deep_link
from schemas.common import GeneralMessageContext
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from states.admins import AdminStorage
from texts import ( from texts import (
GENERAL_PROCESSING,
INVALID_ARGUMENT_REFPAY,
REFERALS_MENU, REFERALS_MENU,
SPECIFY_REFERAL_PAYMENT_AMOUNT,
SUCCESSFUL_REFPAY,
USER_NO_REFERAL,
) )
router = Router() router = Router()
@@ -37,85 +28,3 @@ async def referal_menu(
REFERALS_MENU.format(balance=user.balance, link=referal_link), REFERALS_MENU.format(balance=user.balance, link=referal_link),
reply_markup=referals_kb(share_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))

View File

@@ -29,6 +29,7 @@ return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_b
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder( payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
[ [
[InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")],
[InlineKeyboardButton(text="✍️ Перейти в поддержку", url="https://t.me/maleniasupportbot")], [InlineKeyboardButton(text="✍️ Перейти в поддержку", url="https://t.me/maleniasupportbot")],
[_return_to_menu_btn], [_return_to_menu_btn],
] ]
@@ -113,3 +114,12 @@ def referals_kb(share_link: str) -> InlineKeyboardMarkup:
[_return_to_menu_btn], [_return_to_menu_btn],
] ]
).as_markup() ).as_markup()
def pay_url(url: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="💸 Оплатить", url=url)],
[InlineKeyboardButton(text="", callback_data="menu:main")],
]
).as_markup()

18
main.py
View File

@@ -10,9 +10,11 @@ from config import settings
from db.session import async_session from db.session import async_session
from handlers import routers from handlers import routers
from middlewares import DIMiddleware from middlewares import DIMiddleware
from repositories.tickets import TicketRepository from misc.pally import PallyClient
from repositories.bills import BillsRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from services.billing_service import BillingService
from services.user_service import UserService from services.user_service import UserService
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
@@ -21,9 +23,15 @@ logging.basicConfig(level=logging.DEBUG)
async def main(): async def main():
dp = Dispatcher() dp = Dispatcher()
pally_client = PallyClient(settings.pally_token)
user_repository = UserRepository() user_repository = UserRepository()
ticket_repository = TicketRepository() user_service = UserService(user_repository)
user_service = UserService(user_repository, ticket_repository)
bills_repository = BillsRepository()
billing_service = BillingService(
user_repository=user_repository, bills_repository=bills_repository
)
rw_sdk = RemnawaveSDK( rw_sdk = RemnawaveSDK(
base_url=settings.remnawave_url, base_url=settings.remnawave_url,
@@ -31,10 +39,12 @@ async def main():
) )
deps = DependenciesDTO( deps = DependenciesDTO(
ticket_repository=ticket_repository,
user_repository=user_repository, user_repository=user_repository,
user_service=user_service, user_service=user_service,
rw_sdk=rw_sdk, rw_sdk=rw_sdk,
pally_client=pally_client,
bills_repository=bills_repository,
billing_service=billing_service,
) )
dp.update.middleware.register(DIMiddleware(deps, async_session)) dp.update.middleware.register(DIMiddleware(deps, async_session))

385
misc/pally.py Normal file
View File

@@ -0,0 +1,385 @@
"""
Pally API Asynchronous SDK
This module provides a strictly typed, asynchronous client for the Pally API.
It is built on top of aiohttp for high-performance async requests and pydantic
for robust data validation.
"""
import logging
from datetime import datetime
from enum import StrEnum
from typing import Any
import aiohttp
from pydantic import BaseModel, ConfigDict
logger = logging.getLogger(__name__)
# ==========================================
# 1. ENUMS & CONSTANTS
# ==========================================
class Currency(StrEnum):
RUB = "RUB"
USD = "USD"
EUR = "EUR"
USDT = "USDT"
class BillType(StrEnum):
NORMAL = "normal"
MULTI = "multi"
class BillStatus(StrEnum):
NEW = "NEW"
PROCESS = "PROCESS"
UNDERPAID = "UNDERPAID"
SUCCESS = "SUCCESS"
OVERPAID = "OVERPAID"
FAIL = "FAIL"
class PaymentStatus(StrEnum):
NEW = "NEW"
PROCESS = "PROCESS"
UNDERPAID = "UNDERPAID"
SUCCESS = "SUCCESS"
OVERPAID = "OVERPAID"
FAIL = "FAIL"
class Locale(StrEnum):
EN = "en"
RU = "ru"
# ==========================================
# 2. EXCEPTIONS
# ==========================================
class PallyError(Exception):
"""Base exception for all Pally API errors."""
pass
class PallyAPIError(PallyError):
"""Raised when the API responds with an error HTTP status code."""
def __init__(self, status_code: int, message: str):
self.status_code = status_code
self.message = message
super().__init__(f"Pally API Error {status_code}: {message}")
# ==========================================
# 3. PYDANTIC MODELS (DATA STRUCTURES)
# ==========================================
class BasePallyModel(BaseModel):
"""Base Pydantic model with default configurations."""
model_config = ConfigDict(populate_by_name=True, extra="ignore")
# --- General Responses ---
class PaginationLinks(BasePallyModel):
prev: str | None = None
next: str | None = None
class PaginationMeta(BasePallyModel):
path: str
per_page: int
next_cursor: str | None = None
prev_cursor: str | None = None
# --- Bill Models ---
class BillCreateResponse(BasePallyModel):
success: bool
link_url: str
link_page_url: str
bill_id: str
class Bill(BasePallyModel):
id: str
order_id: str | None = None
active: bool | None = None
status: BillStatus
amount: float
type: BillType
created_at: datetime
currency_in: Currency
ttl: int | None = None
class BillSearchResponse(BasePallyModel):
success: bool
data: list[Bill]
links: PaginationLinks
meta: PaginationMeta
# --- Payment Models ---
class Payment(BasePallyModel):
id: str
bill_id: str
status: PaymentStatus
amount: float
commission: float
account_amount: float
account_currency_code: str
refunded_amount: float
from_card: str | None = None
account_bank: str | None = None
currency_in: Currency
created_at: datetime
payer_phone: str | None = None
payer_email: str | None = None
payer_name: str | None = None
payer_comment: str | None = None
error_code: int | None = None
error_message: str | None = None
description: str | None = None
class PaymentSearchResponse(BasePallyModel):
success: bool
data: list[Payment]
links: PaginationLinks
meta: PaginationMeta
# --- Balance Models ---
class Balance(BasePallyModel):
currency: Currency
balance_available: float
balance_locked: float
balance_hold: float
class BalanceResponse(BasePallyModel):
success: bool
balances: list[Balance]
# ==========================================
# 4. CORE SERVICES
# ==========================================
class BaseService:
"""Base class for all API services handling aiohttp operations."""
def __init__(self, session: aiohttp.ClientSession):
self._session = session
async def _post(self, endpoint: str, data: dict[str, Any], response_model: type[Any]) -> Any:
"""Helper for making POST requests with application/x-www-form-urlencoded data."""
# Clean None values to avoid sending them in the payload
cleaned_data = {k: str(v) for k, v in data.items() if v is not None}
async with self._session.post(endpoint, data=cleaned_data) as response:
await self._handle_errors(response)
json_data = await response.json()
return response_model.model_validate(json_data)
async def _get(self, endpoint: str, params: dict[str, Any], response_model: type[Any]) -> Any:
"""Helper for making GET requests."""
cleaned_params = {k: str(v) for k, v in params.items() if v is not None}
async with self._session.get(endpoint, params=cleaned_params) as response:
await self._handle_errors(response)
json_data = await response.json()
return response_model.model_validate(json_data)
@staticmethod
async def _handle_errors(response: aiohttp.ClientResponse) -> None:
"""Raises exceptions based on HTTP error codes from aiohttp."""
if not response.ok:
try:
error_data = await response.json()
message = (
error_data.get("message")
or error_data.get("error_key")
or await response.text()
)
except Exception:
message = await response.text()
raise PallyAPIError(status_code=response.status, message=message)
class BillService(BaseService):
"""Service to handle all Bill-related operations."""
async def create(
self,
amount: float,
shop_id: str,
order_id: str | None = None,
description: str | None = None,
bill_type: BillType = BillType.NORMAL,
currency_in: Currency | None = None,
ttl: int | None = None,
**kwargs: Any,
) -> BillCreateResponse:
"""Creates a new bill for payment."""
payload = {
"amount": amount,
"shop_id": shop_id,
"order_id": order_id,
"description": description,
"type": bill_type.value,
"currency_in": currency_in.value if currency_in else None,
"ttl": ttl,
**kwargs,
}
return await self._post("bill/create", payload, BillCreateResponse)
async def toggle_activity(self, bill_id: str, active: bool) -> Bill:
"""Activates or deactivates a bill."""
payload = {"id": bill_id, "active": int(active)}
return await self._post("bill/toggle_activity", payload, Bill)
async def get_payments(
self, bill_id: str, per_page: int | None = None, cursor: str | None = None
) -> PaymentSearchResponse:
"""Gets all payments related to a specific bill."""
params = {"id": bill_id, "per_page": per_page, "cursor": cursor}
return await self._get("bill/payments", params, PaymentSearchResponse)
async def search(
self,
start_date: datetime | None = None,
finish_date: datetime | None = None,
shop_id: str | None = None,
per_page: int | None = None,
cursor: str | None = None,
) -> BillSearchResponse:
"""Searches for bills based on parameters."""
params = {
"start_date": start_date.strftime("%Y-%m-%d %H:%M:%S") if start_date else None,
"finish_date": finish_date.strftime("%Y-%m-%d %H:%M:%S") if finish_date else None,
"shop_id": shop_id,
"per_page": per_page,
"cursor": cursor,
}
return await self._get("bill/search", params, BillSearchResponse)
async def status(self, bill_id: str) -> Bill:
"""Gets the status of a specific bill."""
return await self._get("bill/status", {"id": bill_id}, Bill)
class PaymentService(BaseService):
"""Service to handle all Payment-related operations."""
async def search(
self,
start_date: datetime | None = None,
finish_date: datetime | None = None,
shop_id: str | None = None,
per_page: int | None = None,
cursor: str | None = None,
) -> PaymentSearchResponse:
"""Searches for payments."""
params = {
"start_date": start_date.strftime("%Y-%m-%d %H:%M:%S") if start_date else None,
"finish_date": finish_date.strftime("%Y-%m-%d %H:%M:%S") if finish_date else None,
"shop_id": shop_id,
"per_page": per_page,
"cursor": cursor,
}
return await self._get("payment/search", params, PaymentSearchResponse)
async def status(
self, payment_id: str, refunds: bool = False, chargeback: bool = False
) -> Payment:
"""Gets detailed status of a specific payment."""
params = {"id": payment_id, "refunds": int(refunds), "chargeback": int(chargeback)}
return await self._get("payment/status", params, Payment)
class BalanceService(BaseService):
"""Service to handle Balance-related operations."""
async def get(self) -> BalanceResponse:
"""Retrieves merchant balance."""
return await self._get("merchant/balance", {}, BalanceResponse)
# ==========================================
# 5. MAIN CLIENT
# ==========================================
class PallyClient:
"""
Main asynchronous client for the Pally API.
Usage:
async with PallyClient(api_token="your_token") as client:
balance = await client.balance.get()
print(balance)
"""
def __init__(self, api_token: str, base_url: str = "https://pal24.pro/api/v1/"):
"""
Initializes the Pally API client.
Args:
api_token (str): The bearer token provided by Pally.
base_url (str): The base URL for the API.
"""
self.api_token = api_token
self.base_url = base_url
self._session: aiohttp.ClientSession | None = None
@property
def session(self) -> aiohttp.ClientSession:
"""
Lazy-loads the aiohttp ClientSession.
This prevents creating a session outside of the asyncio event loop.
"""
if self._session is None or self._session.closed:
headers = {"Authorization": f"Bearer {self.api_token}", "Accept": "application/json"}
timeout = aiohttp.ClientTimeout(total=10.0)
self._session = aiohttp.ClientSession(
base_url=self.base_url, headers=headers, timeout=timeout
)
return self._session
# Using properties ensures that services always receive the active session context
@property
def bills(self) -> BillService:
return BillService(self.session)
@property
def payments(self) -> PaymentService:
return PaymentService(self.session)
@property
def balance(self) -> BalanceService:
return BalanceService(self.session)
async def close(self) -> None:
"""Closes the underlying aiohttp client session safely."""
if self._session and not self._session.closed:
await self._session.close()
async def __aenter__(self) -> "PallyClient":
# Accessing the property ensures the session is instantiated
# properly within the async context block.
_ = self.session
return self
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
await self.close()

View File

@@ -170,14 +170,18 @@ def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
def calculate_price(subscription: SubscriptionPlan): def calculate_price(subscription: SubscriptionPlan):
return ( return math.ceil(
(
(subscription.devices * settings.per_device_cost) (subscription.devices * settings.per_device_cost)
+ ( + (
int(subscription.whitelists) * settings.whitelist_cost int(subscription.whitelists) * settings.whitelist_cost
if subscription.devices < settings.whitelist_device_threshold if subscription.devices < settings.whitelist_device_threshold
else 0 else 0
) )
) * convert_duration_to_int(subscription.duration) )
* convert_duration_to_int(subscription.duration)
* subscription.discount
)
def get_discount(months) -> float: def get_discount(months) -> float:

25
repositories/bills.py Normal file
View File

@@ -0,0 +1,25 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.bills import Bill, BillStatus
class BillsRepository:
async def get_bill_by_id(self, session: AsyncSession, bill_id: int) -> Bill | None:
stmt = select(Bill).where(Bill.id == bill_id)
res = await session.execute(stmt)
return res.scalar_one_or_none()
async def create(
self,
session: AsyncSession,
*,
creator_id: int,
amount: int,
status: BillStatus | None = BillStatus.NEW,
) -> Bill:
bill = Bill(creator_id=creator_id, amount=amount, status=status)
session.add(bill)
await session.commit()
return bill

View File

@@ -1,55 +0,0 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.tickets import Ticket, TicketStatus
from schemas.tickets import NewTicketDTO
class TicketRepository:
async def get_ticket_by_user_id(
self, session: AsyncSession, user_id: int, status: TicketStatus | None = None
) -> list[Ticket]:
stmt = select(Ticket).where(Ticket.creator_id == user_id)
if status:
stmt = stmt.where(Ticket.status == status)
res = await session.execute(stmt)
return list(res.scalars().all())
async def get_available_ticket_by_user_id(
self, session: AsyncSession, user_id: int
) -> Ticket | None:
stmt = (
select(Ticket)
.where(Ticket.creator_id == user_id)
.where(Ticket.status != TicketStatus.CLOSED)
)
res = await session.execute(stmt)
tickets = list(res.scalars().all())
return tickets[0] if tickets else None
async def get_ticket_by_thread_id(self, session: AsyncSession, thread_id: int) -> Ticket | None:
stmt = select(Ticket).where(Ticket.thread_id == thread_id)
res = await session.execute(stmt)
return res.scalar_one_or_none()
async def create_ticket(self, session: AsyncSession, ticket_data: NewTicketDTO) -> Ticket:
ticket = Ticket(creator_id=ticket_data.creator_id)
session.add(ticket)
await session.commit()
return ticket
async def update_ticket_status(
self, session: AsyncSession, ticket: Ticket, status: TicketStatus
):
ticket.status = status
await session.commit()
return ticket
async def update_ticket_thread_id(self, session: AsyncSession, ticket: Ticket, thread_id: int):
ticket.thread_id = thread_id
await session.commit()
return ticket

13
schemas/billing.py Normal file
View File

@@ -0,0 +1,13 @@
from dataclasses import dataclass
from enum import Enum
class BillingProviders(Enum):
PALLY = "pally"
@dataclass
class BillingContext:
amount: int
bill_id: int | None = None
provider: BillingProviders | None = BillingProviders.PALLY

View File

@@ -2,14 +2,18 @@ from dataclasses import dataclass
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from repositories.tickets import TicketRepository from misc.pally import PallyClient
from repositories.bills import BillsRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from services.billing_service import BillingService
from services.user_service import UserService from services.user_service import UserService
@dataclass @dataclass
class DependenciesDTO: class DependenciesDTO:
ticket_repository: TicketRepository
user_repository: UserRepository user_repository: UserRepository
user_service: UserService user_service: UserService
rw_sdk: RemnawaveSDK rw_sdk: RemnawaveSDK
bills_repository: BillsRepository
billing_service: BillingService
pally_client: PallyClient

View File

@@ -16,3 +16,4 @@ class SubscriptionPlan(BaseModel):
devices: int = settings.min_devices devices: int = settings.min_devices
duration: SubscriptionDuration = SubscriptionDuration.MONTH duration: SubscriptionDuration = SubscriptionDuration.MONTH
whitelists: bool = False whitelists: bool = False
discount: float = 1

View File

@@ -1,6 +0,0 @@
from dataclasses import dataclass
@dataclass
class NewTicketDTO:
creator_id: int

View File

@@ -0,0 +1,41 @@
import logging
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from misc.pally import BillCreateResponse, Currency, PallyAPIError, PallyClient
from repositories import UserRepository
from repositories.bills import BillsRepository
logger = logging.getLogger(__name__)
class BillingService:
def __init__(self, user_repository: UserRepository, bills_repository: BillsRepository) -> None:
self.user_repository = user_repository
self.bills_repository = bills_repository
async def pally_initiate_payment(
self,
session: AsyncSession,
pally_client: PallyClient,
*,
user_id: int,
amount: int,
currency: Currency | None = Currency.RUB,
) -> BillCreateResponse | None:
db_bill = await self.bills_repository.create(session, creator_id=user_id, amount=amount)
try:
async with pally_client as pl:
bill = await pl.bills.create(
db_bill.amount,
shop_id=settings.pally_shop_id,
order_id=db_bill.id,
currency_in=currency,
)
except PallyAPIError:
logger.exception("caught an exception while trying to create a bill:")
return None
return bill

View File

@@ -4,18 +4,15 @@ from aiogram.types import Message
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models.tickets import TicketStatus
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
from repositories.tickets import TicketRepository from schemas.users import NewUserDTO
from schemas.users import NewUserDTO, TicketCreator
from services.rw import RWUserInfo, get_user_by_telegram_id from services.rw import RWUserInfo, get_user_by_telegram_id
from texts import ( from texts import (
NO_SUBSCRIPTION, NO_SUBSCRIPTION,
NOTHING_PLACEHOLDER, NOTHING_PLACEHOLDER,
SUBSCRIPTION_INFORMATION, SUBSCRIPTION_INFORMATION,
TICKET_STATUS_EMOJIS,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,24 +22,8 @@ class UserServiceError(Exception): ...
class UserService: class UserService:
def __init__( def __init__(self, user_repository: UserRepository) -> None:
self, user_repository: UserRepository, ticket_repository: TicketRepository
) -> None:
self.user_repository = user_repository self.user_repository = user_repository
self.ticket_repository = ticket_repository
@staticmethod
def format_ticket_name(
creator: TicketCreator,
status: TicketStatus | None = TicketStatus.OPEN,
ticket_id: int | None = None,
):
return (
TICKET_STATUS_EMOJIS[status.value]
+ (f" T-{ticket_id:0>3}" if ticket_id else "")
+ " | "
+ (f"@{creator.username}" if creator.username else str(creator.id))
)
@staticmethod @staticmethod
def format_subscription_message( def format_subscription_message(
@@ -90,7 +71,7 @@ class UserService:
async def update_user_link( async def update_user_link(
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
) -> User: ) -> User | None:
rw_user = await get_user_by_telegram_id(rw_sdk, user_id) rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
user = await self.user_repository.get_user_by_id(session, user_id) user = await self.user_repository.get_user_by_id(session, user_id)

View File

@@ -5,3 +5,8 @@ class SubscriptionStorage(StatesGroup):
devices = State() devices = State()
duration = State() duration = State()
checkout = State() checkout = State()
class BillingStorage(StatesGroup):
pending = State()
refresh = State()

View File

@@ -1,5 +1,3 @@
from db.models.tickets import TicketStatus
# ============================================================ # ============================================================
# PREMIUM EMOJI # PREMIUM EMOJI
# ============================================================ # ============================================================
@@ -42,13 +40,6 @@ _DIVIDER = "──────────────────────
# МАППИНГИ # МАППИНГИ
# ============================================================ # ============================================================
TICKET_STATUS_EMOJIS = {
TicketStatus.CLOSED.value: "🌑", # Более сдержанно
TicketStatus.OPEN.value: "📡", # В эфире
TicketStatus.PENDING.value: "",
}
# ============================================================ # ============================================================
# СТАТУСЫ ПОДПИСКИ # СТАТУСЫ ПОДПИСКИ
# ============================================================ # ============================================================
@@ -173,6 +164,22 @@ WHITELISTS_ALREADY_ON = "Белые списки уже активны."
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки." CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
# ============================================================
# BILLING
# ============================================================
_BILL_TEMPLATE = (
"<b>💸 Счёт на {amount}₽</b>\n\n"
"<i>‼️ Вам придёт уведомление в течение нескольких минут после завершения платежа.</i>\n"
"<b>🔒 Способ оплаты:</b> <i>{provider}</i>\n\n"
)
_BILL_LINK = f"<b>{MALENIA_LINK} Ссылка на оплату:</b>"
BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
# ============================================================ # ============================================================
# ПОДДЕРЖКА # ПОДДЕРЖКА