Compare commits

...

2 Commits

Author SHA1 Message Date
287f8eebda feat: add subscription and transaction repositories
- Implemented SubscriptionRepository with methods to get, create, and update subscriptions.
- Added BalanceTXRepository for creating and retrieving balance transactions.
- Introduced a test script for simulating Pally webhook for local testing.
2026-04-27 21:33:25 +07:00
049f31118d feat: Implement payment notification system and API structure
- Added notifications for successful payments in `api/core/notifications.py`.
- Created main API entry point in `api/main.py` with FastAPI integration.
- Established routing structure in `api/routes/__init__.py` and `api/routes/pally.py` for handling payment callbacks.
- Developed billing handlers in `bot/handlers/billing.py` and `bot/handlers/buy.py` for subscription management.
- Introduced state management for user interactions in `bot/states/`.
- Created user interface elements in `bot/keyboards/` for navigation and payment processing.
- Set up middleware for dependency injection in `bot/middlewares/di.py`.
- Added support for user commands and interactions in `bot/handlers/common.py` and `bot/handlers/support.py`.
- Implemented logging and error handling throughout the bot's functionality.
- Created entry point script for API server in `entrypoints/api.sh`.
2026-04-24 17:41:42 +07:00
57 changed files with 3683 additions and 84 deletions

41
Dockerfile.api Normal file
View File

@@ -0,0 +1,41 @@
# Stage 1: Builder - Install dependencies
FROM python:3.13-slim AS builder
WORKDIR /build
# Install build-time dependencies required for compiling packages like asyncpg
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Runner - Final lightweight image
FROM python:3.13-slim AS runner
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
WORKDIR /app
# Install runtime dependencies required by asyncpg
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
&& rm -rf /var/lib/apt/lists/*
# Copy installed Python packages from the builder stage
COPY --from=builder /install /usr/local
# Copy application source code
COPY . .
RUN sed -i 's/\r$//' ./entrypoints/api.sh && \
chmod +x ./entrypoints/api.sh
# Ensure the api script is executable
RUN chmod +x ./entrypoints/api.sh
# Default command runs migrations and starts the API
CMD ["sh", "./entrypoints/api.sh"]

View File

@@ -0,0 +1,36 @@
"""plan specifications added
Revision ID: b2ecec0ef260
Revises: e8da1e4516fa
Create Date: 2026-04-27 20:16:04.252427
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b2ecec0ef260'
down_revision: Union[str, Sequence[str], None] = 'e8da1e4516fa'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('subscriptions', sa.Column('devices', sa.INTEGER(), nullable=False, server_default="3"))
op.add_column('subscriptions', sa.Column('whitelists', sa.BOOLEAN(), nullable=False, server_default="false"))
# ### end Alembic commands ###
op.alter_column("subscriptions", "devices", server_default=None)
op.alter_column("subscriptions", "whitelists", server_default=None)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('subscriptions', 'whitelists')
op.drop_column('subscriptions', 'devices')
# ### end Alembic commands ###

View File

@@ -0,0 +1,81 @@
"""replace_billstatus_with_depositstatus
Revision ID: e6541f81f7de
Revises: b2ecec0ef260
Create Date: 2026-04-27 20:38:12.226592
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'e6541f81f7de'
down_revision: Union[str, Sequence[str], None] = 'b2ecec0ef260'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('balance_transactions',
sa.Column('id', sa.BIGINT(), autoincrement=True, nullable=False),
sa.Column('user_id', sa.BIGINT(), nullable=False),
sa.Column('amount', sa.INTEGER(), nullable=False),
sa.Column('tx_type', sa.Enum('DEPOSIT', 'PURCHASE', 'REFUND', 'REFERRAL_BONUS', 'MANUAL', name='balancetxtype'), nullable=False),
sa.Column('balance_before', sa.INTEGER(), nullable=False),
sa.Column('balance_after', sa.INTEGER(), nullable=False),
sa.Column('description', sa.TEXT(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_balance_transactions_user_id'), 'balance_transactions', ['user_id'], unique=False)
op.execute("CREATE TYPE depositstatus AS ENUM ('pending', 'success', 'failed', 'expired')")
op.execute("ALTER TABLE bills ALTER COLUMN status DROP DEFAULT")
op.execute("""
ALTER TABLE bills
ALTER COLUMN status TYPE depositstatus
USING (
CASE status::text
WHEN 'NEW' THEN 'pending'::depositstatus
WHEN 'PROCESS' THEN 'pending'::depositstatus
WHEN 'UNDERPAID' THEN 'pending'::depositstatus
WHEN 'SUCCESS' THEN 'success'::depositstatus
WHEN 'OVERPAID' THEN 'success'::depositstatus
WHEN 'FAIL' THEN 'failed'::depositstatus
ELSE 'pending'::depositstatus
END
)
""")
op.execute("ALTER TABLE bills ALTER COLUMN status SET DEFAULT 'pending'::depositstatus")
op.execute("DROP TYPE billstatus")
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.execute("CREATE TYPE billstatus AS ENUM ('NEW', 'PROCESS', 'UNDERPAID', 'SUCCESS', 'OVERPAID', 'FAIL')")
op.execute("ALTER TABLE bills ALTER COLUMN status DROP DEFAULT")
op.execute("""
ALTER TABLE bills
ALTER COLUMN status TYPE billstatus
USING (
CASE status::text
WHEN 'pending' THEN 'NEW'::billstatus
WHEN 'success' THEN 'SUCCESS'::billstatus
WHEN 'failed' THEN 'FAIL'::billstatus
WHEN 'expired' THEN 'FAIL'::billstatus
ELSE 'NEW'::billstatus
END
)
""")
op.execute("ALTER TABLE bills ALTER COLUMN status SET DEFAULT 'NEW'::billstatus")
op.execute("DROP TYPE depositstatus")
op.drop_index(op.f('ix_balance_transactions_user_id'), table_name='balance_transactions')
op.drop_table('balance_transactions')
# ### end Alembic commands ###

View File

@@ -0,0 +1,39 @@
"""add subscription table
Revision ID: e8da1e4516fa
Revises: d21c5f9364ea
Create Date: 2026-04-25 16:29:08.186456
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e8da1e4516fa'
down_revision: Union[str, Sequence[str], None] = 'd21c5f9364ea'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('subscriptions',
sa.Column('user_id', sa.BIGINT(), nullable=False),
sa.Column('end_date', sa.DateTime(timezone=True), nullable=False),
sa.Column('autorenew', sa.Boolean(), nullable=False),
sa.Column('status', sa.Enum('EXPIRED', 'ACTIVE', name='subscriptionstatus'), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('user_id')
)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('subscriptions')
# ### end Alembic commands ###

View File

@@ -0,0 +1,69 @@
"""fix_balancetxtype_to_lowercase
Revision ID: f0668f8e7310
Revises: e6541f81f7de
Create Date: 2026-04-27 21:06:55.168624
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f0668f8e7310'
down_revision: Union[str, Sequence[str], None] = 'e6541f81f7de'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("""
CREATE TYPE balancetxtype_new AS ENUM (
'deposit', 'purchase', 'refund', 'referral', 'manual'
)
""")
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
op.execute("""
ALTER TABLE balance_transactions
ALTER COLUMN tx_type TYPE balancetxtype_new
USING (
CASE tx_type::text
WHEN 'DEPOSIT' THEN 'deposit'::balancetxtype_new
WHEN 'PURCHASE' THEN 'purchase'::balancetxtype_new
WHEN 'REFUND' THEN 'refund'::balancetxtype_new
WHEN 'REFERRAL_BONUS' THEN 'referral'::balancetxtype_new
WHEN 'MANUAL' THEN 'manual'::balancetxtype_new
ELSE 'manual'::balancetxtype_new
END
)
""")
op.execute("DROP TYPE balancetxtype")
op.execute("ALTER TYPE balancetxtype_new RENAME TO balancetxtype")
def downgrade() -> None:
op.execute("""
CREATE TYPE balancetxtype_old AS ENUM (
'DEPOSIT', 'PURCHASE', 'REFUND', 'REFERRAL_BONUS', 'MANUAL'
)
""")
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
op.execute("""
ALTER TABLE balance_transactions
ALTER COLUMN tx_type TYPE balancetxtype_old
USING (
CASE tx_type::text
WHEN 'deposit' THEN 'DEPOSIT'::balancetxtype_old
WHEN 'purchase' THEN 'PURCHASE'::balancetxtype_old
WHEN 'refund' THEN 'REFUND'::balancetxtype_old
WHEN 'referral' THEN 'REFERRAL_BONUS'::balancetxtype_old
WHEN 'manual' THEN 'MANUAL'::balancetxtype_old
ELSE 'MANUAL'::balancetxtype_old
END
)
""")
op.execute("DROP TYPE balancetxtype")
op.execute("ALTER TYPE balancetxtype_old RENAME TO balancetxtype")

18
api/core/deps.py Normal file
View File

@@ -0,0 +1,18 @@
from aiogram import Bot
from fastapi import Request
from db.session import async_session
from schemas.di import APIDependenciesDTO
def get_bot(request: Request) -> Bot:
return request.app.state.bot
async def get_db():
async with async_session() as db:
yield db
def get_deps(request: Request) -> APIDependenciesDTO:
return request.app.state.deps

13
api/core/notifications.py Normal file
View File

@@ -0,0 +1,13 @@
from aiogram import Bot
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
SUCCESSFUL_PAYMENT = "<b>💸 Зачислен платёж на {amount}₽</b>"
close_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="", callback_data="delete")]]
).as_markup()
async def successful_payment_notification(bot: Bot, user_id: int, amount: int):
await bot.send_message(user_id, SUCCESSFUL_PAYMENT.format(amount=amount), reply_markup=close_kb)

56
api/main.py Normal file
View File

@@ -0,0 +1,56 @@
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from fastapi import FastAPI
from api.routes import routers
from config import settings
from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository
from schemas.di import APIDependenciesDTO
from services.billing_service import BillingService
from services.user_service import UserService
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
# Bot Init
bot_default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=bot_default)
app.state.bot = bot
user_repository = UserRepository()
bills_repository = BillsRepository()
subscription_repository = SubscriptionRepository()
user_service = UserService(user_repository, subscription_repository=subscription_repository)
bills_service = BillingService(
user_repository=user_repository, bills_repository=bills_repository
)
deps_dto = APIDependenciesDTO(
user_repository=user_repository,
user_service=user_service,
bills_repository=bills_repository,
billing_service=bills_service,
)
app.state.deps = deps_dto
for r in routers:
app.include_router(r)
yield # App's lifespan
await bot.session.close() # D IE!!!!!!!!
app = FastAPI(
title="Malenia API",
description="Payment webhook handler and user balance management",
version="1.0.0",
lifespan=lifespan,
docs_url="",
)

3
api/routes/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from .pally import router as pally_router
routers = [pally_router]

106
api/routes/pally.py Normal file
View File

@@ -0,0 +1,106 @@
import hashlib
import logging
import math
from aiogram import Bot
from fastapi import Depends, Form, HTTPException
from fastapi.routing import APIRouter
from sqlalchemy.ext.asyncio import AsyncSession
from api.core.deps import get_bot, get_db, get_deps
from api.core.notifications import successful_payment_notification
from config import settings
from db.models.bills import DepositStatus
from db.models.transactions import BalanceTxType
from misc.pally import BillStatus
from schemas.di import APIDependenciesDTO
router = APIRouter(prefix="/checkout/pally")
logger = logging.getLogger(__name__)
@router.post("/callback")
async def pally_callback(
id: str = Form(...),
bill_id: str = Form(...),
status: str = Form(...),
req_amount: str = Form(...),
currency: str = Form(...),
order_id: str | None = Form(None),
signature: str = Form(...),
session: AsyncSession = Depends(get_db),
bot: Bot = Depends(get_bot),
deps: APIDependenciesDTO = Depends(get_deps),
):
oid = order_id or ""
raw_string = f"{id}{status}{req_amount}{currency}{oid}{settings.pally_token}"
md5_hash = hashlib.md5(raw_string.encode("utf-8")).hexdigest()
expected_signature = hashlib.sha1(md5_hash.encode("utf-8")).hexdigest()
if signature != expected_signature:
logger.critical("Invalid signature for bill %s", bill_id)
raise HTTPException(403, detail="Invalid signature.")
if status != BillStatus.SUCCESS or not req_amount.isdigit():
logger.info("Bill %s skipped: status=%s, req_amount=%s", bill_id, status, req_amount)
return "OK"
logger.info("Received successfully paid bill %s", bill_id)
if not oid.isdigit():
logger.critical("Invalid order_id format for bill %s", bill_id)
return "OK"
bill = await deps.bills_repository.get_bill_by_id(session, int(oid))
if not bill:
logger.critical("Bill %s is not found in db", bill_id)
return "OK"
if bill.status != DepositStatus.PENDING:
logger.warning("bill=%s is already paid according to the db", bill.id)
return "OK"
try:
amount = int(req_amount)
if bill.amount != amount:
logger.warning("requested amount for bill=%s is not equal to db entry", bill.id)
return "OK"
await deps.user_repository.increase_user_balance(
session,
bill.creator_id,
amount=amount,
tx_type=BalanceTxType.DEPOSIT,
description="personal balance deposit via PALLY",
)
await successful_payment_notification(bot, bill.creator_id, amount)
user = await deps.user_repository.get_user_by_id(session, bill.creator_id)
referal = user.referal_id if user else None
if referal is not None:
referal_amount = math.floor(amount * (settings.referal_bonus / 100))
await deps.user_repository.increase_user_balance(
session,
referal,
referal_amount,
tx_type=BalanceTxType.REFERRAL_BONUS,
description=f"referal reward for referal_id={bill.creator_id}",
)
await successful_payment_notification(bot, referal, referal_amount)
except Exception:
logger.exception(
"Exception caught while processing balances for bill %s (Creator: %s)",
bill_id,
bill.creator_id,
)
await deps.bills_repository.update_bill_status(
session, int(bill.id), status=DepositStatus.SUCCESS
)
return "OK"

View File

@@ -2,6 +2,7 @@ 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 .deposit import router as deposit_router
from .menus import router as menus_router
from .prehandling import router as prehandling_router
from .referals import router as referal_router
@@ -11,6 +12,7 @@ routers = [
prehandling_router,
referal_router,
billing_router,
deposit_router,
menus_router,
ads_router,
support_router,

View File

@@ -5,11 +5,11 @@ 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 bot.keyboards.client import pay_url, return_to_menu
from bot.states.buy import BillingStorage
from bot.texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
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__)
@@ -29,8 +29,11 @@ async def pally_init(
await state.clear()
ctx.provider = BillingProviders.PALLY
ctx.amount = int(ctx.amount)
await cb.message.edit_text(BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title()))
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

View File

@@ -4,32 +4,30 @@ import math
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from config import settings
from keyboards.client import (
devices_selector,
duration_selector,
payment_gateways,
return_to_menu,
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector, return_to_menu
from bot.states.buy import SubscriptionStorage
from bot.texts import (
CALLBACK_FALLBACK,
CONTEXT_REINITIATED,
INSUFFICIENT_FUNDS,
NOTHING_PLACEHOLDER,
STANDARD_FALLBACK,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
SUCCESSFULLY_CREATED_SUBSCRIPTION,
)
from config import settings
from db.models.transactions import BalanceTxType
from misc.utils import (
calculate_price,
convert_duration_to_int,
convert_int_to_duration,
get_discount,
)
from schemas.billing import BillingContext
from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan
from states.buy import BillingStorage, SubscriptionStorage
from texts import (
CALLBACK_FALLBACK,
CHECKOUT_PAYMENT_CHOICE,
CONTEXT_REINITIATED,
NOTHING_PLACEHOLDER,
STANDARD_FALLBACK,
SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR,
)
router = Router()
logger = logging.getLogger(__name__)
@@ -157,7 +155,9 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm")
async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
async def proceed_to_checkout(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
@@ -166,11 +166,43 @@ async def proceed_to_checkout(cb: CallbackQuery, state: FSMContext):
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
await cb.message.edit_text(
CHECKOUT_PAYMENT_CHOICE,
reply_markup=payment_gateways,
) # FIXME: temp solution that's incredibly stupid
amount = calculate_price(ctx)
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
await state.set_state(BillingStorage.pending)
billing_context = BillingContext(amount=calculate_price(ctx))
await state.set_data({"ctx": billing_context})
if user.balance < amount:
await cb.message.edit_text(
INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), reply_markup=deposit_kb
)
return
if not user:
await cb.message.edit_text(CALLBACK_FALLBACK)
return
try:
await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user)
except Exception:
logger.exception(
"buy_subscription caught an exception on user_id=%s, amount=%s. balance was NOT affected, transaction is rejected.",
user.id,
amount,
)
await cb.message.edit_text(CALLBACK_FALLBACK)
return
deducted_user = await deps.user_repository.decrease_user_balance(
session,
user.id,
amount,
BalanceTxType.PURCHASE,
description=f"subscription purchase: devices={ctx.devices}, duration={ctx.duration}, whitelists={ctx.whitelists}",
)
if not deducted_user:
logger.critical(
"RECONCILIATION NEEDED: subscription created for user_id=%s "
"but balance deduction FAILED. amount=%s.",
cb.from_user.id,
amount,
)
await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu)

View File

@@ -2,7 +2,7 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from texts import UNDEFINED_MESSAGE
from bot.texts import UNDEFINED_MESSAGE
router = Router()

46
bot/handlers/deposit.py Normal file
View File

@@ -0,0 +1,46 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from bot.keyboards.client import payment_gateways, return_to_menu
from bot.states.buy import BillingStorage
from bot.states.deposit import DepositStorage
from bot.texts import (
CHECKOUT_PAYMENT_CHOICE,
DEPOSIT_AMOUNT,
DEPOSIT_AMOUNT_FALLBACK,
)
from schemas.billing import BillingContext
from schemas.common import GeneralMessageContext
router = Router()
@router.callback_query(F.data == "deposit")
async def deposit_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(DEPOSIT_AMOUNT, reply_markup=return_to_menu)
ctx = GeneralMessageContext(cb.message)
await state.set_state(DepositStorage.amount)
await state.set_data({"ctx": ctx})
@router.message(DepositStorage.amount)
async def deposit_amount(msg: Message, state: FSMContext):
data = await state.get_data()
await state.clear()
await msg.delete()
ctx: GeneralMessageContext | None = data.get("ctx")
if not isinstance(ctx, GeneralMessageContext) or not msg.text.isdigit():
await msg.answer(DEPOSIT_AMOUNT_FALLBACK, reply_markup=return_to_menu)
await state.set_state(DepositStorage.amount)
await state.set_data(data)
return
await ctx.msg.edit_text(CHECKOUT_PAYMENT_CHOICE, reply_markup=payment_gateways)
await state.set_state(BillingStorage.pending)
billing_context = BillingContext(amount=int(msg.text))
await state.set_data({"ctx": billing_context})

View File

@@ -4,11 +4,11 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import close_kb, main_menu, return_to_menu
from bot.keyboards.client import close_kb, main_menu, return_to_menu
from bot.texts import FAQ_TEXT, GENERAL_PROCESSING
from misc.utils import build_main_menu_text, format_subscription_link
from schemas.di import DependenciesDTO
from services.rw import get_user_by_telegram_id
from texts import FAQ_TEXT, GENERAL_PROCESSING
router = Router()
@@ -34,7 +34,9 @@ async def fetch_referal(
)
await msg.answer(
build_main_menu_text(format_subscription_link(user.subscription_link)),
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)
@@ -51,7 +53,9 @@ async def standard_start(
user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
await msg.answer(
build_main_menu_text(format_subscription_link(user.subscription_link)),
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)
@@ -64,7 +68,9 @@ async def main_menu_cb(
user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)),
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)
@@ -79,7 +85,9 @@ async def update_link(
user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
await cb.answer("")
await cb.message.edit_text(
build_main_menu_text(format_subscription_link(user.subscription_link)),
build_main_menu_text(
format_subscription_link(user.subscription_link), balance=user.balance
),
reply_markup=main_menu,
)

View File

@@ -4,12 +4,10 @@ from aiogram.types import CallbackQuery
from aiogram.utils.deep_linking import create_start_link
from sqlalchemy.ext.asyncio import AsyncSession
from keyboards.client import referals_kb
from bot.keyboards.client import referals_kb
from bot.texts import REFERALS_MENU
from misc.utils import format_share_deep_link
from schemas.di import DependenciesDTO
from texts import (
REFERALS_MENU,
)
router = Router()

View File

@@ -5,8 +5,8 @@ from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from keyboards.client import support_url_kb
from texts import SUPPORT_INIT
from bot.keyboards.client import support_url_kb
from bot.texts import SUPPORT_INIT
router = Router()
logger = logging.getLogger(__name__)

View File

View File

@@ -9,14 +9,17 @@ main_menu = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
[
InlineKeyboardButton(text="⚙️ Купить", callback_data="buy"),
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
InlineKeyboardButton(text="🛒 Купить", callback_data="buy"),
InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit"),
],
[
InlineKeyboardButton(text="📢 Наш Канал", url="https://t.me/maleniavpn"),
InlineKeyboardButton(text="🎧 Тех. Поддержка", url="https://t.me/maleniasupportbot"),
],
[InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link")],
[
InlineKeyboardButton(text="🔗 Обновить ссылку", callback_data="update_link"),
InlineKeyboardButton(text="⁉️ FAQ", callback_data="faq"),
],
[InlineKeyboardButton(text="👤 Реферальная система", callback_data="referal")],
]
).as_markup()
@@ -46,6 +49,10 @@ support_url_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
]
).as_markup()
deposit_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
[[InlineKeyboardButton(text="💸 Пополнить", callback_data="deposit")], [_return_to_menu_btn]]
).as_markup()
def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
return InlineKeyboardButton(text=text, callback_data=data)

View File

@@ -6,12 +6,13 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from remnawave import RemnawaveSDK
from bot.handlers import routers
from bot.middlewares import DIMiddleware
from config import settings
from db.session import async_session
from handlers import routers
from middlewares import DIMiddleware
from misc.pally import PallyClient
from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository
from schemas.di import DependenciesDTO
from services.billing_service import BillingService
@@ -26,9 +27,10 @@ async def main():
pally_client = PallyClient(settings.pally_token)
user_repository = UserRepository()
user_service = UserService(user_repository)
bills_repository = BillsRepository()
subscriptions_repository = SubscriptionRepository()
user_service = UserService(user_repository, subscription_repository=subscriptions_repository)
billing_service = BillingService(
user_repository=user_repository, bills_repository=bills_repository
)
@@ -45,6 +47,7 @@ async def main():
pally_client=pally_client,
bills_repository=bills_repository,
billing_service=billing_service,
subscriptions_repository=subscriptions_repository,
)
dp.update.middleware.register(DIMiddleware(deps, async_session))

0
bot/states/__init__.py Normal file
View File

5
bot/states/deposit.py Normal file
View File

@@ -0,0 +1,5 @@
from aiogram.fsm.state import State, StatesGroup
class DepositStorage(StatesGroup):
amount = State()

View File

@@ -94,7 +94,8 @@ NOTHING_PLACEHOLDER = "∅"
MAIN_MENU = (
str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_COINS} Личный кабинет</i>\n"
+ f"<i>{MALENIA_HEART} Личный кабинет</i>\n"
+ f"<b>{MALENIA_COINS} Ваш баланс:</b> <code>{{balance}}₽</code>\n"
+ f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
)
@@ -162,7 +163,7 @@ SUBSCRIPTION_DURATION_SELECTOR = (
WHITELISTS_ALREADY_ON = "Белые списки уже активны."
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки."
CHECKOUT_PAYMENT_CHOICE = "<b>💳 Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>"
# ============================================================
# BILLING
@@ -180,6 +181,13 @@ BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
INSUFFICIENT_FUNDS = "<b>⚠️ Недостаточно средств для проведения операции.</b>\n\n💰 Текущий баланс: {balance}\n💸 Требуется: {amount}"
SUCCESSFULLY_CREATED_SUBSCRIPTION = (
"<b>💚 Ваша подписка успешно оплачена!</b> Ссылка на добавления доступна в главном меню."
)
DEPOSIT_AMOUNT = "<b>💸 Укажите сумму пополнения</b>"
DEPOSIT_AMOUNT_FALLBACK = "<b>❌ Сумма пополнения указана неверно, повторите попытку.</b>"
# ============================================================
# ПОДДЕРЖКА

View File

@@ -6,24 +6,31 @@ load_dotenv(override=True)
class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN")
postgres_url: str = Field(
"postgresql+asyncpg://malenia:skibidi@localhost:5432/malenia_db", alias="POSTGRES_URL"
)
bot_token: str = Field(alias="BOT_TOKEN")
pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
pally_token: str = Field(alias="PALLY_TOKEN")
referal_bonus: int = Field(10, alias="REFERAL_BONUS")
proxy: str | None = Field(None, alias="PROXY")
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")
whitelist_cost: int = Field(100, alias="WHITELIST_COST")
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD")
general_squad: str = Field("2de0b5ea-49b9-4181-916f-ae67b9c83b80")
whitelist_squad: str = Field("e4435baa-64d3-4b29-a18e-81ad1c60d977")
settings = Settings() # type: ignore

View File

@@ -1,4 +1,6 @@
from .bills import Bill
from .subscriptions import Subscription
from .transactions import BalanceTransaction
from .users import User
__all__ = ["Bill", "User"]
__all__ = ["BalanceTransaction", "Bill", "Subscription", "User"]

View File

@@ -1,15 +1,22 @@
from enum import StrEnum
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 DepositStatus(StrEnum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
EXPIRED = "expired"
class Bill(Base):
__tablename__ = "bills"
@@ -18,8 +25,10 @@ class Bill(Base):
)
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
status: Mapped[DepositStatus] = mapped_column(
Enum(DepositStatus, name="depositstatus", values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=DepositStatus.PENDING,
)
user: Mapped["User"] = relationship(

View File

@@ -0,0 +1,35 @@
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import BOOLEAN, INTEGER, Boolean, DateTime, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
from schemas.subscriptions import SubscriptionStatus
if TYPE_CHECKING:
from db.models.users import User
class Subscription(Base):
__tablename__ = "subscriptions"
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), primary_key=True)
end_date: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
autorenew: Mapped[bool] = mapped_column(Boolean, default=False)
status: Mapped[SubscriptionStatus] = mapped_column(
Enum(
SubscriptionStatus,
name="subscriptionstatus",
values_callable=lambda x: [e.value for e in x],
),
default=SubscriptionStatus.EXPIRED,
)
devices: Mapped[int] = mapped_column(INTEGER, nullable=False, default=3)
whitelists: Mapped[bool] = mapped_column(BOOLEAN, nullable=False, default=False)
plan_price_paid: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0)
duration_days: Mapped[int] = mapped_column(INTEGER, nullable=False, default=30)
user: Mapped["User"] = relationship(back_populates="subscription")

41
db/models/transactions.py Normal file
View File

@@ -0,0 +1,41 @@
from datetime import datetime
from enum import StrEnum
from typing import TYPE_CHECKING
from sqlalchemy import BIGINT, INTEGER, TEXT, DateTime, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base
if TYPE_CHECKING:
from db.models.users import User
class BalanceTxType(StrEnum):
DEPOSIT = "deposit"
PURCHASE = "purchase"
REFUND = "refund"
REFERRAL_BONUS = "referral"
MANUAL = "manual"
class BalanceTransaction(Base):
__tablename__ = "balance_transactions"
id: Mapped[int] = mapped_column(BIGINT, autoincrement=True, primary_key=True)
user_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False, index=True)
amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
tx_type: Mapped[BalanceTxType] = mapped_column(
Enum(BalanceTxType, name="balancetxtype", values_callable=lambda x: [e.value for e in x]),
nullable=False,
)
balance_before: Mapped[int] = mapped_column(INTEGER, nullable=False)
balance_after: Mapped[int] = mapped_column(INTEGER, nullable=False)
description: Mapped[str] = mapped_column(TEXT, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now() # noqa: PLW0108
)
user: Mapped["User"] = relationship("User", back_populates="balance_transactions")

View File

@@ -7,6 +7,8 @@ from db.base import Base
if TYPE_CHECKING:
from db.models.bills import Bill
from db.models.subscriptions import Subscription
from db.models.transactions import BalanceTransaction
class User(Base):
@@ -22,3 +24,9 @@ class User(Base):
back_populates="user",
cascade="all, delete",
)
subscription: Mapped["Subscription"] = relationship(
"Subscription", back_populates="user", cascade="all, delete-orphan"
)
balance_transactions: Mapped[list["BalanceTransaction"]] = relationship(
"BalanceTransaction", back_populates="user", cascade="all, delete"
)

View File

@@ -3,6 +3,7 @@ services:
image: postgres:16-alpine
container_name: maleniabot_db
environment:
TZ: UTC
POSTGRES_USER: ${POSTGRES_USER:-malenia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
@@ -23,11 +24,12 @@ services:
bot:
build:
dockerfile: Dockerfile
container_name: maleniabot_app
container_name: maleniabot_bot
depends_on:
postgres:
condition: service_healthy
environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-}
@@ -43,5 +45,31 @@ services:
PALLY_SHOP_ID: ${PALLY_SHOP_ID}
restart: unless-stopped
api:
build:
dockerfile: Dockerfile.api
container_name: maleniabot_api
depends_on:
postgres:
condition: service_healthy
environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-}
REMNAWAVE_URL: ${REMNAWAVE_URL}
SUBSCRIPTION_URL: ${SUBSCRIPTION_URL}
REMNAWAVE_TOKEN: ${REMNAWAVE_TOKEN}
MIN_DEVICES: ${MIN_DEVICES:-3}
MAX_DEVICES: ${MAX_DEVICES:-12}
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}
ports:
- "8000:8000"
restart: unless-stopped
volumes:
postgres_data:

5
entrypoints/api.sh Normal file
View File

@@ -0,0 +1,5 @@
#!/bin/bash
set -e
echo "[+] Starting API server..."
python -m uvicorn api.main:app --host 0.0.0.0 --port 8000

View File

@@ -6,4 +6,4 @@ echo
alembic upgrade head
echo "[+] Starting bot..."
python main.py
python -m bot.main

View File

@@ -3,7 +3,7 @@ import urllib.parse
from datetime import UTC, datetime, timedelta
from random import choice
import texts
from bot import texts
from config import settings
from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan
@@ -231,5 +231,5 @@ def get_subscription_link(short_uuid: str):
return settings.subscription_url + "/" + short_uuid
def build_main_menu_text(link: str) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link)
def build_main_menu_text(link: str, balance: int) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance)

1299
plans/FINANCE_REFINEMENT.md Normal file

File diff suppressed because it is too large Load Diff

1183
plans/SUBSCRIPTION_FIX.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,8 @@ ignore = [
"PLR0913",
"RUF001",
"RUF002",
"RUF003"
"RUF003",
"B008"
]
[tool.ruff.lint.isort]

View File

@@ -1,7 +1,7 @@
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.bills import Bill, BillStatus
from db.models.bills import Bill, DepositStatus
class BillsRepository:
@@ -17,9 +17,15 @@ class BillsRepository:
*,
creator_id: int,
amount: int,
status: BillStatus | None = BillStatus.NEW,
status: DepositStatus | None = DepositStatus.PENDING,
) -> Bill:
bill = Bill(creator_id=creator_id, amount=amount, status=status)
session.add(bill)
await session.commit()
return bill
async def update_bill_status(self, session: AsyncSession, bill_id: int, status: DepositStatus):
stmt = update(Bill).where(Bill.id == bill_id).values(status=status).returning(Bill)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one_or_none()

View File

@@ -0,0 +1,74 @@
from datetime import datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.subscriptions import Subscription, SubscriptionStatus
class SubscriptionRepository:
async def get_subscription_by_user_id(
self, session: AsyncSession, user_id: int
) -> Subscription | None:
stmt = select(Subscription).where(Subscription.user_id == user_id)
res = await session.execute(stmt)
return res.scalar_one_or_none()
async def create_subscription(
self,
session: AsyncSession,
*,
user_id: int,
end_date: datetime,
devices: int,
duration_days: int,
plan_price_paid: int,
whitelists: bool,
autorenew: bool = False,
status: SubscriptionStatus = SubscriptionStatus.EXPIRED,
) -> Subscription:
subscription = Subscription(
user_id=user_id,
end_date=end_date,
autorenew=autorenew,
status=status,
devices=devices,
whitelists=whitelists,
duration_days=duration_days,
plan_price_paid=plan_price_paid,
)
session.add(subscription)
await session.commit()
return subscription
async def update_sub(
self,
session: AsyncSession,
user_id: int,
*,
end_date: datetime,
devices: int,
whitelists: bool,
duration_days: int,
plan_price_paid: int,
autorenew: bool = False,
status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
) -> Subscription | None:
stmt = (
update(Subscription)
.where(Subscription.user_id == user_id)
.values(
end_date=end_date,
autorenew=autorenew,
status=status,
devices=devices,
whitelists=whitelists,
duration_days=duration_days,
plan_price_paid=plan_price_paid,
)
.returning(Subscription)
)
result = await session.execute(stmt)
await session.commit()
return result.scalar_one_or_none()

View File

@@ -0,0 +1,41 @@
from datetime import datetime, timezone
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.transactions import BalanceTransaction, BalanceTxType
class BalanceTXRepository:
async def create(
self,
session: AsyncSession,
user_id: int,
amount: int,
tx_type: BalanceTxType,
balance_before: int,
balance_after: int,
description: str,
) -> BalanceTransaction:
created_at = datetime.now(timezone.UTC)
tx = BalanceTransaction(
user_id=user_id,
amount=amount,
tx_type=tx_type,
balance_before=balance_before,
balance_after=balance_after,
description=description,
created_at=created_at,
)
session.add(tx)
await session.commit()
return tx
async def get_transactions_by_id(
self, session: AsyncSession, user_id: int, limit: int = 50
) -> list[BalanceTransaction]:
stmt = select(BalanceTransaction).where(BalanceTransaction.user_id == user_id).limit(limit)
res = await session.execute(stmt)
return list(res.scalars().all())

View File

@@ -1,7 +1,10 @@
import datetime
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from db.models import User
from db.models.transactions import BalanceTransaction, BalanceTxType
from schemas.users import NewUserDTO
@@ -61,12 +64,60 @@ class UserRepository:
return user
async def increase_user_balance(
self, session: AsyncSession, user_id: int, amount: int
self,
session: AsyncSession,
user_id: int,
amount: int,
tx_type: BalanceTxType,
description: str,
) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id)
if not user:
return None
created_at = datetime.datetime.now(datetime.UTC)
transaction = BalanceTransaction(
user_id=user_id,
amount=amount,
tx_type=tx_type,
balance_before=user.balance,
balance_after=user.balance + amount,
description=description,
created_at=created_at,
)
user.balance += amount
session.add(transaction)
await session.commit()
return user
async def decrease_user_balance(
self,
session: AsyncSession,
user_id: int,
amount: int,
tx_type: BalanceTxType,
description: str,
) -> User | None:
user = await self.get_user_by_id(session, user_id=user_id)
if not user or user.balance < amount:
return None
created_at = datetime.datetime.now(datetime.timezone.UTC)
transaction = BalanceTransaction(
user_id=user_id,
amount=amount,
tx_type=tx_type,
balance_before=user.balance,
balance_after=user.balance + amount,
description=description,
created_at=created_at,
)
user.balance -= amount
session.add(transaction)
await session.commit()
return user

View File

@@ -8,3 +8,6 @@ python-dotenv
aiohttp-socks
alembic
remnawave>=2.6.1
fastapi>=0.100.0
uvicorn[standard]>=0.24.0
python-multipart

View File

@@ -4,6 +4,7 @@ from remnawave import RemnawaveSDK
from misc.pally import PallyClient
from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository
from services.billing_service import BillingService
from services.user_service import UserService
@@ -13,7 +14,16 @@ from services.user_service import UserService
class DependenciesDTO:
user_repository: UserRepository
user_service: UserService
rw_sdk: RemnawaveSDK
bills_repository: BillsRepository
billing_service: BillingService
subscriptions_repository: SubscriptionRepository
pally_client: PallyClient
rw_sdk: RemnawaveSDK
@dataclass
class APIDependenciesDTO:
user_repository: UserRepository
user_service: UserService
bills_repository: BillsRepository
billing_service: BillingService

View File

@@ -1,4 +1,4 @@
from enum import Enum
from enum import Enum, StrEnum
from pydantic import BaseModel
@@ -17,3 +17,13 @@ class SubscriptionPlan(BaseModel):
duration: SubscriptionDuration = SubscriptionDuration.MONTH
whitelists: bool = False
discount: float = 1
class SubscriptionStatus(StrEnum):
EXPIRED = "expired"
ACTIVE = "active"
class InternalSquads(StrEnum):
WHITELISTS = settings.whitelist_squad
DEFAULT = settings.general_squad

View File

@@ -4,7 +4,7 @@ from datetime import UTC, datetime, timedelta
from uuid import UUID
from remnawave import RemnawaveSDK
from remnawave.models import UpdateUserRequestDto
from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto
logger = logging.getLogger(__name__)
@@ -319,3 +319,33 @@ async def update_squads(
exc,
)
return False
async def create_user(
sdk: RemnawaveSDK,
username: str,
telegram_id: int,
expire_at: datetime,
hwid_device_limit: int | None,
squad_uuids: list[str],
) -> RWUserInfo | None:
"""
Создать нового пользователя в Remnawave.
hwid_device_limit=None → без ограничений.
Возвращает RWUserInfo при успехе, None при ошибке.
"""
try:
dto = CreateUserRequestDto(
username=username,
telegram_id=telegram_id,
expire_at=expire_at,
hwid_device_limit=hwid_device_limit,
active_internal_squads=[UUID(s) for s in squad_uuids] if squad_uuids else None,
)
result = await sdk.users.create_user(dto)
if result is None:
return None
return _parse_user(result)
except Exception as exc:
logger.warning("Failed to create user username=%s: %s", username, exc)
return None

View File

@@ -1,19 +1,22 @@
import datetime
import logging
from aiogram.types import Message
from aiogram.types import Message, User as TelegramUser
from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession
from db.models.users import User
from misc import utils
from repositories import UserRepository
from schemas.users import NewUserDTO
from services.rw import RWUserInfo, get_user_by_telegram_id
from texts import (
from bot.texts import (
NO_SUBSCRIPTION,
NOTHING_PLACEHOLDER,
SUBSCRIPTION_INFORMATION,
)
from db.models.users import User
from misc import utils
from repositories import UserRepository
from repositories.subscriptions import SubscriptionRepository
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
from schemas.users import NewUserDTO
from services import rw
logger = logging.getLogger(__name__)
@@ -22,12 +25,15 @@ class UserServiceError(Exception): ...
class UserService:
def __init__(self, user_repository: UserRepository) -> None:
def __init__(
self, user_repository: UserRepository, subscription_repository: SubscriptionRepository
) -> None:
self.user_repository = user_repository
self.subscription_repository = subscription_repository
@staticmethod
def format_subscription_message(
rw_user: RWUserInfo | None,
rw_user: rw.RWUserInfo | None,
link: str | None = None,
):
if rw_user is None:
@@ -62,7 +68,7 @@ class UserService:
if user:
return user
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None
referal = int(referal) if str(referal).isdigit() and user_id != int(referal) else None
@@ -72,7 +78,7 @@ class UserService:
async def update_user_link(
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
) -> User | None:
rw_user = await get_user_by_telegram_id(rw_sdk, user_id)
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user_id)
user = await self.user_repository.get_user_by_id(session, user_id)
@@ -97,3 +103,98 @@ class UserService:
logger.critical("failed to send a message to user %d.", user.id, exc_info=True)
return sent
async def buy_subscription(
self,
session: AsyncSession,
subscription: SubscriptionPlan,
rw_sdk: RemnawaveSDK,
user: TelegramUser,
):
duration = utils.convert_duration_to_timedelta(subscription.duration)
squads = [InternalSquads.DEFAULT]
if subscription.whitelists:
squads.append(InternalSquads.WHITELISTS)
## If subscription exists, add.
rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id)
if rw_user:
db_subscription = await self.subscription_repository.get_subscription_by_user_id(
session, user.id
)
if db_subscription:
db_subscription = await self.subscription_repository.update_sub(
session,
user.id,
end_date=rw_user.expire_at + duration,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
else:
db_subscription = await self.subscription_repository.create_subscription(
session,
user_id=user.id,
end_date=rw_user.expire_at + duration,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
await rw.add_days(rw_sdk, rw_user.uuid, duration.days)
await rw.update_squads(rw_sdk, rw_user.uuid, squads)
await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices)
await self.update_user_link(session, user.id, rw_sdk)
return db_subscription
# Create
expire = datetime.datetime.now(datetime.UTC) + duration
username = "tg_" + str(user.username or user.id)
rw_user = await rw.create_user(
rw_sdk,
username=username,
telegram_id=user.id,
expire_at=expire,
hwid_device_limit=subscription.devices,
squad_uuids=squads,
)
db_subscription = await self.subscription_repository.get_subscription_by_user_id(
session, user.id
)
if db_subscription:
db_subscription = await self.subscription_repository.update_sub(
session,
user.id,
end_date=expire,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
else:
db_subscription = await self.subscription_repository.create_subscription(
session,
user_id=user.id,
end_date=expire,
autorenew=False,
status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices),
whitelists=subscription.whitelists,
plan_price_paid=utils.calculate_price(subscription),
duration_days=duration.days,
)
await self.update_user_link(session, user.id, rw_sdk)
return db_subscription

View File

@@ -0,0 +1,81 @@
"""
Симулятор вебхука Pally для локального тестирования.
Использование:
python tests/send_test_webhook.py --user-id 123456789 --amount 300 --bill-id 1
Предварительно:
- API должен быть запущен: uvicorn api.main:app --port 8000
- Пользователь с --user-id должен существовать в БД
- Счёт с --bill-id должен существовать в БД и принадлежать этому пользователю
"""
import argparse
import hashlib
import os
import httpx
from dotenv import load_dotenv
load_dotenv()
def calculate_signature(
payment_id: str,
status: str,
req_amount: str,
currency: str,
order_id: str,
pally_token: str,
) -> str:
raw = f"{payment_id}{status}{req_amount}{currency}{order_id}{pally_token}"
md5 = hashlib.md5(raw.encode()).hexdigest()
return hashlib.sha1(md5.encode()).hexdigest()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--user-id", type=int, required=True)
parser.add_argument("--amount", type=int, required=True)
parser.add_argument("--bill-id", type=int, required=True)
parser.add_argument("--host", default="http://127.0.0.1:8000")
parser.add_argument(
"--status",
default="SUCCESS",
choices=["SUCCESS", "FAIL", "PROCESS"],
help="Статус платежа (по умолчанию SUCCESS)",
)
args = parser.parse_args()
pally_token = os.environ["PALLY_TOKEN"]
# Эти поля могут быть любыми строками — вебхук их не использует содержательно,
# только включает в подпись. Главное чтобы совпадали с тем, что пойдёт в подпись.
payment_id = "test_payment_001"
bill_id = f"test_bill_{args.bill_id}"
currency = "RUB"
order_id = str(args.bill_id) # order_id == id счёта в нашей БД
signature = calculate_signature(
payment_id, args.status, str(args.amount), currency, order_id, pally_token
)
payload = {
"id": payment_id,
"bill_id": bill_id,
"status": args.status,
"req_amount": str(args.amount),
"currency": currency,
"order_id": order_id,
"signature": signature,
}
print(f"{args.host}/checkout/pally/callback")
print(f"Отправляю вебхук: {payload}\n")
response = httpx.post(f"{args.host}/checkout/pally/callback", data=payload)
print(f"Статус ответа: {response.status_code}")
print(f"Тело ответа: {response.text}")
if __name__ == "__main__":
main()