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:
@@ -2,10 +2,6 @@
|
||||
# Get it from @BotFather on Telegram
|
||||
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_URL=https://your-remnawave-instance.com
|
||||
REMNAWAVE_TOKEN=your_remnawave_api_token_here
|
||||
|
||||
41
alembic/versions/d21c5f9364ea_billing.py
Normal file
41
alembic/versions/d21c5f9364ea_billing.py
Normal 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 ###
|
||||
10
config.py
10
config.py
@@ -7,12 +7,18 @@ load_dotenv(override=True)
|
||||
|
||||
class Settings(BaseSettings):
|
||||
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")
|
||||
admin_group_id: int = Field(alias="ADMIN_GROUP_ID")
|
||||
|
||||
remnawave_url: str = Field(alias="REMNAWAVE_URL")
|
||||
subscription_url: str = Field(alias="SUBSCRIPTION_URL")
|
||||
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")
|
||||
max_devices: int = Field(12, alias="MAX_DEVICES")
|
||||
per_device_cost: int = Field(50, alias="PER_DEVICE_COST")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from .tickets import Ticket
|
||||
from .bills import Bill
|
||||
from .users import User
|
||||
|
||||
__all__ = ["Ticket", "User"]
|
||||
__all__ = ["Bill", "User"]
|
||||
|
||||
28
db/models/bills.py
Normal file
28
db/models/bills.py
Normal 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",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -1,8 +1,13 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
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
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from db.models.bills import Bill
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
@@ -11,3 +16,9 @@ class User(Base):
|
||||
referal_id: Mapped[int] = mapped_column(BIGINT, nullable=True)
|
||||
subscription_link: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True)
|
||||
balance: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0)
|
||||
|
||||
bills: Mapped[list["Bill"]] = relationship(
|
||||
"Bill",
|
||||
back_populates="user",
|
||||
cascade="all, delete",
|
||||
)
|
||||
|
||||
@@ -31,7 +31,6 @@ services:
|
||||
BOT_TOKEN: ${BOT_TOKEN}
|
||||
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
|
||||
PROXY: ${PROXY:-}
|
||||
ADMIN_GROUP_ID: ${ADMIN_GROUP_ID}
|
||||
REMNAWAVE_URL: ${REMNAWAVE_URL}
|
||||
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL}
|
||||
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN}
|
||||
@@ -40,6 +39,8 @@ services:
|
||||
PER_DEVICE_COST: ${PER_DEVICE_COST:-50}
|
||||
WHITELIST_COST: ${WHITELIST_COST:-100}
|
||||
WHITELIST_DEVICE_THRESHOLD: ${WHITELIST_DEVICE_THRESHOLD:-6}
|
||||
PALLY_TOKEN: ${PALLY_TOKEN}
|
||||
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -29,6 +29,7 @@ return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_b
|
||||
|
||||
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
|
||||
[
|
||||
[InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")],
|
||||
[InlineKeyboardButton(text="✍️ Перейти в поддержку", url="https://t.me/maleniasupportbot")],
|
||||
[_return_to_menu_btn],
|
||||
]
|
||||
@@ -113,3 +114,12 @@ def referals_kb(share_link: str) -> InlineKeyboardMarkup:
|
||||
[_return_to_menu_btn],
|
||||
]
|
||||
).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
18
main.py
@@ -10,9 +10,11 @@ from config import settings
|
||||
from db.session import async_session
|
||||
from handlers import routers
|
||||
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 schemas.di import DependenciesDTO
|
||||
from services.billing_service import BillingService
|
||||
from services.user_service import UserService
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
@@ -21,9 +23,15 @@ logging.basicConfig(level=logging.DEBUG)
|
||||
async def main():
|
||||
dp = Dispatcher()
|
||||
|
||||
pally_client = PallyClient(settings.pally_token)
|
||||
|
||||
user_repository = UserRepository()
|
||||
ticket_repository = TicketRepository()
|
||||
user_service = UserService(user_repository, ticket_repository)
|
||||
user_service = UserService(user_repository)
|
||||
|
||||
bills_repository = BillsRepository()
|
||||
billing_service = BillingService(
|
||||
user_repository=user_repository, bills_repository=bills_repository
|
||||
)
|
||||
|
||||
rw_sdk = RemnawaveSDK(
|
||||
base_url=settings.remnawave_url,
|
||||
@@ -31,10 +39,12 @@ async def main():
|
||||
)
|
||||
|
||||
deps = DependenciesDTO(
|
||||
ticket_repository=ticket_repository,
|
||||
user_repository=user_repository,
|
||||
user_service=user_service,
|
||||
rw_sdk=rw_sdk,
|
||||
pally_client=pally_client,
|
||||
bills_repository=bills_repository,
|
||||
billing_service=billing_service,
|
||||
)
|
||||
dp.update.middleware.register(DIMiddleware(deps, async_session))
|
||||
|
||||
|
||||
385
misc/pally.py
Normal file
385
misc/pally.py
Normal 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()
|
||||
@@ -170,14 +170,18 @@ def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
|
||||
|
||||
|
||||
def calculate_price(subscription: SubscriptionPlan):
|
||||
return (
|
||||
return math.ceil(
|
||||
(
|
||||
(subscription.devices * settings.per_device_cost)
|
||||
+ (
|
||||
int(subscription.whitelists) * settings.whitelist_cost
|
||||
if subscription.devices < settings.whitelist_device_threshold
|
||||
else 0
|
||||
)
|
||||
) * convert_duration_to_int(subscription.duration)
|
||||
)
|
||||
* convert_duration_to_int(subscription.duration)
|
||||
* subscription.discount
|
||||
)
|
||||
|
||||
|
||||
def get_discount(months) -> float:
|
||||
|
||||
25
repositories/bills.py
Normal file
25
repositories/bills.py
Normal 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
|
||||
@@ -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
13
schemas/billing.py
Normal 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
|
||||
@@ -2,14 +2,18 @@ from dataclasses import dataclass
|
||||
|
||||
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 services.billing_service import BillingService
|
||||
from services.user_service import UserService
|
||||
|
||||
|
||||
@dataclass
|
||||
class DependenciesDTO:
|
||||
ticket_repository: TicketRepository
|
||||
user_repository: UserRepository
|
||||
user_service: UserService
|
||||
rw_sdk: RemnawaveSDK
|
||||
bills_repository: BillsRepository
|
||||
billing_service: BillingService
|
||||
pally_client: PallyClient
|
||||
|
||||
@@ -16,3 +16,4 @@ class SubscriptionPlan(BaseModel):
|
||||
devices: int = settings.min_devices
|
||||
duration: SubscriptionDuration = SubscriptionDuration.MONTH
|
||||
whitelists: bool = False
|
||||
discount: float = 1
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NewTicketDTO:
|
||||
creator_id: int
|
||||
41
services/billing_service.py
Normal file
41
services/billing_service.py
Normal 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
|
||||
@@ -4,18 +4,15 @@ from aiogram.types import Message
|
||||
from remnawave import RemnawaveSDK
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from db.models.tickets import TicketStatus
|
||||
from db.models.users import User
|
||||
from misc import utils
|
||||
from repositories import UserRepository
|
||||
from repositories.tickets import TicketRepository
|
||||
from schemas.users import NewUserDTO, TicketCreator
|
||||
from schemas.users import NewUserDTO
|
||||
from services.rw import RWUserInfo, get_user_by_telegram_id
|
||||
from texts import (
|
||||
NO_SUBSCRIPTION,
|
||||
NOTHING_PLACEHOLDER,
|
||||
SUBSCRIPTION_INFORMATION,
|
||||
TICKET_STATUS_EMOJIS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -25,24 +22,8 @@ class UserServiceError(Exception): ...
|
||||
|
||||
|
||||
class UserService:
|
||||
def __init__(
|
||||
self, user_repository: UserRepository, ticket_repository: TicketRepository
|
||||
) -> None:
|
||||
def __init__(self, user_repository: UserRepository) -> None:
|
||||
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
|
||||
def format_subscription_message(
|
||||
@@ -90,7 +71,7 @@ class UserService:
|
||||
|
||||
async def update_user_link(
|
||||
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
|
||||
) -> User:
|
||||
) -> User | None:
|
||||
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
|
||||
|
||||
user = await self.user_repository.get_user_by_id(session, user_id)
|
||||
|
||||
@@ -5,3 +5,8 @@ class SubscriptionStorage(StatesGroup):
|
||||
devices = State()
|
||||
duration = State()
|
||||
checkout = State()
|
||||
|
||||
|
||||
class BillingStorage(StatesGroup):
|
||||
pending = State()
|
||||
refresh = State()
|
||||
|
||||
25
texts.py
25
texts.py
@@ -1,5 +1,3 @@
|
||||
from db.models.tickets import TicketStatus
|
||||
|
||||
# ============================================================
|
||||
# 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 = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
|
||||
|
||||
# ============================================================
|
||||
# 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
|
||||
|
||||
|
||||
# ============================================================
|
||||
# ПОДДЕРЖКА
|
||||
|
||||
Reference in New Issue
Block a user