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 + # ============================================================ # ПОДДЕРЖКА