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
|
# 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
|
||||||
|
|||||||
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):
|
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")
|
||||||
|
|||||||
@@ -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
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 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",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]
|
||||||
|
|||||||
@@ -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
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,
|
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})
|
||||||
|
|||||||
@@ -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
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 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))
|
|
||||||
|
|||||||
@@ -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
18
main.py
@@ -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
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):
|
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
|
+ (
|
||||||
if subscription.devices < settings.whitelist_device_threshold
|
int(subscription.whitelists) * settings.whitelist_cost
|
||||||
else 0
|
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:
|
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 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
25
texts.py
25
texts.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# ПОДДЕРЖКА
|
# ПОДДЕРЖКА
|
||||||
|
|||||||
Reference in New Issue
Block a user