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.
This commit is contained in:
2026-04-27 21:33:25 +07:00
parent 049f31118d
commit 287f8eebda
34 changed files with 3401 additions and 53 deletions

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")

View File

@@ -2,11 +2,13 @@ from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from aiogram import Bot from aiogram import Bot
from aiogram.client.default import DefaultBotProperties
from fastapi import FastAPI from fastapi import FastAPI
from api.routes import routers from api.routes import routers
from config import settings from config import settings
from repositories.bills import BillsRepository from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from schemas.di import APIDependenciesDTO from schemas.di import APIDependenciesDTO
from services.billing_service import BillingService from services.billing_service import BillingService
@@ -16,13 +18,15 @@ from services.user_service import UserService
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator: async def lifespan(app: FastAPI) -> AsyncGenerator:
# Bot Init # Bot Init
bot = Bot(token=settings.bot_token) bot_default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=bot_default)
app.state.bot = bot app.state.bot = bot
user_repository = UserRepository() user_repository = UserRepository()
user_service = UserService(user_repository)
bills_repository = BillsRepository() bills_repository = BillsRepository()
subscription_repository = SubscriptionRepository()
user_service = UserService(user_repository, subscription_repository=subscription_repository)
bills_service = BillingService( bills_service = BillingService(
user_repository=user_repository, bills_repository=bills_repository user_repository=user_repository, bills_repository=bills_repository
) )

View File

@@ -10,6 +10,8 @@ from sqlalchemy.ext.asyncio import AsyncSession
from api.core.deps import get_bot, get_db, get_deps from api.core.deps import get_bot, get_db, get_deps
from api.core.notifications import successful_payment_notification from api.core.notifications import successful_payment_notification
from config import settings from config import settings
from db.models.bills import DepositStatus
from db.models.transactions import BalanceTxType
from misc.pally import BillStatus from misc.pally import BillStatus
from schemas.di import APIDependenciesDTO from schemas.di import APIDependenciesDTO
@@ -56,16 +58,38 @@ async def pally_callback(
logger.critical("Bill %s is not found in db", bill_id) logger.critical("Bill %s is not found in db", bill_id)
return "OK" return "OK"
if bill.status != DepositStatus.PENDING:
logger.warning("bill=%s is already paid according to the db", bill.id)
return "OK"
try: try:
amount = int(req_amount) amount = int(req_amount)
await deps.user_repository.increase_user_balance(session, bill.creator_id, amount=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) await successful_payment_notification(bot, bill.creator_id, amount)
referal = bill.user.referal_id 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: if referal is not None:
referal_amount = math.floor(amount * (settings.referal_bonus / 100)) referal_amount = math.floor(amount * (settings.referal_bonus / 100))
await deps.user_repository.increase_user_balance(session, referal, referal_amount) 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) await successful_payment_notification(bot, referal, referal_amount)
except Exception: except Exception:
@@ -75,4 +99,8 @@ async def pally_callback(
bill.creator_id, bill.creator_id,
) )
await deps.bills_repository.update_bill_status(
session, int(bill.id), status=DepositStatus.SUCCESS
)
return "OK" return "OK"

View File

@@ -2,6 +2,7 @@ from .ads import router as ads_router
from .billing import router as billing_router from .billing import router as billing_router
from .buy import router as buy_router from .buy import router as buy_router
from .common import router as common_router from .common import router as common_router
from .deposit import router as deposit_router
from .menus import router as menus_router from .menus import router as menus_router
from .prehandling import router as prehandling_router from .prehandling import router as prehandling_router
from .referals import router as referal_router from .referals import router as referal_router
@@ -11,6 +12,7 @@ routers = [
prehandling_router, prehandling_router,
referal_router, referal_router,
billing_router, billing_router,
deposit_router,
menus_router, menus_router,
ads_router, ads_router,
support_router, support_router,

View File

@@ -29,6 +29,7 @@ async def pally_init(
await state.clear() await state.clear()
ctx.provider = BillingProviders.PALLY ctx.provider = BillingProviders.PALLY
ctx.amount = int(ctx.amount)
await cb.message.edit_text( await cb.message.edit_text(
BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title()) BILL_CREATION.format(amount=ctx.amount, provider=ctx.provider.value.title())

View File

@@ -4,31 +4,29 @@ import math
from aiogram import F, Router from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import ( from bot.keyboards.client import deposit_kb, devices_selector, duration_selector, return_to_menu
devices_selector, from bot.states.buy import SubscriptionStorage
duration_selector,
payment_gateways,
return_to_menu,
)
from bot.states.buy import BillingStorage, SubscriptionStorage
from bot.texts import ( from bot.texts import (
CALLBACK_FALLBACK, CALLBACK_FALLBACK,
CHECKOUT_PAYMENT_CHOICE,
CONTEXT_REINITIATED, CONTEXT_REINITIATED,
INSUFFICIENT_FUNDS,
NOTHING_PLACEHOLDER, NOTHING_PLACEHOLDER,
STANDARD_FALLBACK, STANDARD_FALLBACK,
SUBSCRIPTION_DEVICE_SELECTOR, SUBSCRIPTION_DEVICE_SELECTOR,
SUBSCRIPTION_DURATION_SELECTOR, SUBSCRIPTION_DURATION_SELECTOR,
SUCCESSFULLY_CREATED_SUBSCRIPTION,
) )
from config import settings from config import settings
from db.models.transactions import BalanceTxType
from misc.utils import ( from misc.utils import (
calculate_price, calculate_price,
convert_duration_to_int, convert_duration_to_int,
convert_int_to_duration, convert_int_to_duration,
get_discount, get_discount,
) )
from schemas.billing import BillingContext from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan from schemas.subscriptions import SubscriptionPlan
router = Router() router = Router()
@@ -157,7 +155,9 @@ async def select_duration(cb: CallbackQuery, state: FSMContext):
@router.callback_query(SubscriptionStorage.duration, F.data == "confirm") @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() data = await state.get_data()
await state.clear() 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) await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return return
await cb.message.edit_text( amount = calculate_price(ctx)
CHECKOUT_PAYMENT_CHOICE, user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
reply_markup=payment_gateways,
) # FIXME: temp solution that's incredibly stupid
await state.set_state(BillingStorage.pending) if user.balance < amount:
billing_context = BillingContext(amount=calculate_price(ctx)) await cb.message.edit_text(
await state.set_data({"ctx": billing_context}) 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)

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

@@ -34,7 +34,9 @@ async def fetch_referal(
) )
await msg.answer( 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, 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) user = await deps.user_service.add_user(session, user_id=msg.from_user.id, rw_sdk=deps.rw_sdk)
await msg.answer( 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, 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) user = await deps.user_repository.get_user_by_id(session, cb.from_user.id)
await cb.message.edit_text( 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, 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) user = await deps.user_service.update_user_link(session, cb.from_user.id, deps.rw_sdk)
await cb.answer("") await cb.answer("")
await cb.message.edit_text( 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, reply_markup=main_menu,
) )

View File

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

View File

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

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 = ( MAIN_MENU = (
str(MALENIA_LOGO) str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n" + " <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>" + f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
) )
@@ -162,7 +163,7 @@ SUBSCRIPTION_DURATION_SELECTOR = (
WHITELISTS_ALREADY_ON = "Белые списки уже активны." WHITELISTS_ALREADY_ON = "Белые списки уже активны."
CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки." CHECKOUT_PAYMENT_CHOICE = "<b>💳 Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>"
# ============================================================ # ============================================================
# BILLING # BILLING
@@ -180,6 +181,13 @@ BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK 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

@@ -29,5 +29,8 @@ class Settings(BaseSettings):
whitelist_cost: int = Field(100, alias="WHITELIST_COST") whitelist_cost: int = Field(100, alias="WHITELIST_COST")
whitelist_device_threshold: int = Field(6, alias="WHITELIST_DEVICE_THRESHOLD") 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 settings = Settings() # type: ignore

View File

@@ -1,4 +1,6 @@
from .bills import Bill from .bills import Bill
from .subscriptions import Subscription
from .transactions import BalanceTransaction
from .users import User 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 typing import TYPE_CHECKING
from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey from sqlalchemy import BIGINT, INTEGER, Enum, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from db.base import Base from db.base import Base
from misc.pally import BillStatus
if TYPE_CHECKING: if TYPE_CHECKING:
from db.models.users import User from db.models.users import User
class DepositStatus(StrEnum):
PENDING = "pending"
SUCCESS = "success"
FAILED = "failed"
EXPIRED = "expired"
class Bill(Base): class Bill(Base):
__tablename__ = "bills" __tablename__ = "bills"
@@ -18,8 +25,10 @@ class Bill(Base):
) )
creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False) creator_id: Mapped[int] = mapped_column(BIGINT, ForeignKey("users.id"), nullable=False)
amount: Mapped[int] = mapped_column(INTEGER, nullable=False) amount: Mapped[int] = mapped_column(INTEGER, nullable=False)
status: Mapped[BillStatus] = mapped_column( status: Mapped[DepositStatus] = mapped_column(
Enum(BillStatus), nullable=False, default=BillStatus.NEW Enum(DepositStatus, name="depositstatus", values_callable=lambda x: [e.value for e in x]),
nullable=False,
default=DepositStatus.PENDING,
) )
user: Mapped["User"] = relationship( 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: if TYPE_CHECKING:
from db.models.bills import Bill from db.models.bills import Bill
from db.models.subscriptions import Subscription
from db.models.transactions import BalanceTransaction
class User(Base): class User(Base):
@@ -22,3 +24,9 @@ class User(Base):
back_populates="user", back_populates="user",
cascade="all, delete", 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 image: postgres:16-alpine
container_name: maleniabot_db container_name: maleniabot_db
environment: environment:
TZ: UTC
POSTGRES_USER: ${POSTGRES_USER:-malenia} POSTGRES_USER: ${POSTGRES_USER:-malenia}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-malenia_password}
POSTGRES_DB: ${POSTGRES_DB:-malenia_db} POSTGRES_DB: ${POSTGRES_DB:-malenia_db}
@@ -28,6 +29,7 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN} BOT_TOKEN: ${BOT_TOKEN}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db} POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-} PROXY: ${PROXY:-}
@@ -51,6 +53,7 @@ services:
postgres: postgres:
condition: service_healthy condition: service_healthy
environment: environment:
TZ: UTC
BOT_TOKEN: ${BOT_TOKEN} BOT_TOKEN: ${BOT_TOKEN}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db} POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-} PROXY: ${PROXY:-}

View File

@@ -231,5 +231,5 @@ def get_subscription_link(short_uuid: str):
return settings.subscription_url + "/" + short_uuid return settings.subscription_url + "/" + short_uuid
def build_main_menu_text(link: str) -> str: def build_main_menu_text(link: str, balance: int) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link) 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

@@ -1,7 +1,7 @@
from sqlalchemy import select from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models.bills import Bill, BillStatus from db.models.bills import Bill, DepositStatus
class BillsRepository: class BillsRepository:
@@ -17,9 +17,15 @@ class BillsRepository:
*, *,
creator_id: int, creator_id: int,
amount: int, amount: int,
status: BillStatus | None = BillStatus.NEW, status: DepositStatus | None = DepositStatus.PENDING,
) -> Bill: ) -> Bill:
bill = Bill(creator_id=creator_id, amount=amount, status=status) bill = Bill(creator_id=creator_id, amount=amount, status=status)
session.add(bill) session.add(bill)
await session.commit() await session.commit()
return bill 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 import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from db.models import User from db.models import User
from db.models.transactions import BalanceTransaction, BalanceTxType
from schemas.users import NewUserDTO from schemas.users import NewUserDTO
@@ -61,12 +64,60 @@ class UserRepository:
return user return user
async def increase_user_balance( 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 | None:
user = await self.get_user_by_id(session, user_id=user_id) user = await self.get_user_by_id(session, user_id=user_id)
if not user: if not user:
return None 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 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() await session.commit()
return user return user

View File

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

View File

@@ -1,4 +1,4 @@
from enum import Enum from enum import Enum, StrEnum
from pydantic import BaseModel from pydantic import BaseModel
@@ -17,3 +17,13 @@ class SubscriptionPlan(BaseModel):
duration: SubscriptionDuration = SubscriptionDuration.MONTH duration: SubscriptionDuration = SubscriptionDuration.MONTH
whitelists: bool = False whitelists: bool = False
discount: float = 1 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 uuid import UUID
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from remnawave.models import UpdateUserRequestDto from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -319,3 +319,33 @@ async def update_squads(
exc, exc,
) )
return False 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,6 +1,7 @@
import datetime
import logging import logging
from aiogram.types import Message from aiogram.types import Message, User as TelegramUser
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,8 +13,10 @@ from bot.texts import (
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
from repositories.subscriptions import SubscriptionRepository
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
from schemas.users import NewUserDTO from schemas.users import NewUserDTO
from services.rw import RWUserInfo, get_user_by_telegram_id from services import rw
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -22,12 +25,15 @@ class UserServiceError(Exception): ...
class UserService: 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.user_repository = user_repository
self.subscription_repository = subscription_repository
@staticmethod @staticmethod
def format_subscription_message( def format_subscription_message(
rw_user: RWUserInfo | None, rw_user: rw.RWUserInfo | None,
link: str | None = None, link: str | None = None,
): ):
if rw_user is None: if rw_user is None:
@@ -62,7 +68,7 @@ class UserService:
if user: if user:
return 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 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 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( async def update_user_link(
self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK self, session: AsyncSession, user_id: int, rw_sdk: RemnawaveSDK
) -> User | None: ) -> 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) 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) logger.critical("failed to send a message to user %d.", user.id, exc_info=True)
return sent 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()