diff --git a/.env.example b/.env.example
index b0ef6b1..39d7999 100644
--- a/.env.example
+++ b/.env.example
@@ -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
diff --git a/alembic/versions/d21c5f9364ea_billing.py b/alembic/versions/d21c5f9364ea_billing.py
new file mode 100644
index 0000000..172dc62
--- /dev/null
+++ b/alembic/versions/d21c5f9364ea_billing.py
@@ -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 ###
diff --git a/config.py b/config.py
index a1f1ded..418e319 100644
--- a/config.py
+++ b/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")
diff --git a/db/models/__init__.py b/db/models/__init__.py
index 57fbb5c..aa4646f 100644
--- a/db/models/__init__.py
+++ b/db/models/__init__.py
@@ -1,4 +1,4 @@
-from .tickets import Ticket
+from .bills import Bill
from .users import User
-__all__ = ["Ticket", "User"]
+__all__ = ["Bill", "User"]
diff --git a/db/models/bills.py b/db/models/bills.py
new file mode 100644
index 0000000..3f57dba
--- /dev/null
+++ b/db/models/bills.py
@@ -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",
+ )
diff --git a/db/models/tickets.py b/db/models/tickets.py
deleted file mode 100644
index a89972d..0000000
--- a/db/models/tickets.py
+++ /dev/null
@@ -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)
diff --git a/db/models/users.py b/db/models/users.py
index bbc7127..e2458fa 100644
--- a/db/models/users.py
+++ b/db/models/users.py
@@ -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",
+ )
diff --git a/docker-compose.yml b/docker-compose.yml
index 142465b..445c082 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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:
diff --git a/handlers/__init__.py b/handlers/__init__.py
index 395c835..867b717 100644
--- a/handlers/__init__.py
+++ b/handlers/__init__.py
@@ -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,
+]
diff --git a/handlers/ads.py b/handlers/ads.py
index cd796cd..193b29b 100644
--- a/handlers/ads.py
+++ b/handlers/ads.py
@@ -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
diff --git a/handlers/billing.py b/handlers/billing.py
new file mode 100644
index 0000000..9381569
--- /dev/null
+++ b/handlers/billing.py
@@ -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),
+ )
diff --git a/handlers/buy.py b/handlers/buy.py
index ad7b609..60da736 100644
--- a/handlers/buy.py
+++ b/handlers/buy.py
@@ -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})
diff --git a/handlers/common.py b/handlers/common.py
index b742466..4e60da4 100644
--- a/handlers/common.py
+++ b/handlers/common.py
@@ -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)
diff --git a/handlers/prehandling.py b/handlers/prehandling.py
new file mode 100644
index 0000000..df2e457
--- /dev/null
+++ b/handlers/prehandling.py
@@ -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
diff --git a/handlers/referals.py b/handlers/referals.py
index 2c882e5..9280cff 100644
--- a/handlers/referals.py
+++ b/handlers/referals.py
@@ -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))
diff --git a/keyboards/client.py b/keyboards/client.py
index a5ea761..2a0dc6d 100644
--- a/keyboards/client.py
+++ b/keyboards/client.py
@@ -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()
diff --git a/main.py b/main.py
index e78705a..9875e9a 100644
--- a/main.py
+++ b/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))
diff --git a/misc/pally.py b/misc/pally.py
new file mode 100644
index 0000000..5b09262
--- /dev/null
+++ b/misc/pally.py
@@ -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()
diff --git a/misc/utils.py b/misc/utils.py
index 3f3c8af..daf1fcd 100644
--- a/misc/utils.py
+++ b/misc/utils.py
@@ -170,14 +170,18 @@ def convert_duration_to_timedelta(duration: SubscriptionDuration) -> timedelta:
def calculate_price(subscription: SubscriptionPlan):
- return (
- (subscription.devices * settings.per_device_cost)
- + (
- int(subscription.whitelists) * settings.whitelist_cost
- if subscription.devices < settings.whitelist_device_threshold
- else 0
+ 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:
diff --git a/repositories/bills.py b/repositories/bills.py
new file mode 100644
index 0000000..275c415
--- /dev/null
+++ b/repositories/bills.py
@@ -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
diff --git a/repositories/tickets.py b/repositories/tickets.py
deleted file mode 100644
index aac9af9..0000000
--- a/repositories/tickets.py
+++ /dev/null
@@ -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
diff --git a/schemas/billing.py b/schemas/billing.py
new file mode 100644
index 0000000..fe7b2cc
--- /dev/null
+++ b/schemas/billing.py
@@ -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
diff --git a/schemas/di.py b/schemas/di.py
index 21583c7..a1a6fe7 100644
--- a/schemas/di.py
+++ b/schemas/di.py
@@ -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
diff --git a/schemas/subscriptions.py b/schemas/subscriptions.py
index b5370fa..cc9abc1 100644
--- a/schemas/subscriptions.py
+++ b/schemas/subscriptions.py
@@ -16,3 +16,4 @@ class SubscriptionPlan(BaseModel):
devices: int = settings.min_devices
duration: SubscriptionDuration = SubscriptionDuration.MONTH
whitelists: bool = False
+ discount: float = 1
diff --git a/schemas/tickets.py b/schemas/tickets.py
deleted file mode 100644
index fa33e84..0000000
--- a/schemas/tickets.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from dataclasses import dataclass
-
-
-@dataclass
-class NewTicketDTO:
- creator_id: int
diff --git a/services/billing_service.py b/services/billing_service.py
new file mode 100644
index 0000000..65f4045
--- /dev/null
+++ b/services/billing_service.py
@@ -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
diff --git a/services/user_service.py b/services/user_service.py
index c03bde0..a13ad47 100644
--- a/services/user_service.py
+++ b/services/user_service.py
@@ -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)
diff --git a/states/buy.py b/states/buy.py
index 374c4e6..5260365 100644
--- a/states/buy.py
+++ b/states/buy.py
@@ -5,3 +5,8 @@ class SubscriptionStorage(StatesGroup):
devices = State()
duration = State()
checkout = State()
+
+
+class BillingStorage(StatesGroup):
+ pending = State()
+ refresh = State()
diff --git a/texts.py b/texts.py
index 103f9f4..8cb8814 100644
--- a/texts.py
+++ b/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 = (
+ "💸 Счёт на {amount}₽\n\n"
+ "‼️ Вам придёт уведомление в течение нескольких минут после завершения платежа.\n"
+ "🔒 Способ оплаты: {provider}\n\n"
+)
+
+_BILL_LINK = f"{MALENIA_LINK} Ссылка на оплату:"
+
+BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
+
+BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
+
# ============================================================
# ПОДДЕРЖКА