diff --git a/alembic/versions/b2ecec0ef260_plan_specifications_added.py b/alembic/versions/b2ecec0ef260_plan_specifications_added.py new file mode 100644 index 0000000..abca726 --- /dev/null +++ b/alembic/versions/b2ecec0ef260_plan_specifications_added.py @@ -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 ### diff --git a/alembic/versions/e6541f81f7de_replace_billstatus_with_depositstatus.py b/alembic/versions/e6541f81f7de_replace_billstatus_with_depositstatus.py new file mode 100644 index 0000000..5d4c570 --- /dev/null +++ b/alembic/versions/e6541f81f7de_replace_billstatus_with_depositstatus.py @@ -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 ### diff --git a/alembic/versions/e8da1e4516fa_add_subscription_table.py b/alembic/versions/e8da1e4516fa_add_subscription_table.py new file mode 100644 index 0000000..75f12ad --- /dev/null +++ b/alembic/versions/e8da1e4516fa_add_subscription_table.py @@ -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 ### diff --git a/alembic/versions/f0668f8e7310_fix_balancetxtype_to_lowercase.py b/alembic/versions/f0668f8e7310_fix_balancetxtype_to_lowercase.py new file mode 100644 index 0000000..ac4e642 --- /dev/null +++ b/alembic/versions/f0668f8e7310_fix_balancetxtype_to_lowercase.py @@ -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") + diff --git a/api/main.py b/api/main.py index e1f38ae..d1bb6f6 100644 --- a/api/main.py +++ b/api/main.py @@ -2,11 +2,13 @@ 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 @@ -16,13 +18,15 @@ from services.user_service import UserService @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncGenerator: # 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 user_repository = UserRepository() - user_service = UserService(user_repository) - 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 ) diff --git a/api/routes/pally.py b/api/routes/pally.py index 500b77a..4df6464 100644 --- a/api/routes/pally.py +++ b/api/routes/pally.py @@ -10,6 +10,8 @@ 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 @@ -56,16 +58,38 @@ async def pally_callback( 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) - 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) - 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: 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) except Exception: @@ -75,4 +99,8 @@ async def pally_callback( bill.creator_id, ) + await deps.bills_repository.update_bill_status( + session, int(bill.id), status=DepositStatus.SUCCESS + ) + return "OK" diff --git a/bot/handlers/__init__.py b/bot/handlers/__init__.py index 867b717..1af2023 100644 --- a/bot/handlers/__init__.py +++ b/bot/handlers/__init__.py @@ -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, diff --git a/bot/handlers/billing.py b/bot/handlers/billing.py index a6e489e..e58ca91 100644 --- a/bot/handlers/billing.py +++ b/bot/handlers/billing.py @@ -29,6 +29,7 @@ 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()) diff --git a/bot/handlers/buy.py b/bot/handlers/buy.py index 4068594..6653a7c 100644 --- a/bot/handlers/buy.py +++ b/bot/handlers/buy.py @@ -4,31 +4,29 @@ 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 bot.keyboards.client import ( - devices_selector, - duration_selector, - payment_gateways, - return_to_menu, -) -from bot.states.buy import BillingStorage, SubscriptionStorage +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, - CHECKOUT_PAYMENT_CHOICE, 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 router = Router() @@ -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) diff --git a/bot/handlers/deposit.py b/bot/handlers/deposit.py new file mode 100644 index 0000000..f83703b --- /dev/null +++ b/bot/handlers/deposit.py @@ -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}) diff --git a/bot/handlers/menus.py b/bot/handlers/menus.py index 80b743e..43739a4 100644 --- a/bot/handlers/menus.py +++ b/bot/handlers/menus.py @@ -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, ) diff --git a/bot/keyboards/client.py b/bot/keyboards/client.py index 2a0dc6d..a06cf79 100644 --- a/bot/keyboards/client.py +++ b/bot/keyboards/client.py @@ -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) diff --git a/bot/main.py b/bot/main.py index 4634da9..30532b9 100644 --- a/bot/main.py +++ b/bot/main.py @@ -12,6 +12,7 @@ from config import settings from db.session import async_session 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)) diff --git a/bot/states/deposit.py b/bot/states/deposit.py new file mode 100644 index 0000000..fdb1405 --- /dev/null +++ b/bot/states/deposit.py @@ -0,0 +1,5 @@ +from aiogram.fsm.state import State, StatesGroup + + +class DepositStorage(StatesGroup): + amount = State() diff --git a/bot/texts.py b/bot/texts.py index 8cb8814..db93690 100644 --- a/bot/texts.py +++ b/bot/texts.py @@ -94,7 +94,8 @@ NOTHING_PLACEHOLDER = "∅" MAIN_MENU = ( str(MALENIA_LOGO) + " Malenia — {quote}\n\n" - + f"{MALENIA_COINS} Личный кабинет\n" + + f"{MALENIA_HEART} Личный кабинет\n" + + f"{MALENIA_COINS} Ваш баланс: {{balance}}₽\n" + f"{MALENIA_LINK} Ссылка на подписку: {{link}}" ) @@ -162,7 +163,7 @@ SUBSCRIPTION_DURATION_SELECTOR = ( WHITELISTS_ALREADY_ON = "Белые списки уже активны." -CHECKOUT_PAYMENT_CHOICE = "Пока что без автоплатежа. Нажмите кнопку ниже и обратитесь в поддержку для оплаты и активации подписки." +CHECKOUT_PAYMENT_CHOICE = "💳 Пополнение баланса:\n\n❓ Выберите способ оплаты:" # ============================================================ # BILLING @@ -180,6 +181,13 @@ BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK +INSUFFICIENT_FUNDS = "⚠️ Недостаточно средств для проведения операции.\n\n💰 Текущий баланс: {balance}₽\n💸 Требуется: {amount}₽" +SUCCESSFULLY_CREATED_SUBSCRIPTION = ( + "💚 Ваша подписка успешно оплачена! Ссылка на добавления доступна в главном меню." +) + +DEPOSIT_AMOUNT = "💸 Укажите сумму пополнения" +DEPOSIT_AMOUNT_FALLBACK = "❌ Сумма пополнения указана неверно, повторите попытку." # ============================================================ # ПОДДЕРЖКА diff --git a/config.py b/config.py index c95f557..469c062 100644 --- a/config.py +++ b/config.py @@ -29,5 +29,8 @@ class Settings(BaseSettings): 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 diff --git a/db/models/__init__.py b/db/models/__init__.py index aa4646f..913c817 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -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"] diff --git a/db/models/bills.py b/db/models/bills.py index 3f57dba..a9da8ac 100644 --- a/db/models/bills.py +++ b/db/models/bills.py @@ -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( diff --git a/db/models/subscriptions.py b/db/models/subscriptions.py new file mode 100644 index 0000000..0111914 --- /dev/null +++ b/db/models/subscriptions.py @@ -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") diff --git a/db/models/transactions.py b/db/models/transactions.py new file mode 100644 index 0000000..da1e16c --- /dev/null +++ b/db/models/transactions.py @@ -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") diff --git a/db/models/users.py b/db/models/users.py index e2458fa..bc218d3 100644 --- a/db/models/users.py +++ b/db/models/users.py @@ -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" + ) diff --git a/docker-compose.yml b/docker-compose.yml index 1b2e242..8f0219b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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} @@ -28,6 +29,7 @@ services: 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:-} @@ -51,6 +53,7 @@ services: 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:-} diff --git a/misc/utils.py b/misc/utils.py index 8eecb86..e4ed046 100644 --- a/misc/utils.py +++ b/misc/utils.py @@ -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) diff --git a/plans/FINANCE_REFINEMENT.md b/plans/FINANCE_REFINEMENT.md new file mode 100644 index 0000000..3a450f5 --- /dev/null +++ b/plans/FINANCE_REFINEMENT.md @@ -0,0 +1,1299 @@ +# 🏦 FINANCE_REFINEMENT — План рефинансирования системы управления подписками + +> **Цель документа:** пошаговый план перехода бота с фрагментарного состояния (баги + архитектурные +> пробелы) к надёжной, аудируемой, внутренней системе управления балансом и подписками. +> +> Каждая фаза приоритизирована по принципу **«сначала не ломать, затем улучшать»**. +> Фазы 1–2 устраняют критические баги, блокирующие корректную работу прямо сейчас. +> Фазы 3–4 закладывают фундамент модели данных, необходимый для всех последующих фаз. +> Фазы 5–7 добавляют новую функциональность поверх исправленной основы. + +--- + +## Содержание + +1. [Фаза 1 — Критические исправления бизнес-логики](#фаза-1) +2. [Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний](#фаза-2) +3. [Фаза 3 — Расширение модели данных](#фаза-3) +4. [Фаза 4 — Архитектурная согласованность (DI)](#фаза-4) +5. [Фаза 5 — Система автопродления подписок](#фаза-5) +6. [Фаза 6 — Реферальная система: учёт покупок за баланс](#фаза-6) +7. [Фаза 7 — Синхронизация состояния с Remnawave](#фаза-7) + +--- + + +## Фаза 1 — Критические исправления бизнес-логики + +### Цель + +Устранить баги, при которых базовый флоу покупки подписки **молча ломается**: подписка в Remnawave +не продлевается, лимиты устройств не обновляются, а кнопка пополнения баланса вообще не работает. +Все четыре проблемы этой фазы не требуют изменений схемы БД и реализуются правками в существующих +файлах. + +**Приоритет: НЕМЕДЛЕННО** — без этих исправлений основной бизнес-процесс не работает. + +--- + +### 1.1 Bug 1 — Неправильный аргумент в `rw.add_days` + +**Файл:** `services/user_service.py`, метод `UserService.buy_subscription` + +**Проблема:** +```python +# БЫЛО (неправильно): +await rw.add_days(rw_sdk, user.id, duration.days) +# ↑ +# user.id — это Telegram ID (int), например 123456789 +# rw.add_days() ожидает UUID-строку вида "550e8400-e29b-41d4-a716-446655440000" +# Внутри функция вызывает sdk.users.get_user_by_uuid(uuid=user_uuid), +# что всегда завершается ошибкой, которая перехватывается и логируется как warning. +# В итоге срок подписки в Remnawave НИКОГДА не продлевается. +``` + +**Исправление:** +В ветке `if rw_user:` функции `buy_subscription` у нас уже есть объект `rw_user` типа `RWUserInfo`, +который содержит поле `uuid` — корректный UUID пользователя в Remnawave. Нужно использовать его: + +```python +# СТАЛО (правильно): +await rw.add_days(rw_sdk, rw_user.uuid, duration.days) +# ↑ +# rw_user.uuid — строковый UUID из Remnawave, например "550e8400-..." +``` + +**Конкретные шаги:** +1. Открыть `services/user_service.py`. +2. Найти строку `await rw.add_days(rw_sdk, user.id, duration.days)` в ветке `if rw_user:`. +3. Заменить `user.id` на `rw_user.uuid`. +4. Убедиться, что переменная `rw_user` объявлена выше этой строки (она уже есть — + `rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id)`). + +--- + +### 1.2 Bug 2 — Лимит HWID-устройств не обновляется при продлении + +**Файл:** `services/user_service.py`, метод `UserService.buy_subscription`, ветка `if rw_user:` + +**Проблема:** +При продлении существующей подписки вызывается только `rw.add_days(...)`. Если пользователь менял +количество устройств (например, покупал 6 вместо прежних 3), `hwid_device_limit` в Remnawave +остаётся старым. Функция `rw.set_hwid_limit` уже существует в `services/rw.py`, но не вызывается. + +**Исправление:** +Добавить вызов `rw.set_hwid_limit` сразу после `rw.add_days` в ветке продления: + +```python +# В ветке if rw_user: после вызова rw.add_days +await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) +``` + +**Конкретные шаги:** +1. Открыть `services/user_service.py`. +2. В ветке `if rw_user:` найти строку `await rw.add_days(rw_sdk, rw_user.uuid, duration.days)`. +3. Добавить **следующей строкой**: + ```python + await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) + ``` +4. Параметр `subscription.devices` уже доступен — это поле объекта `SubscriptionPlan`. + +--- + +### 1.3 Bug 3 — Squad (Whitelist) не обновляется при продлении + +**Файл:** `services/user_service.py`, метод `UserService.buy_subscription`, ветка `if rw_user:` + +**Проблема:** +При продлении squads пользователя в Remnawave не обновляются. Если пользователь добавил +опцию `whitelists=True` при продлении, а до этого у него был только `DEFAULT` squad, он так и +останется без whitelist squad. Функция `rw.update_squads` уже существует в `services/rw.py`. + +**Исправление:** +Сформировать актуальный список squad UUID на основе `subscription.whitelists` и вызвать +`rw.update_squads` при продлении: + +```python +# В ветке if rw_user: после rw.set_hwid_limit +new_squads = [InternalSquads.DEFAULT] +if subscription.whitelists: + new_squads.append(InternalSquads.WHITELISTS) +await rw.update_squads(rw_sdk, rw_user.uuid, [s for s in new_squads]) +``` + +**Конкретные шаги:** +1. Открыть `services/user_service.py`. +2. Убедиться, что `InternalSquads` уже импортирован (он есть в `schemas.subscriptions`). +3. В ветке `if rw_user:`, после `rw.set_hwid_limit`, добавить три строки выше. +4. Обратить внимание: `InternalSquads` — это `StrEnum`, значения которого являются UUID-строками + из `config.py` (`general_squad`, `whitelist_squad`). Передавать их в `update_squads` напрямую + как строки корректно. + +--- + +### 1.4 Bug 4 — Отсутствует обработчик кнопки «💸 Пополнить» + +**Файлы:** +- Создать или расширить: `bot/handlers/deposit.py` (новый файл) +- Зарегистрировать роутер: `bot/handlers/__init__.py` +- Клавиатура уже есть: `bot/keyboards/client.py` → `deposit_kb`, `payment_gateways` + +**Проблема:** +В `bot/keyboards/client.py` определены кнопки с `callback_data="deposit"` (в `main_menu` и в +`deposit_kb`). Ни в одном из роутеров (`buy.py`, `billing.py`, `menus.py`, `common.py`, +`referals.py`, `support.py`, `ads.py`) нет обработчика `F.data == "deposit"`. При нажатии кнопки +Telegram просто показывает крутящееся колёсико, бот не реагирует. + +**Исправление:** +Создать файл `bot/handlers/deposit.py` с роутером, который: +1. Перехватывает `F.data == "deposit"`. +2. Отображает клавиатуру выбора платёжного провайдера (`payment_gateways`). +3. Устанавливает FSM-состояние `BillingStorage.pending` и сохраняет минимальную сумму + пополнения в контексте. + +```python +# bot/handlers/deposit.py + +import logging + +from aiogram import F, Router +from aiogram.fsm.context import FSMContext +from aiogram.types import CallbackQuery + +from bot.keyboards.client import payment_gateways +from bot.states.buy import BillingStorage +from schemas.billing import BillingContext + +router = Router() +logger = logging.getLogger(__name__) + +DEPOSIT_PROMPT = ( + "💳 Пополнение баланса\n\n" + "Выберите способ оплаты. После оплаты баланс будет зачислен автоматически." +) + + +@router.callback_query(F.data == "deposit") +async def deposit_init(cb: CallbackQuery, state: FSMContext): + await state.clear() + + # Устанавливаем состояние ожидания выбора провайдера. + # BillingContext.amount = 0 — сумму пользователь выберет или введёт на следующем шаге. + # Если в будущем добавить ввод произвольной суммы — добавить отдельный State. + billing_ctx = BillingContext(amount=0) + await state.set_state(BillingStorage.pending) + await state.set_data({"ctx": billing_ctx}) + + await cb.message.edit_text(DEPOSIT_PROMPT, reply_markup=payment_gateways) +``` + +**Конкретные шаги:** +1. Создать файл `bot/handlers/deposit.py` с содержимым выше. +2. В `bot/handlers/__init__.py` импортировать и зарегистрировать роутер `deposit.router`. + Пример: + ```python + from bot.handlers.deposit import router as deposit_router + # и добавить его в список роутеров вместе с остальными + ``` +3. Уточнить логику `BillingContext.amount`: если сейчас `billing.py` использует `ctx.amount` + для создания счёта, нужно либо добавить ввод суммы пользователем (State для ввода числа), + либо временно хардкодить минимальную сумму (например, 100₽). Это решение принимает команда. + +--- + + +## Фаза 2 — Финансовая безопасность: атомарность и защита от двойных списаний + +### Цель + +Гарантировать, что деньги пользователя не будут списаны без предоставления подписки +(Bug 5) и что одна покупка не спишет баланс дважды (Bug 6). Обе проблемы решаются +на уровне логики хендлера без изменения схемы БД. + +**Приоритет: ВЫСОКИЙ** — прямые финансовые потери для пользователей или для сервиса. + +--- + +### 2.1 Bug 5 — Отсутствие атомарности: деньги списываются до создания подписки + +**Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` + +**Проблема:** +```python +# ТЕКУЩИЙ КОД (опасный): +user = await deps.user_repository.decrease_user_balance(session, user.id, amount) +# ← Баланс уже списан. Если следующая строка упадёт — деньги потеряны. +await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user) +``` +Если Remnawave недоступен или `buy_subscription` выбрасывает исключение, баланс пользователя +уже уменьшен, а подписка не создана. + +**Исправление:** +Инвертировать порядок: сначала попытаться создать подписку, затем списать баланс. Если +создание подписки упало — ни баланс, ни подписка не затронуты. Если списание упало после +успешного создания подписки — выполнить компенсирующую транзакцию (вернуть деньги). + +```python +# ИСПРАВЛЕННЫЙ КОД в proceed_to_checkout: + +amount = calculate_price(ctx) +user = await deps.user_repository.get_user_by_id(session, cb.from_user.id) + +if user.balance < amount: + await cb.message.edit_text( + INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), + reply_markup=deposit_kb, + ) + return + +# 1. Сначала попытаться создать/продлить подписку в Remnawave. +# Если это упадёт — баланс не тронут, пользователь видит ошибку. +try: + await deps.user_service.buy_subscription(session, ctx, deps.rw_sdk, cb.from_user) +except Exception: + logger.exception( + "buy_subscription failed for user_id=%s, amount=%s. Balance NOT deducted.", + cb.from_user.id, + amount, + ) + await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) + return + +# 2. Только после успешного создания подписки — списать баланс. +deducted_user = await deps.user_repository.decrease_user_balance(session, user.id, amount) +if not deducted_user: + # Крайне редкий случай: подписка создана, но списание не прошло. + # Логируем как критическую ошибку для ручной reconciliation. + 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) +``` + +**Конкретные шаги:** +1. Открыть `bot/handlers/buy.py`. +2. В хендлере `proceed_to_checkout` переместить `decrease_user_balance` **после** вызова + `buy_subscription`, обернув `buy_subscription` в `try/except`. +3. Добавить импорт `logger = logging.getLogger(__name__)` если его нет (он уже есть). +4. Добавить текст `STANDARD_FALLBACK` в импорты из `bot.texts` (он уже импортирован). + +> **Примечание о полной атомарности:** Идеальное решение — использовать Postgres транзакцию +> с `SAVEPOINT` или паттерн «двухфазного коммита». Это реализуется в Фазе 3 после введения +> таблицы `BalanceTransaction`. До тех пор описанный выше подход кратно безопаснее текущего. + +--- + +### 2.2 Bug 6 — Двойное списание: нет защиты от повторных нажатий + +**Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` + +**Проблема:** +Если пользователь быстро нажмёт кнопку «Подтвердить» дважды (или отправит дублирующий +callback), два запроса попадут в хендлер почти одновременно. `decrease_user_balance` выполнится +дважды до того, как первый коммит завершится, и баланс спишется двойной суммой. + +**Исправление:** +Установить FSM-состояние `SubscriptionStorage.checkout` **в самом начале хендлера**, до любых +операций. Повторный callback не пройдёт, так как состояние уже изменилось и хендлер +`SubscriptionStorage.duration, F.data == "confirm"` не сработает. + +```python +# В начале proceed_to_checkout — ПЕРВЫМ ДЕЙСТВИЕМ: +@router.callback_query(SubscriptionStorage.duration, F.data == "confirm") +async def proceed_to_checkout( + cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO +): + data = await state.get_data() + # Немедленно переключаем состояние, чтобы заблокировать повторные нажатия. + await state.set_state(SubscriptionStorage.checkout) + + ctx: SubscriptionPlan | None = data.get("ctx") + if not ctx: + await state.clear() + await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) + return + + # ... далее вся логика checkout ... + + await state.clear() # Очистить состояние после успешного завершения +``` + +**Конкретные шаги:** +1. Открыть `bot/handlers/buy.py`. +2. В начале `proceed_to_checkout`, сразу после `data = await state.get_data()`, добавить + `await state.set_state(SubscriptionStorage.checkout)`. +3. Убедиться, что в `bot/states/buy.py` в классе `SubscriptionStorage` уже есть состояние + `checkout = State()` — оно есть, можно использовать. +4. В конце хендлера (как при успехе, так и при ошибке) вызывать `await state.clear()`. +5. Дополнительно: добавить `await cb.answer()` с `cache_time=5` при получении повторного + callback в состоянии `checkout`, чтобы убрать крутящийся индикатор у пользователя. + +--- + + +## Фаза 3 — Расширение модели данных + +### Цель + +Устранить структурные пробелы в схеме БД: добавить недостающие поля в `Subscription`, +создать таблицу аудита балансовых операций, отвязать модель `Bill` от Pally, и зафиксировать +денежную деноминацию. Эта фаза является **обязательным фундаментом** для Фазы 5 (автопродление) +и существенно упрощает будущую реализацию тарификации. + +**Приоритет: ВЫСОКИЙ** — без этих изменений невозможно реализовать автопродление и аудит. +Все задачи этой фазы требуют миграции Alembic. + +--- + +### 3.1 Gap 7 — Модель `Subscription` не хранит параметры плана + +**Файл:** `db/models/subscriptions.py` + +**Проблема:** +Таблица `subscriptions` содержит только `user_id`, `end_date`, `autorenew`, `status`. Без +хранения параметров активного плана невозможно: +- Автоматически продлить подписку (неизвестно сколько устройств и какой whitelist был) +- Рассчитать доплату при апгрейде плана (пропорциональный пересчёт) +- Показать пользователю «текущий план» без запроса к Remnawave + +**Исправление:** +Добавить в модель `Subscription` следующие поля: + +```python +# db/models/subscriptions.py — добавить поля: + +from sqlalchemy import INTEGER, TEXT, Numeric + +# Новые колонки: +devices: Mapped[int] = mapped_column(INTEGER, nullable=False, default=1) +# Количество HWID-устройств в плане. Нужно для автопродления. + +duration_months: Mapped[int] = mapped_column(INTEGER, nullable=False, default=1) +# Длительность последнего оплаченного периода в месяцах (1, 3, 6, 12). +# Нужно для автопродления — повторить ту же длительность. + +plan_price_paid: Mapped[int] = mapped_column(INTEGER, nullable=False, default=0) +# Сумма в рублях, фактически списанная за последнюю оплату. +# Нужна для аудита и расчёта пропорциональных доплат. + +whitelists: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) +# Включена ли опция «Обход белых списков» в текущем плане. +# Нужно для автопродления. + +rw_uuid: Mapped[str] = mapped_column(TEXT, nullable=True, unique=True) +# UUID пользователя в Remnawave. Хранить здесь, а не только в User.subscription_link, +# чтобы не парсить ссылку каждый раз. +# Nullable=True для обратной совместимости (заполняется при первой покупке/синхронизации). +``` + +**Конкретные шаги:** +1. Открыть `db/models/subscriptions.py`. +2. Добавить 5 новых колонок (см. выше) в класс `Subscription`. +3. Добавить недостающие импорты: `Boolean` уже есть, `INTEGER` — добавить, `TEXT` — добавить. +4. Обновить `repositories/subscriptions.py`: + - В `create_subscription`: принимать и сохранять `devices`, `duration_months`, + `plan_price_paid`, `whitelists`, `rw_uuid`. + - В `update_sub`: принимать и обновлять те же поля (добавить как keyword-only аргументы + с дефолтными значениями для обратной совместимости). +5. Обновить `services/user_service.py`, метод `buy_subscription`: + - Передавать `devices=subscription.devices`, `duration_months=convert_duration_to_int(subscription.duration)`, + `plan_price_paid=amount` (получать `amount` как параметр метода или вычислять внутри), + `whitelists=subscription.whitelists`, `rw_uuid=rw_user.uuid` (при продлении) или + `rw_user.uuid` (при создании из нового `rw_user`). +6. Сигнатура `buy_subscription` должна принять `amount: int` как параметр (его вычисляет + `proceed_to_checkout` из `calculate_price(ctx)` — передавать явно). +7. Создать миграцию: `alembic revision --autogenerate -m "add_plan_details_to_subscriptions"`. +8. Проверить и применить: `alembic upgrade head`. + +--- + +### 3.2 Gap 8 — Отсутствует таблица аудита балансовых операций + +**Файл:** создать `db/models/balance_transactions.py` + +**Проблема:** +Баланс изменяется через прямые `user.balance += amount` / `user.balance -= amount` без записи +истории. При спорной ситуации (пользователь говорит «у меня списали дважды») невозможно +проверить, что произошло. + +**Исправление:** +Создать таблицу `balance_transactions`: + +```python +# db/models/balance_transactions.py + +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) + # amount > 0 → приход (deposit, refund, referral) + # amount < 0 → расход (purchase) + + tx_type: Mapped[BalanceTxType] = mapped_column( + Enum(BalanceTxType), 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) + # Человекочитаемое описание: "Покупка: 3 уст., 1 мес.", "Депозит Pally #12345" и т.д. + + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=lambda: datetime.now() + ) + + user: Mapped["User"] = relationship("User", back_populates="balance_transactions") +``` + +**Конкретные шаги:** +1. Создать `db/models/balance_transactions.py` с содержимым выше. +2. В `db/models/users.py` добавить relationship: + ```python + from db.models.balance_transactions import BalanceTransaction + balance_transactions: Mapped[list["BalanceTransaction"]] = relationship( + "BalanceTransaction", back_populates="user", cascade="all, delete" + ) + ``` +3. В `db/models/__init__.py` экспортировать `BalanceTransaction` и `BalanceTxType`. +4. Создать `repositories/balance_transactions.py` с классом `BalanceTransactionRepository`: + - Метод `create(session, user_id, amount, tx_type, balance_before, balance_after, description)`. + - Метод `get_by_user_id(session, user_id, limit=50)` для истории. +5. Обновить `repositories/users.py` — в `increase_user_balance` и `decrease_user_balance` + добавить запись в `balance_transactions`. Для этого репозиторий пользователей должен + либо принимать `BalanceTransactionRepository` в параметрах метода, либо создавать запись + напрямую через `session.add(BalanceTransaction(...))`. +6. Обновить `api/routes/pally.py`: передавать `description="Депозит Pally bill_id={bill_id}"`, + `tx_type=BalanceTxType.DEPOSIT` при пополнении, и `tx_type=BalanceTxType.REFERRAL_BONUS` + при зачислении реферального бонуса. +7. Обновить `bot/handlers/buy.py` (`proceed_to_checkout`): при списании передавать + `tx_type=BalanceTxType.PURCHASE` и `description` с параметрами плана. +8. Создать и применить миграцию: `alembic revision --autogenerate -m "add_balance_transactions"`. + +--- + +### 3.3 Gap 12 — Модель `Bill` жёстко привязана к Pally + +**Файл:** `db/models/bills.py`, `misc/pally.py` + +**Проблема:** +`Bill.status` использует `BillStatus` из `misc.pally`: +```python +from misc.pally import BillStatus +``` +Это значит, что модель БД зависит от конкретного платёжного провайдера. При добавлении +нового провайдера (криптовалюты, ЮKassa и т.д.) возникнет конфликт статусов или придётся +менять схему. + +**Исправление:** +Ввести провайдеронезависимый `DepositStatus` и расширить `Bill` полем `provider`: + +1. Создать `schemas/billing.py` (или расширить существующий): + ```python + # Добавить в schemas/billing.py: + from enum import StrEnum + + class DepositStatus(StrEnum): + PENDING = "pending" # Счёт создан, ожидает оплаты + SUCCESS = "success" # Оплачен успешно + FAILED = "failed" # Истёк или отменён + EXPIRED = "expired" # Истёк по таймауту + + class DepositProvider(StrEnum): + PALLY = "pally" + MANUAL = "manual" # Ручное пополнение администратором + ``` + +2. Обновить `db/models/bills.py`: + ```python + # Заменить: + from misc.pally import BillStatus + # На: + from schemas.billing import DepositStatus, DepositProvider + + # В классе Bill изменить: + status: Mapped[DepositStatus] = mapped_column( + Enum(DepositStatus), nullable=False, default=DepositStatus.PENDING + ) + provider: Mapped[DepositProvider] = mapped_column( + Enum(DepositProvider), nullable=False, default=DepositProvider.PALLY + ) + ``` + +3. Обновить `api/routes/pally.py`: заменить `BillStatus.SUCCESS` на `DepositStatus.SUCCESS`. +4. Обновить `services/billing_service.py`: использовать `DepositStatus.PENDING` при создании. +5. Обновить `repositories/bills.py`: методы `create` и `get_bill_by_id` не требуют изменений, + но нужно убрать импорт `BillStatus` из `misc.pally` если он там есть. +6. Создать и применить миграцию: `alembic revision --autogenerate -m "decouple_bill_from_pally"`. + +--- + +### 3.4 Gap 14 — Неопределённая деноминация поля `balance` + +**Файлы:** `db/models/users.py`, `repositories/users.py`, `config.py` + +**Проблема:** +`User.balance: Mapped[int] = mapped_column(INTEGER, ...)` не имеет явного указания деноминации. +По коду можно предположить, что это **рубли целым числом** (цены в `config.py` — `per_device_cost=50`, +`whitelist_cost=100`). Но нет ни комментариев, ни соглашения. При добавлении частичных платежей +или криптовалют с дробными суммами это станет источником ошибок. + +**Исправление:** +Зафиксировать соглашение явно в трёх местах: + +1. В `db/models/users.py` добавить комментарий к колонке: + ```python + balance: Mapped[int] = mapped_column( + INTEGER, + nullable=False, + default=0, + comment="Баланс пользователя в РУБЛЯХ (целые числа). " + "Дробные значения НЕ поддерживаются. " + "Минимальная единица = 1 рубль.", + ) + ``` + +2. В `config.py` добавить комментарий рядом с ценовыми параметрами: + ```python + # Все ценовые параметры указаны в РУБЛЯХ (целые числа). + per_device_cost: int = Field(50, alias="PER_DEVICE_COST") # ₽/устройство/месяц + whitelist_cost: int = Field(100, alias="WHITELIST_COST") # ₽/месяц за whitelist опцию + ``` + +3. Создать `docs/CONVENTIONS.md` (или добавить раздел в `README.md`) с явным описанием: + > **Деноминация баланса:** все балансовые операции производятся в **целых рублях (RUB)**. + > Дробные суммы округляются вверх (`math.ceil`) при расчёте цены подписки. + > Если в будущем потребуется поддержка копеек — выполнить миграцию умножением всех + > балансов на 100 и изменением всех ценовых параметров. + +4. **Важно:** не менять тип колонки в БД в этой фазе — только зафиксировать соглашение. + Если в будущем потребуются дробные суммы — отдельный тикет с полной миграцией. + +--- + + +## Фаза 4 — Архитектурная согласованность (DI) + +### Цель + +Устранить несоответствие между `DependenciesDTO` (бот) и `APIDependenciesDTO` (API): у API-слоя +нет доступа к `rw_sdk` и `subscriptions_repository`, что блокирует любые операции с Remnawave +на стороне API без рефакторинга. + +**Приоритет: СРЕДНИЙ** — не ломает текущий код, но заблокирует реализацию синхронизации +(Фаза 7) и другие API-endpoint'ы, которые потребуют Remnawave. + +--- + +### 4.1 Gap 11 — `APIDependenciesDTO` не содержит `rw_sdk` и `subscriptions_repository` + +**Файлы:** `schemas/di.py`, `api/core/deps.py` (если существует), `api/main.py` + +**Проблема:** +```python +@dataclass +class APIDependenciesDTO: + user_repository: UserRepository + user_service: UserService + bills_repository: BillsRepository + billing_service: BillingService + # ← Нет rw_sdk, нет subscriptions_repository +``` +`DependenciesDTO` (для бота) содержит оба поля. API-маршруты (`api/routes/pally.py`) получают +`APIDependenciesDTO` через `Depends(get_deps)`, и если понадобится выполнить операцию с +Remnawave (например, в новом webhook или admin endpoint), придётся дублировать инициализацию. + +**Исправление:** +Расширить `APIDependenciesDTO` недостающими полями: + +```python +# schemas/di.py + +from remnawave import RemnawaveSDK +from repositories.subscriptions import SubscriptionRepository + +@dataclass +class APIDependenciesDTO: + user_repository: UserRepository + user_service: UserService + bills_repository: BillsRepository + billing_service: BillingService + subscriptions_repository: SubscriptionRepository # ← добавить + rw_sdk: RemnawaveSDK # ← добавить +``` + +**Конкретные шаги:** +1. Открыть `schemas/di.py`. +2. Добавить импорты `RemnawaveSDK` и `SubscriptionRepository`. +3. Добавить два поля в `APIDependenciesDTO`. +4. Найти функцию `get_deps` в `api/core/deps.py` (или там, где инициализируется + `APIDependenciesDTO`) и передать в неё `rw_sdk` и `subscriptions_repository`. + Инстанс `RemnawaveSDK` скорее всего создаётся при старте приложения — использовать + тот же инстанс через `app.state` или dependency injection FastAPI. +5. Проверить, что существующий `api/routes/pally.py` не сломался (он не использует новые + поля, так что просто добавление полей обратно совместимо). + +--- + + +## Фаза 5 — Система автопродления подписок + +### Цель + +Реализовать автоматическое продление истекающих подписок. Это требует: планировщика задач, +логики проверки баланса, списания, продления в Remnawave, и уведомлений пользователей. + +**Приоритет: СРЕДНИЙ** — не критично прямо сейчас, но является ключевой бизнес-функцией. +**Зависит от:** Фазы 3 (поля `devices`, `duration_months`, `whitelists`, `rw_uuid` в `Subscription`) +и Фазы 3.2 (таблица `BalanceTransaction` для аудита списаний). + +--- + +### 5.1 Gap 9 — Автопродление не реализовано + +**Новые файлы:** +- `services/renewal_service.py` — логика проверки и продления +- `entrypoints/scheduler.py` — запуск APScheduler +- Обновить `requirements.txt`: добавить `apscheduler>=3.10` + +**Архитектура:** + +APScheduler запускается как отдельный процесс (или в рамках того же процесса бота) +и каждые N часов (рекомендуется 6–12 часов) вызывает задачу проверки истекающих подписок. + +``` +Задача renewal_job(): + 1. Запросить из БД все подписки где: + - autorenew = True + - status = ACTIVE + - end_date <= NOW() + 3 дня (уведомлять заранее) + 2. Для каждой такой подписки: + a. Вычислить стоимость продления (devices, duration_months, whitelists → calculate_price) + b. Проверить баланс пользователя + c. Если баланс достаточен → списать → продлить в Remnawave → обновить end_date в БД + d. Если баланс недостаточен → отправить уведомление за 3 дня, за 1 день, в день X + e. Если end_date уже прошла → status = EXPIRED → disable_user в Remnawave +``` + +**Создать `services/renewal_service.py`:** + +```python +# services/renewal_service.py + +import logging +from datetime import UTC, datetime, timedelta + +from aiogram import Bot +from remnawave import RemnawaveSDK +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models.balance_transactions import BalanceTxType +from db.models.subscriptions import Subscription, SubscriptionStatus +from misc import utils +from repositories.subscriptions import SubscriptionRepository +from repositories.users import UserRepository +from services import rw + +logger = logging.getLogger(__name__) + +RENEWAL_NOTICE_DAYS = 3 # За сколько дней предупреждать +RENEWAL_TEXT_SUCCESS = "✅ Подписка автоматически продлена на {months} мес. Списано {amount}₽." +RENEWAL_TEXT_LOW_BALANCE = ( + "⚠️ Через {days} дн. истекает подписка. " + "Для автопродления нужно {needed}₽, на балансе {balance}₽. " + "Пополните баланс: /start" +) +RENEWAL_TEXT_EXPIRED = "❌ Подписка истекла. Пополните баланс и купите новую: /start" + + +class RenewalService: + def __init__( + self, + user_repo: UserRepository, + sub_repo: SubscriptionRepository, + ): + self.user_repo = user_repo + self.sub_repo = sub_repo + + async def run_renewal_cycle( + self, session: AsyncSession, bot: Bot, rw_sdk: RemnawaveSDK + ) -> None: + now = datetime.now(UTC) + notice_threshold = now + timedelta(days=RENEWAL_NOTICE_DAYS) + + # Получить все подписки, которые истекают в ближайшие RENEWAL_NOTICE_DAYS дней + # или уже истекли (нужен метод в репозитории — см. ниже) + expiring = await self.sub_repo.get_expiring_subscriptions( + session, before=notice_threshold + ) + + for sub in expiring: + try: + await self._process_subscription(session, bot, rw_sdk, sub, now) + except Exception: + logger.exception( + "Renewal cycle error for user_id=%s", sub.user_id + ) + + async def _process_subscription( + self, + session: AsyncSession, + bot: Bot, + rw_sdk: RemnawaveSDK, + sub: Subscription, + now: datetime, + ) -> None: + user = await self.user_repo.get_user_by_id(session, sub.user_id) + if not user: + return + + # Гарантируем timezone-aware + end_date = sub.end_date + if end_date.tzinfo is None: + end_date = end_date.replace(tzinfo=UTC) + + # 1. Подписка уже истекла → деактивировать + if end_date < now: + if sub.rw_uuid: + await rw.disable_user(rw_sdk, sub.rw_uuid) + await self.sub_repo.update_sub( + session, sub.user_id, + end_date=end_date, + autorenew=sub.autorenew, + status=SubscriptionStatus.EXPIRED, + devices=sub.devices, + duration_months=sub.duration_months, + plan_price_paid=sub.plan_price_paid, + whitelists=sub.whitelists, + rw_uuid=sub.rw_uuid, + ) + try: + await bot.send_message(sub.user_id, RENEWAL_TEXT_EXPIRED) + except Exception: + pass + return + + # 2. Подписка истекает в ближайшие дни + days_left = (end_date - now).days + + if not sub.autorenew: + # Не автопродление — только уведомить + if days_left <= RENEWAL_NOTICE_DAYS: + try: + await bot.send_message( + sub.user_id, + f"⏰ До конца подписки осталось {days_left} дн. " + f"Продлите её через бот: /start", + ) + except Exception: + pass + return + + # 3. autorenew=True — пытаемся продлить + # Вычислить стоимость: нужен SubscriptionPlan-объект + from schemas.subscriptions import SubscriptionDuration, SubscriptionPlan + plan = SubscriptionPlan( + devices=sub.devices, + duration=utils.convert_int_to_duration(sub.duration_months), + whitelists=sub.whitelists, + discount=1, # без скидок при автопродлении (решение команды) + ) + amount = utils.calculate_price(plan) + + if user.balance < amount: + try: + await bot.send_message( + sub.user_id, + RENEWAL_TEXT_LOW_BALANCE.format( + days=days_left, needed=amount, balance=user.balance + ), + ) + except Exception: + pass + return + + # Списать и продлить + if not sub.rw_uuid: + logger.error("Cannot renew sub for user_id=%s: rw_uuid is None", sub.user_id) + return + + duration_delta = utils.convert_duration_to_timedelta(plan.duration) + + await rw.add_days(rw_sdk, sub.rw_uuid, duration_delta.days) + await self.user_repo.decrease_user_balance(session, user.id, amount) + + new_end_date = end_date + duration_delta + await self.sub_repo.update_sub( + session, sub.user_id, + end_date=new_end_date, + autorenew=True, + status=SubscriptionStatus.ACTIVE, + devices=sub.devices, + duration_months=sub.duration_months, + plan_price_paid=amount, + whitelists=sub.whitelists, + rw_uuid=sub.rw_uuid, + ) + + logger.info( + "Auto-renewed subscription for user_id=%s, amount=%s, new_end=%s", + sub.user_id, amount, new_end_date, + ) + + try: + await bot.send_message( + sub.user_id, + RENEWAL_TEXT_SUCCESS.format(months=sub.duration_months, amount=amount), + ) + except Exception: + pass +``` + +**Обновить `repositories/subscriptions.py`:** +Добавить метод `get_expiring_subscriptions`: +```python +async def get_expiring_subscriptions( + self, session: AsyncSession, before: datetime +) -> list[Subscription]: + """Вернуть подписки, у которых end_date <= before и status = ACTIVE.""" + stmt = ( + select(Subscription) + .where( + Subscription.end_date <= before, + Subscription.status == SubscriptionStatus.ACTIVE, + ) + ) + res = await session.execute(stmt) + return list(res.scalars().all()) +``` + +**Создать `entrypoints/scheduler.py`:** +```python +# entrypoints/scheduler.py + +import asyncio +import logging + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger + +from db.session import get_session # или аналогичная фабрика сессий +from repositories.subscriptions import SubscriptionRepository +from repositories.users import UserRepository +from services.renewal_service import RenewalService +# bot и rw_sdk инициализировать так же, как в bot/main.py + +logger = logging.getLogger(__name__) + + +async def renewal_job(bot, rw_sdk): + user_repo = UserRepository() + sub_repo = SubscriptionRepository() + service = RenewalService(user_repo, sub_repo) + + async with get_session() as session: + await service.run_renewal_cycle(session, bot, rw_sdk) + + +def start_scheduler(bot, rw_sdk): + scheduler = AsyncIOScheduler() + scheduler.add_job( + renewal_job, + trigger=IntervalTrigger(hours=6), + args=[bot, rw_sdk], + id="renewal_cycle", + replace_existing=True, + misfire_grace_time=300, # 5 минут — если задача пропущена, всё равно запустить + ) + scheduler.start() + logger.info("Renewal scheduler started (interval=6h)") + return scheduler +``` + +**Конкретные шаги:** +1. Добавить `apscheduler>=3.10` в `requirements.txt`. +2. Создать `services/renewal_service.py`. +3. Добавить `get_expiring_subscriptions` в `repositories/subscriptions.py`. +4. Создать `entrypoints/scheduler.py`. +5. Интегрировать вызов `start_scheduler(bot, rw_sdk)` в точку запуска бота (`bot/main.py` + или `entrypoints/bot.py` — где инициализируется dispatcher). +6. Добавить кнопку «🔄 Автопродление» в меню подписки, которая переключает `sub.autorenew`. + Соответствующий хендлер: `F.data == "autorenew:toggle"` → инвертировать `sub.autorenew` + → показать подтверждение. + +--- + + +## Фаза 6 — Реферальная система: учёт покупок за баланс + +### Цель + +Реферальный бонус сейчас начисляется **только при пополнении баланса через Pally**. Если +реферал использует накопленный баланс для покупки подписки, реферер не получает бонус. +Это создаёт несоответствие: при одинаковом экономическом результате (реферал заплатил за VPN) +реферер получает бонус только в части случаев. + +**Приоритет: НИЗКИЙ** — не блокирует работу, но влияет на справедливость реферальной системы. +**Зависит от:** Фазы 3.2 (таблица `BalanceTransaction` — нужна для запрета двойного начисления). + +--- + +### 6.1 Gap 10 — Реферальный бонус не начисляется при покупке за баланс + +**Файл:** `bot/handlers/buy.py`, хендлер `proceed_to_checkout` + +**Проблема:** +В `api/routes/pally.py` есть логика: +```python +referal = bill.user.referal_id +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) +``` +Но в `proceed_to_checkout` (покупка за баланс) аналогичной логики нет. + +**Решение:** +Добавить начисление реферального бонуса в `proceed_to_checkout` после успешного списания. + +**Архитектурный выбор — две стратегии:** + +**Стратегия A (простая):** Начислять бонус реферу при каждой покупке за баланс, независимо +от источника пополнения. Минус: если пользователь пополнил через Pally и сразу купил, +реферер получит бонус дважды (при пополнении и при покупке). + +**Стратегия B (рекомендуемая):** Начислять бонус рефереру только при покупке, но **убрать** +начисление при пополнении через Pally. Реферальный бонус = % от фактических трат на VPN, +а не от пополнений. Это честнее и проще в аудите. + +**Реализация Стратегии B:** + +1. В `api/routes/pally.py` — убрать блок начисления реферального бонуса при пополнении. +2. В `bot/handlers/buy.py`, `proceed_to_checkout`, после успешного `decrease_user_balance`: + ```python + # Начисление реферального бонуса + user_full = await deps.user_repository.get_user_by_id(session, cb.from_user.id) + if user_full.referal_id is not None: + referal_bonus_amount = math.floor(amount * (settings.referal_bonus / 100)) + if referal_bonus_amount > 0: + await deps.user_repository.increase_user_balance( + session, user_full.referal_id, referal_bonus_amount + ) + # Записать в BalanceTransaction с tx_type=REFERRAL_BONUS + logger.info( + "Referral bonus %s credited to user_id=%s (referral of %s)", + referal_bonus_amount, user_full.referal_id, cb.from_user.id, + ) + # Отправить уведомление рефереру (опционально) + try: + await cb.bot.send_message( + user_full.referal_id, + f"🎁 Ваш реферал купил подписку. " + f"Вам начислен бонус {referal_bonus_amount}₽." + ) + except Exception: + pass + ``` +3. Добавить импорт `import math` в `bot/handlers/buy.py` (он уже есть). + +**Конкретные шаги:** +1. Принять решение о стратегии (A или B) — зафиксировать в комментарии кода. +2. Реализовать выбранную стратегию. +3. При выборе B: протестировать что при пополнении через Pally реферальный бонус + больше не начисляется (регрессионный тест). +4. Добавить запись в `BalanceTransaction` для реферальных бонусов (для аудита). + +--- + + +## Фаза 7 — Синхронизация состояния с Remnawave + +### Цель + +Устранить возможную рассинхронизацию между БД бота и состоянием в Remnawave. Если +администратор вручную изменяет данные пользователя в панели Remnawave (меняет дату, +устройства, отключает), бот об этом не знает и показывает устаревшие данные. + +**Приоритет: НИЗКИЙ** — актуально при активном ручном управлении через панель Remnawave. +Может быть реализован как периодическая задача в рамках того же APScheduler из Фазы 5. +**Зависит от:** Фазы 3.1 (поле `rw_uuid` в `Subscription`), Фазы 4 (DI в API). + +--- + +### 7.1 Gap 13 — Нет механизма синхронизации состояния из Remnawave + +**Новый файл:** `services/sync_service.py` + +**Стратегия синхронизации:** + +Поскольку Remnawave является источником истины для статуса и дат подписки, периодически +(раз в 1–4 часа) вытягивать актуальное состояние всех пользователей из Remnawave и +обновлять `Subscription` в локальной БД. + +**Сценарии рассинхронизации, которые нужно обработать:** + +| Ситуация в Remnawave | Действие в БД бота | +|---|---| +| `expire_at` изменён вручную | Обновить `end_date` в `Subscription` | +| Статус изменён на `DISABLED` | Обновить `status = EXPIRED` в `Subscription` | +| `hwid_device_limit` изменён | Обновить `devices` в `Subscription` | +| Пользователь удалён из Remnawave | `status = EXPIRED`, `rw_uuid = None` | +| Пользователь добавлен в Remnawave вручную | Создать `Subscription` в БД бота | + +**Создать `services/sync_service.py`:** + +```python +# services/sync_service.py + +import logging +from datetime import UTC, datetime + +from remnawave import RemnawaveSDK +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models.subscriptions import SubscriptionStatus +from repositories.subscriptions import SubscriptionRepository +from repositories.users import UserRepository +from services import rw + +logger = logging.getLogger(__name__) + + +class SyncService: + def __init__( + self, + user_repo: UserRepository, + sub_repo: SubscriptionRepository, + ): + self.user_repo = user_repo + self.sub_repo = sub_repo + + async def sync_all_users( + self, session: AsyncSession, rw_sdk: RemnawaveSDK + ) -> dict[str, int]: + """ + Синхронизировать состояние всех подписок с Remnawave. + Возвращает статистику: {"synced": N, "expired": M, "errors": K}. + """ + stats = {"synced": 0, "expired": 0, "errors": 0} + + users = await self.user_repo.get_users(session) + + for user in users: + try: + rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id) + sub = await self.sub_repo.get_subscription_by_user_id(session, user.id) + + if rw_user is None: + # Пользователя нет в Remnawave + if sub and sub.status == SubscriptionStatus.ACTIVE: + await self.sub_repo.update_sub( + session, user.id, + end_date=sub.end_date, + autorenew=False, + status=SubscriptionStatus.EXPIRED, + devices=sub.devices, + duration_months=sub.duration_months, + plan_price_paid=sub.plan_price_paid, + whitelists=sub.whitelists, + rw_uuid=None, + ) + stats["expired"] += 1 + continue + + # Определить актуальный статус + rw_status_map = { + "ACTIVE": SubscriptionStatus.ACTIVE, + "DISABLED": SubscriptionStatus.EXPIRED, + "EXPIRED": SubscriptionStatus.EXPIRED, + "LIMITED": SubscriptionStatus.EXPIRED, + "ON_HOLD": SubscriptionStatus.EXPIRED, + } + new_status = rw_status_map.get(rw_user.status, SubscriptionStatus.EXPIRED) + + # Нормализовать дату + expire_at = rw_user.expire_at + if expire_at.tzinfo is None: + expire_at = expire_at.replace(tzinfo=UTC) + + if sub: + # Обновить существующую запись + await self.sub_repo.update_sub( + session, user.id, + end_date=expire_at, + autorenew=sub.autorenew, + status=new_status, + devices=rw_user.hwid_device_limit or sub.devices, + duration_months=sub.duration_months, + plan_price_paid=sub.plan_price_paid, + whitelists=sub.whitelists, + rw_uuid=rw_user.uuid, + ) + else: + # Пользователь есть в Remnawave, но нет в нашей БД + # (добавлен вручную через панель) + await self.sub_repo.create_subscription( + session, + user_id=user.id, + end_date=expire_at, + autorenew=False, + status=new_status, + devices=rw_user.hwid_device_limit or 1, + duration_months=1, + plan_price_paid=0, + whitelists=False, + rw_uuid=rw_user.uuid, + ) + + stats["synced"] += 1 + + except Exception: + logger.exception("Sync error for user_id=%s", user.id) + stats["errors"] += 1 + + logger.info("Sync complete: %s", stats) + return stats +``` + +**Интеграция в планировщик:** +В `entrypoints/scheduler.py` добавить вторую задачу: +```python +from services.sync_service import SyncService + +async def sync_job(rw_sdk): + user_repo = UserRepository() + sub_repo = SubscriptionRepository() + service = SyncService(user_repo, sub_repo) + async with get_session() as session: + await service.sync_all_users(session, rw_sdk) + +# В start_scheduler: +scheduler.add_job( + sync_job, + trigger=IntervalTrigger(hours=2), + args=[rw_sdk], + id="remnawave_sync", + replace_existing=True, + misfire_grace_time=120, +) +``` + +**Admin-endpoint для ручного запуска синхронизации:** +Добавить в `api/routes/` файл `admin.py` с защищённым endpoint'ом: +```python +# api/routes/admin.py + +from fastapi import Depends, Header, HTTPException +from fastapi.routing import APIRouter +from sqlalchemy.ext.asyncio import AsyncSession + +from api.core.deps import get_db, get_deps +from config import settings +from schemas.di import APIDependenciesDTO +from services.sync_service import SyncService +from repositories.subscriptions import SubscriptionRepository +from repositories.users import UserRepository + +router = APIRouter(prefix="/admin") + + +@router.post("/sync") +async def trigger_sync( + x_admin_token: str = Header(...), + session: AsyncSession = Depends(get_db), + deps: APIDependenciesDTO = Depends(get_deps), +): + # Простая защита через статический токен в заголовке + if x_admin_token != settings.remnawave_token: # или отдельный ADMIN_TOKEN в config + raise HTTPException(403, "Forbidden") + + service = SyncService(deps.user_repository, deps.subscriptions_repository) + stats = await service.sync_all_users(session, deps.rw_sdk) + return {"ok": True, "stats": stats} +``` + +**Конкретные шаги:** +1. Создать `services/sync_service.py`. +2. Добавить `sync_job` в `entrypoints/scheduler.py`. +3. Создать `api/routes/admin.py` с защищённым endpoint'ом. +4. Зарегистрировать `admin.router` в `api/main.py`. +5. Добавить `ADMIN_TOKEN` в `config.py` и `.env` (отдельный токен, не `remnawave_token`). +6. Учесть ограничение по RPS к Remnawave API: если пользователей много (> 1000), добавить + `asyncio.sleep(0.1)` между запросами в `sync_all_users` или использовать batched-запросы + если Remnawave API их поддерживает. + +--- + +## Сводная таблица задач + +| # | Проблема | Фаза | Файлы | Сложность | +|---|---|---|---|---| +| 1 | `add_days` получает `user.id` вместо UUID | 1 | `services/user_service.py` | 🟢 Мин | +| 2 | HWID не обновляется при продлении | 1 | `services/user_service.py` | 🟢 Мин | +| 3 | Squads не обновляются при продлении | 1 | `services/user_service.py` | 🟢 Мин | +| 4 | Нет обработчика `deposit` | 1 | `bot/handlers/deposit.py` (новый) | 🟡 Лёгкая | +| 5 | Нет атомарности в checkout | 2 | `bot/handlers/buy.py` | 🟡 Лёгкая | +| 6 | Двойное списание без защиты | 2 | `bot/handlers/buy.py` | 🟢 Мин | +| 7 | `Subscription` без полей плана | 3 | `db/models/subscriptions.py` + миграция | 🔴 Средняя | +| 8 | Нет аудит-лога баланса | 3 | `db/models/balance_transactions.py` (новый) + миграция | 🔴 Средняя | +| 9 | `Bill` привязан к Pally | 3 | `db/models/bills.py`, `schemas/billing.py` | 🟡 Лёгкая | +| 10 | Деноминация не зафиксирована | 3 | `db/models/users.py`, `config.py` | 🟢 Мин | +| 11 | `APIDependenciesDTO` неполный | 4 | `schemas/di.py`, `api/core/deps.py` | 🟡 Лёгкая | +| 12 | Автопродление не реализовано | 5 | `services/renewal_service.py` (новый) | 🔴 Средняя | +| 13 | Реферал не начисляется при покупке | 6 | `bot/handlers/buy.py` | 🟡 Лёгкая | +| 14 | Нет синхронизации с Remnawave | 7 | `services/sync_service.py` (новый) | 🔴 Средняя | + +**Обозначения сложности:** +- 🟢 Минимальная — 1–10 строк, без новых зависимостей +- 🟡 Лёгкая — до 50 строк, возможно новый файл +- 🔴 Средняя — новые модели/миграции или существенный рефакторинг + +--- + +## Порядок реализации (рекомендуемый спринт-план) + +### Спринт 1 (Фазы 1–2): ~1–2 дня +Исправить все 6 критических багов. Минимальный риск регрессий, максимальный эффект. + +### Спринт 2 (Фаза 3): ~2–3 дня +Расширить модели данных. Написать и применить миграции. Обновить репозитории и сервисы. +Тщательно протестировать, что существующий флоу покупки не сломан. + +### Спринт 3 (Фаза 4): ~0.5 дня +Быстрый рефакторинг DI — небольшой объём работы, но необходим до реализации следующих фаз. + +### Спринт 4 (Фаза 5): ~2–3 дня +Реализовать автопродление. Тщательно тестировать на тестовых пользователях с ненастоящими +деньгами перед запуском в продакшен. + +### Спринт 5 (Фазы 6–7): ~1–2 дня +Реферальная система и синхронизация — завершающий этап. + +--- + +*Документ создан на основе анализа кодовой базы. Актуализировать при изменении архитектуры.* \ No newline at end of file diff --git a/plans/SUBSCRIPTION_FIX.md b/plans/SUBSCRIPTION_FIX.md new file mode 100644 index 0000000..d568f85 --- /dev/null +++ b/plans/SUBSCRIPTION_FIX.md @@ -0,0 +1,1183 @@ +# 🔄 SUBSCRIPTION_FIX — Исправление логики смены и продления тарифного плана + +> **Связанный документ:** [`FINANCE_REFINEMENT.md`](./FINANCE_REFINEMENT.md) — +> Фазы 1 и 3 являются обязательными предварительными условиями для данного плана. + +--- + +## Содержание + +1. [Постановка проблемы](#1-постановка-проблемы) + - 1.1 [«То же продление» — ожидаемое поведение (корректно)](#11-то-же-продление--ожидаемое-поведение-корректно) + - 1.2 [«Апгрейд» — скрытая потеря выручки](#12-апгрейд--скрытая-потеря-выручки) + - 1.3 [«Даунгрейд» — пользователь теряет переплаченные дни](#13-даунгрейд--пользователь-теряет-переплаченные-дни) + - 1.4 [«Истёкшая → новая» — уже обрабатывается верно](#14-истёкшая--новая--уже-обрабатывается-верно) + - 1.5 [Конкретный числовой пример](#15-конкретный-числовой-пример) +2. [Выбранное решение: «Замена со зачётом»](#2-выбранное-решение-замена-со-зачётом) + - 2.1 [Алгоритм](#21-алгоритм) + - 2.2 [Числовой пример решения](#22-числовой-пример-решения) + - 2.3 [Обоснование выбора](#23-обоснование-выбора) + - 2.4 [Отклонённые альтернативы](#24-отклонённые-альтернативы) +3. [Предварительные условия (Prerequisites)](#3-предварительные-условия-prerequisites) +4. [Поэтапный план реализации](#4-поэтапный-план-реализации) + - [Фаза 0 — Завершение предварительных условий](#фаза-0--завершение-предварительных-условий) + - [Фаза 1 — Функция сравнения планов `is_same_plan`](#фаза-1--функция-сравнения-планов-is_same_plan) + - [Фаза 2 — Функция расчёта зачёта `calculate_remaining_credit`](#фаза-2--функция-расчёта-зачёта-calculate_remaining_credit) + - [Фаза 3 — Три ветки в `buy_subscription`](#фаза-3--три-ветки-в-buy_subscription) + - [Фаза 4 — UI: отображение зачёта пользователю](#фаза-4--ui-отображение-зачёта-пользователю) + - [Фаза 5 — Граничный случай: истёкшая подписка в Remnawave](#фаза-5--граничный-случай-истёкшая-подписка-в-remnawave) +5. [Изменения модели данных](#5-изменения-модели-данных) +6. [Граничные случаи и обработка ошибок](#6-граничные-случаи-и-обработка-ошибок) +7. [Сводная таблица задач](#7-сводная-таблица-задач) +8. [Рекомендуемый порядок реализации](#8-рекомендуемый-порядок-реализации) + +--- + +## 1. Постановка проблемы + +Текущий метод `UserService.buy_subscription` обрабатывает всех существующих пользователей +Remnawave единственным способом: **добавить дни к текущему сроку истечения**. Никакого +понятия «смена плана» не существует. Это порождает четыре принципиально разных сценария, +из которых только один обрабатывается правильно. + +--- + +### 1.1 «То же продление» — ожидаемое поведение (корректно) + +**Сценарий:** Пользователь купил подписку на 3 устройства на 1 месяц. Через 10 дней покупает +ещё раз **те же** 3 устройства на 1 месяц. + +**Текущее поведение (после исправлений Bug 1–3 из FINANCE_REFINEMENT Фаза 1):** +- `add_days(rw_user.uuid, 30)` — срок продлевается на 30 дней ✅ +- `set_hwid_limit(rw_user.uuid, 3)` — лимит не меняется (уже 3) ✅ +- `update_squads(...)` — squads не меняются ✅ + +**Вывод:** Этот сценарий работает корректно после применения Фазы 1 из FINANCE_REFINEMENT. +Дополнительных изменений не требует. **Настоящий документ его не трогает.** + +--- + +### 1.2 «Апгрейд» — скрытая потеря выручки + +**Сценарий:** Пользователь купил подписку на 3 устройства на 1 месяц (150₽). На 3-й день +покупает **6 устройств на 1 месяц** (300₽). + +**Текущее поведение (после Bug 1–3):** +1. `rw_user` найден → входим в ветку продления. +2. `add_days(rw_user.uuid, 30)` — добавляет 30 дней поверх оставшихся ~27 дней. +3. `set_hwid_limit(rw_user.uuid, 6)` — лимит устройств обновляется до 6. +4. Итог: пользователь получает **57 дней** подписки на 6 устройств за **450₽** суммарно. + +**Ожидаемое поведение:** +- 3 дня подписки на 3 устройства (уже использованы). +- 30 новых дней на 6 устройств (оплачены 300₽). +- Оставшиеся 27 дней прежнего плана должны либо сгореть, либо быть учтены как зачёт. + +**Потеря выручки:** +- Пользователь получает 27 дней «апгрейда» на 6 устройств **бесплатно**. +- Стоимость этих дней по тарифу 6 устройств: 27/30 × 300₽ = **270₽** — это + деньги, которые бизнес недополучает с каждой такой операции. + +--- + +### 1.3 «Даунгрейд» — пользователь теряет переплаченные дни + +**Сценарий:** Пользователь купил подписку на 6 устройств + whitelist на 3 месяца (2 100₽ +с учётом скидки). Через 2 недели переходит на 3 устройства без whitelist на 1 месяц (150₽). + +**Текущее поведение (после Bug 1–3):** +1. `rw_user` найден → ветка продления. +2. `add_days(rw_user.uuid, 30)` — добавляет 30 дней поверх оставшихся ~76 дней. +3. `set_hwid_limit(rw_user.uuid, 3)` — лимит устройств понижается до 3. +4. `update_squads(...)` — whitelist squad удаляется. +5. Итог: пользователь получает **106 дней** подписки на 3 устройства за 2 250₽ суммарно. + +**Проблемы:** +- За 76 оставшихся дней 6-устройственного плана пользователь заплатил пропорционально + ~1 680₽ — и эти дни не возвращаются и не компенсируются. +- Пользователь чувствует себя обманутым: переход на более дешёвый план не даёт ни возврата, + ни кредита. +- С точки зрения бизнеса: это честно (деньги уже получены), но **приводит к оттоку** + лояльных пользователей. + +--- + +### 1.4 «Истёкшая → новая» — уже обрабатывается верно + +**Сценарий:** Срок подписки истёк. Remnawave удаляет или деактивирует пользователя. +Пользователь покупает новый план. + +**Текущее поведение:** +- `get_user_by_telegram_id` возвращает `None` (пользователь удалён из RW). +- Выполняется ветка **создания** (`create_user`) с новыми параметрами. + +**Вывод:** Этот сценарий работает правильно **при условии**, что Remnawave действительно +удаляет истёкших пользователей. Если пользователь остаётся в RW со статусом `"EXPIRED"`, +нужна дополнительная обработка (см. [Фаза 5](#фаза-5--граничный-случай-истёкшая-подписка-в-remnawave)). + +--- + +### 1.5 Конкретный числовой пример + +| Момент | Действие | Стоимость | Состояние в RW | +|--------|----------|-----------|----------------| +| День 0 | Куплено: 3 устройства × 1 мес | 150₽ | hwid=3, expire=День 30 | +| День 2 | Куплено: 6 устройств × 1 мес | 300₽ | hwid=6, expire=День 60 (добавили 30) | +| Итог | — | Заплачено: 450₽ | **~58 дней** на 6 устройств | + +**Корректная стоимость 58 дней на 6 устройств** была бы: +- Справедливая пропорция: 2 дня × (150₽/30) + 28 дней × (300₽/30) + 30 дней × 300₽/30 = 10 + 280 + 300 = **590₽** +- Пользователь заплатил: **450₽** +- **Недополучено: 140₽** за каждую такую транзакцию. + +--- + +## 2. Выбранное решение: «Замена со зачётом» + +### 2.1 Алгоритм + +При наличии **активной** подписки (rw_user существует И `expire_at > now`): + +``` +Если новый план == текущий план: + → Чистое продление: add_days(duration.days) [текущее поведение, корректно] + +Если новый план != текущий план: + 1. Рассчитать remaining_days = (rw_user.expire_at - now).days + 2. Получить из БД: plan_price_paid и duration_days активной подписки + 3. credit = floor(plan_price_paid * remaining_days / duration_days) + 4. Зачислить credit на баланс пользователя (если credit > 0) + 5. Обновить Remnawave: + a. Установить новый expire_at = now + new_duration (через delta add_days) + b. set_hwid_limit(new_devices) + c. update_squads(new_squad_list) + 6. Обновить запись Subscription в БД с новыми параметрами плана + 7. Записать зачёт в лог (BalanceTransaction, если реализован из FINANCE_REFINEMENT Фаза 3) +``` + +При наличии **истёкшей** подписки (`rw_user` существует, но `expire_at <= now` или +`status in ("EXPIRED", "DISABLED")`): +- Кредит не начисляется (период уже закончился). +- Устанавливаем `expire_at = now + new_duration`, обновляем HWID и squads, включаем пользователя. + +При отсутствии `rw_user` (пользователь не найден в Remnawave): +- Свежее создание (текущая ветка `create_user`), без изменений. + +--- + +### 2.2 Числовой пример решения + +**Те же исходные данные: покупка 6 устройств на День 2 (28 оставшихся дней из 30)** + +``` +remaining_days = 28 +plan_price_paid = 150₽ +duration_days = 30 + +credit = floor(150 * 28 / 30) = floor(140.0) = 140₽ +``` + +| Статья | Сумма | +|--------|-------| +| Первая покупка (3 устр., 30 дней) | 150₽ | +| Зачёт за 28 неиспользованных дней | −140₽ | +| Вторая покупка (6 устр., 30 дней) | 300₽ | +| **Итого фактически уплачено** | **310₽** | +| **Справедливая стоимость (пропорция)** | **310₽** | + +> 2 дня на 3 устр.: 2 × 5₽/день = 10₽ +> 30 дней на 6 устр.: 30 × (300₽/30) = 300₽ +> **Итого: 310₽** — совпадает точно. + +**Состояние после замены:** +- Баланс пользователя: пополнен на 140₽ (кредит), затем списано 300₽ за новый план. +- Remnawave: hwid=6, expire=День 32 (сегодня + 30 дней), squads актуальны. +- Пользователь видит: «Тариф обновлён. На ваш баланс зачислено 140₽ за неиспользованные дни.» + +--- + +### 2.3 Обоснование выбора + +| Критерий | Replace-and-Credit | +|---|---| +| Финансовая честность для бизнеса | ✅ Нет «бесплатных апгрейдов» | +| Честность для пользователя | ✅ Нет потери переплаченных дней | +| Прозрачность | ✅ Пользователь видит начисленный зачёт | +| Сложность UX | ✅ Один экран, один клик — без доп. шагов | +| Сложность реализации | ✅ Умеренная (новый модуль + 3 ветки в сервисе) | +| Требует изменений модели БД | ⚠️ Да, но они уже запланированы в FINANCE_REFINEMENT | + +Ключевое свойство: **зачёт появляется на балансе до списания новой суммы**. Это означает, +что после зачисления пользователь может себе «позволить» новый план даже если у него было +мало денег — логика проверки баланса в хендлере должна это учитывать +(см. [Секцию 6](#6-граничные-случаи-и-обработка-ошибок)). + +--- + +### 2.4 Отклонённые альтернативы + +**❌ «Запретить смену плана до окончания периода»** +Самый простой в реализации подход: показывать пользователю сообщение «Смена тарифа +недоступна до X дата». Отклонён по причине плохого UX — пользователь, которому нужно +больше устройств прямо сейчас, уйдёт к конкуренту. + +**❌ «Списать полную стоимость, игнорируя остаток»** +Пользователь платит полную цену нового плана, оставшиеся дни сгорают без компенсации. +Это двойное снятие денег (за уже оплаченный период + новый период), что воспринимается +как мошенничество и является прямой причиной chargebacks. + +**❌ «Показать счёт на доплату (proratedinvoice)»** +Вычислить только разницу и попросить пользователя доплатить. Концептуально верно, но +требует отдельного экрана подтверждения, динамически рассчитываемой суммы в `InlineKeyboard`, +и повторного прохождения платёжного флоу. Значительно сложнее как в UX, так и в реализации. +Отклонён как избыточный — «Замена со зачётом» даёт тот же финансовый результат проще. + +--- + +## 3. Предварительные условия (Prerequisites) + +Следующие задачи из [`FINANCE_REFINEMENT.md`](./FINANCE_REFINEMENT.md) **должны быть +выполнены до начала работы по настоящему плану**. Они перечислены здесь для ясности — +реализация описана в том документе. + +--- + +### P1 — Bug 1: Исправить аргумент `rw.add_days` *(FINANCE_REFINEMENT, Фаза 1, §1.1)* + +```python +# БЫЛО (сломано): +await rw.add_days(rw_sdk, user.id, duration.days) # user.id = Telegram int ID + +# СТАЛО (правильно): +await rw.add_days(rw_sdk, rw_user.uuid, duration.days) # rw_user.uuid = UUID-строка +``` + +**Почему это prerequisite:** Весь текущий документ опирается на корректную работу +`add_days`. Без этого исправления Ветка B (чистое продление) не работает вовсе. + +--- + +### P2 — Gap 7: Расширить модель `Subscription` *(FINANCE_REFINEMENT, Фаза 3, §3.1)* + +FINANCE_REFINEMENT Gap 7 добавляет поля `devices`, `duration_months`, `plan_price_paid`, +`whitelists`, `rw_uuid` в таблицу `subscriptions`. **Настоящий документ требует +дополнительного поля `duration_days`**, которого нет в Gap 7 (там только `duration_months`). + +`duration_days` необходим для точного расчёта зачёта, потому что месяцы имеют разное +количество дней (28–31), а формула зачёта оперирует днями, а не месяцами. + +Подробности — в [Секции 5](#5-изменения-модели-данных). + +**Почему это prerequisite:** `calculate_remaining_credit()` читает `subscription.plan_price_paid` +и `subscription.duration_days`. Без этих полей зачёт невозможно рассчитать. + +--- + +### P3 — Bug 5: Атомарность `proceed_to_checkout` *(FINANCE_REFINEMENT, Фаза 2, §2.1)* + +```python +# ИСПРАВЛЕННЫЙ порядок: +# 1. Сначала buy_subscription (Remnawave + DB subscription) +# 2. Потом decrease_user_balance +# 3. При ошибке Remnawave — ни баланс, ни подписка не тронуты +``` + +**Почему это prerequisite:** В сценарии «Замена со зачётом» мы начисляем кредит +(`increase_user_balance`) внутри `buy_subscription` **до** того, как новая подписка +создана в Remnawave. Если Remnawave упадёт после начисления кредита — баланс окажется +некорректным. Атомарная структура из P3 позволяет корректно откатить состояние. + +--- + +## 4. Поэтапный план реализации + +--- + +### Фаза 0 — Завершение предварительных условий + +**Задача:** Выполнить P1, P2, P3 из Секции 3 (если они ещё не реализованы). + +**Проверка готовности перед Фазой 1:** +- [ ] `rw.add_days` принимает `rw_user.uuid`, не `user.id`. +- [ ] В модели `Subscription` присутствуют поля `devices`, `duration_days`, `plan_price_paid`, + `whitelists`, `rw_uuid`. +- [ ] Миграция Alembic применена. +- [ ] `subscription_repository.update_sub` и `create_subscription` принимают новые поля. +- [ ] `buy_subscription` принимает параметр `amount: int` и сохраняет поля плана в БД. +- [ ] Порядок в `proceed_to_checkout` исправлен (сначала RW, потом списание). + +--- + +### Фаза 1 — Функция сравнения планов `is_same_plan` + +**Файл:** `services/subscription_calculator.py` (новый файл) + +**Цель:** Определить, совпадает ли выбранный пользователем новый план с тем, что +уже активен в Remnawave, чтобы выбрать между «чистым продлением» (Ветка B) и +«заменой со зачётом» (Ветка C). + +**Сравнение производится по двум параметрам:** +1. `hwid_device_limit` в Remnawave vs `new_plan.devices`. +2. Наличие UUID whitelist-squad в `rw_user.active_squads` vs `new_plan.whitelists`. + +```python +# services/subscription_calculator.py + +from services.rw import RWUserInfo, InternalSquads +from schemas.subscriptions import SubscriptionPlan + + +def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool: + """ + Возвращает True, если новый план идентичен текущему активному плану в Remnawave. + + Сравниваются только те параметры, которые отражаются в Remnawave: + - лимит устройств (hwid_device_limit) + - наличие whitelist squad + + Длительность (количество дней) НЕ является признаком «другого плана» — + продление на тот же тариф считается чистым extension независимо от срока. + """ + # 1. Сравниваем лимит HWID-устройств + if rw_user.hwid_device_limit != new_plan.devices: + return False + + # 2. Сравниваем наличие whitelist squad + active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads} + current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids + if current_has_whitelist != new_plan.whitelists: + return False + + return True +``` + +**Конкретные шаги:** +1. Создать файл `services/subscription_calculator.py`. +2. Добавить функцию `is_same_plan` (код выше). +3. Убедиться, что `InternalSquads` экспортируется из `services/rw.py` или + `schemas/subscriptions.py` — если нет, вынести в `config.py` или `constants.py`. +4. Написать минимальные unit-тесты: + - Тест: `hwid=3` в RW, `new_plan.devices=3`, whitelist=False в обоих → `True`. + - Тест: `hwid=3` в RW, `new_plan.devices=6` → `False`. + - Тест: `hwid=6` в RW, whitelist squad присутствует, `new_plan.whitelists=False` → `False`. + - Тест: `hwid=6` в RW, `new_plan.devices=6`, whitelist одинаков → `True` (продление 6→6). + +--- + +### Фаза 2 — Функция расчёта зачёта `calculate_remaining_credit` + +**Файл:** `services/subscription_calculator.py` (расширить) + +**Цель:** Вычислить сумму в рублях, которую нужно вернуть пользователю за неиспользованные +дни текущего оплаченного периода. + +**Формула:** +``` +credit = floor(plan_price_paid × remaining_days / duration_days) + +где: + remaining_days = max(0, (subscription.end_date − now).days) + duration_days = subscription.duration_days (из БД) + plan_price_paid = subscription.plan_price_paid (из БД, в рублях) +``` + +**Свойства формулы:** +- Округление вниз (`floor`) — в пользу пользователя (нельзя вернуть дробные рубли). +- При `remaining_days = 0` (подписка только что истекла) → `credit = 0`. +- При `remaining_days > duration_days` (такого не должно быть в норме, но защита нужна) + → `remaining_days` ограничивается `duration_days`. + +```python +# services/subscription_calculator.py (продолжение) + +import math +from datetime import datetime, UTC + +from db.models.subscriptions import Subscription + + +def calculate_remaining_credit( + subscription: Subscription, + now: datetime | None = None, +) -> int: + """ + Рассчитывает зачёт в рублях за неиспользованные дни активной подписки. + + Возвращает 0, если: + - подписка уже истекла (end_date <= now) + - в модели нет данных о цене или длительности (plan_price_paid=0 или duration_days=0) + + Args: + subscription: Объект активной подписки из БД. Должен содержать поля + plan_price_paid (int, руб.) и duration_days (int, дней). + now: Текущий момент (UTC). По умолчанию datetime.now(UTC). + + Returns: + int: Сумма зачёта в рублях, >= 0. + """ + if now is None: + now = datetime.now(UTC) + + # Защита: нет данных о плане → нет зачёта + if not subscription.plan_price_paid or not subscription.duration_days: + return 0 + + # Подписка уже истекла + if subscription.end_date <= now: + return 0 + + remaining_days = (subscription.end_date - now).days + if remaining_days <= 0: + return 0 + + # Аномальная защита: не возвращать больше, чем оплаченный период + remaining_days = min(remaining_days, subscription.duration_days) + + credit = math.floor( + subscription.plan_price_paid * remaining_days / subscription.duration_days + ) + return max(0, credit) +``` + +**Конкретные шаги:** +1. Дополнить `services/subscription_calculator.py` функцией `calculate_remaining_credit`. +2. Добавить импорты: `math`, `datetime`, `UTC`, модель `Subscription`. +3. Написать unit-тесты: + - `plan_price_paid=150, duration_days=30, remaining_days=28` → `credit=140`. + - `plan_price_paid=150, duration_days=30, remaining_days=0` → `credit=0`. + - `plan_price_paid=0, duration_days=30, remaining_days=20` → `credit=0` (нет данных). + - `plan_price_paid=2100, duration_days=90, remaining_days=76` → `credit=floor(2100*76/90)=1773`. + - `plan_price_paid=150, duration_days=30, end_date=вчера` → `credit=0` (истекла). + +--- + +### Фаза 3 — Три ветки в `buy_subscription` + +**Файл:** `services/user_service.py` + +**Цель:** Переписать `buy_subscription` с явным разделением на три ветки: +- **Ветка A:** Пользователя нет в Remnawave → свежее создание. +- **Ветка B:** Активная подписка, тот же план → чистое продление (add days). +- **Ветка C:** Активная или истёкшая подписка, другой план → замена со зачётом (replace). + +**Возвращаемое значение:** Для поддержки UI в Фазе 4 функция возвращает именованный +кортеж / датакласс `BuyResult`, чтобы хендлер знал, был ли начислен кредит. + +```python +# services/user_service.py + +# Добавить импорты в начало файла: +import logging +import math +from dataclasses import dataclass +from datetime import datetime, UTC + +from services.subscription_calculator import is_same_plan, calculate_remaining_credit + +logger = logging.getLogger(__name__) + + +@dataclass +class BuyResult: + """Результат выполнения buy_subscription.""" + subscription: "Subscription" + credit_issued: int # 0 если зачёт не производился (продление / свежее создание) + is_renewal: bool # True если чистое продление того же плана + is_replace: bool # True если была замена плана (Ветка C) + + +class UserService: + # ... существующий код ... + + async def buy_subscription( + self, + session, + subscription: SubscriptionPlan, + rw_sdk, + user, + amount: int, # фактически рассчитанная и списанная сумма в рублях + ) -> BuyResult: + """ + Создаёт, продлевает или заменяет подписку пользователя. + + Три ветки: + A) rw_user не найден → create_user (свежая подписка) + B) rw_user активен, тот же план → add_days (чистое продление) + C) rw_user найден (активный или истёкший), другой план → replace + credit + """ + duration = convert_duration_to_timedelta(subscription.duration) + now = datetime.now(UTC) + + rw_user = await rw.get_user_by_telegram_id(rw_sdk, user.id) + + new_squads = [InternalSquads.DEFAULT] + if subscription.whitelists: + new_squads.append(InternalSquads.WHITELISTS) + + # ═══════════════════════════════════════════════════════════════════ + # ВЕТКА A — Пользователь не найден в Remnawave → свежее создание + # ═══════════════════════════════════════════════════════════════════ + if rw_user is None: + expire = now + duration + rw_user = await rw.create_user( + rw_sdk, + username=f"tg_{user.id}", + telegram_id=user.id, + expire_at=expire, + hwid_device_limit=subscription.devices, + squad_uuids=new_squads, + ) + if rw_user is None: + raise RuntimeError( + f"Remnawave create_user вернул None для tg_id={user.id}" + ) + + db_subscription = await self.subscription_repository.create_subscription( + session, + user_id=user.id, + end_date=expire, + autorenew=False, + status=SubscriptionStatus.ACTIVE, + devices=subscription.devices, + duration_days=duration.days, + plan_price_paid=amount, + whitelists=subscription.whitelists, + rw_uuid=rw_user.uuid, + ) + logger.info( + "Новая подписка создана: user_id=%s, devices=%s, days=%s, amount=%s₽", + user.id, subscription.devices, duration.days, amount, + ) + return BuyResult( + subscription=db_subscription, + credit_issued=0, + is_renewal=False, + is_replace=False, + ) + + # Определяем, активна ли подписка + is_expired = rw_user.expire_at <= now or rw_user.status in ("EXPIRED", "DISABLED") + + # ═══════════════════════════════════════════════════════════════════ + # ВЕТКА B — Активная подписка, ТОТ ЖЕ план → чистое продление + # ═══════════════════════════════════════════════════════════════════ + if not is_expired and is_same_plan(rw_user, subscription): + new_expire = rw_user.expire_at + duration + await rw.add_days(rw_sdk, rw_user.uuid, duration.days) + # Примечание: set_hwid_limit и update_squads не нужны — план не менялся. + # Они вызывались в Bug 2/3 фикс как «всегда обновлять», но здесь + # is_same_plan гарантирует, что значения в RW уже актуальны. + + db_subscription = await self.subscription_repository.update_sub( + session, + user_id=user.id, + end_date=new_expire, + autorenew=False, + status=SubscriptionStatus.ACTIVE, + # Обновляем финансовые поля для корректного расчёта следующего зачёта: + plan_price_paid=amount, + duration_days=duration.days, + ) + logger.info( + "Подписка продлена (тот же план): user_id=%s, new_expire=%s, amount=%s₽", + user.id, new_expire, amount, + ) + return BuyResult( + subscription=db_subscription, + credit_issued=0, + is_renewal=True, + is_replace=False, + ) + + # ═══════════════════════════════════════════════════════════════════ + # ВЕТКА C — Другой план (или истёкшая подписка) → замена со зачётом + # ═══════════════════════════════════════════════════════════════════ + + # Рассчитываем зачёт ТОЛЬКО для активных подписок с другим планом. + # Для истёкших — кредит = 0 (период уже закончился). + credit = 0 + if not is_expired: + db_sub = await self.subscription_repository.get_subscription_by_user_id( + session, user.id + ) + if db_sub: + credit = calculate_remaining_credit(db_sub, now) + + if credit > 0: + await self.user_repository.increase_user_balance(session, user.id, credit) + logger.info( + "Зачёт за смену плана: user_id=%s, credit=%s₽ | " + "старый план: devices=%s, whitelists=%s | " + "новый план: devices=%s, whitelists=%s", + user.id, credit, + db_sub.devices if db_sub else "?", + db_sub.whitelists if db_sub else "?", + subscription.devices, + subscription.whitelists, + ) + + # Целевой expire = сейчас + новая длительность (НЕ аддитивно к старому!) + new_expire = now + duration + + # Включаем пользователя, если он был отключён + if rw_user.status == "DISABLED": + await rw.enable_user(rw_sdk, rw_user.uuid) + + # Remnawave не имеет метода set_expire_at — используем add_days с дельтой. + # delta может быть отрицательным (даунгрейд + короткий период). + # Большинство реализаций RW SDK поддерживают отрицательное значение. + delta_days = (new_expire - rw_user.expire_at).days + if delta_days != 0: + await rw.add_days(rw_sdk, rw_user.uuid, delta_days) + + await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) + await rw.update_squads(rw_sdk, rw_user.uuid, new_squads) + + db_subscription = await self.subscription_repository.update_sub( + session, + user_id=user.id, + end_date=new_expire, + autorenew=False, + status=SubscriptionStatus.ACTIVE, + devices=subscription.devices, + duration_days=duration.days, + plan_price_paid=amount, + whitelists=subscription.whitelists, + ) + logger.info( + "Подписка заменена: user_id=%s, devices=%s, days=%s, amount=%s₽, credit=%s₽", + user.id, subscription.devices, duration.days, amount, credit, + ) + return BuyResult( + subscription=db_subscription, + credit_issued=credit, + is_renewal=False, + is_replace=True, + ) +``` + +**Конкретные шаги:** +1. Открыть `services/user_service.py`. +2. Добавить `@dataclass BuyResult` (или вынести в `schemas/subscriptions.py`). +3. Добавить импорты: `is_same_plan`, `calculate_remaining_credit` из нового модуля. +4. Полностью переписать метод `buy_subscription` согласно коду выше. +5. Убедиться, что `subscription_repository` имеет метод `get_subscription_by_user_id`. + Если нет — добавить в `repositories/subscriptions.py`. +6. Убедиться, что `user_repository` имеет метод `increase_user_balance`. + Если нет — добавить в `repositories/users.py` (симметрично `decrease_user_balance`). +7. Обновить хендлер `proceed_to_checkout` в `bot/handlers/buy.py`: + - `buy_subscription` теперь возвращает `BuyResult`, не `Subscription`. + - Передавать `amount=amount` как аргумент. + - Передавать `result.credit_issued` в логику отображения сообщения (Фаза 4). + +--- + +### Фаза 4 — UI: отображение зачёта пользователю + +**Файл:** `bot/handlers/buy.py` и `bot/texts.py` (или аналогичный файл с текстами) + +**Цель:** Показывать разные сообщения в зависимости от результата операции: +- Чистое создание или продление → стандартный текст (уже есть). +- Замена плана с зачётом → новый текст с упоминанием начисленного зачёта. +- Замена плана без зачёта (истёкшая подписка) → текст про повторную активацию. + +**Новые текстовые константы:** + +```python +# bot/texts.py (или аналог) + +# Уже существующий: +SUCCESSFULLY_CREATED_SUBSCRIPTION = ( + "✅ Подписка активирована!\n\n" + "Используйте кнопку ниже для получения конфигурации." +) + +# Новый — для чистого продления: +SUCCESSFULLY_RENEWED_SUBSCRIPTION = ( + "✅ Подписка продлена!\n\n" + "Срок действия вашей текущей подписки увеличен." +) + +# Новый — для замены плана с зачётом: +SUBSCRIPTION_REPLACED_WITH_CREDIT = ( + "✅ Тариф обновлён!\n\n" + "На ваш баланс зачислено {credit}₽ за неиспользованные дни прежнего тарифа.\n" + "Новая подписка активна с сегодняшнего дня." +) + +# Новый — для замены плана без зачёта (истёкшая): +SUBSCRIPTION_REACTIVATED = ( + "✅ Подписка возобновлена!\n\n" + "Ваша подписка была неактивна. Новый тариф действует с сегодняшнего дня." +) +``` + +**Обновлённый хендлер `proceed_to_checkout`:** + +```python +# bot/handlers/buy.py + +@router.callback_query(SubscriptionStorage.duration, F.data == "confirm") +async def proceed_to_checkout( + cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO +): + # Немедленно блокируем повторные нажатия (FINANCE_REFINEMENT Bug 6) + await state.set_state(SubscriptionStorage.checkout) + + data = await state.get_data() + ctx: SubscriptionPlan | None = data.get("ctx") + if not ctx: + await state.clear() + await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) + return + + amount = calculate_price(ctx) + user = await deps.user_repository.get_user_by_id(session, cb.from_user.id) + + # Проверка баланса ПЕРЕД операцией + # Примечание: после начисления кредита баланс вырастет, но мы не знаем credit заранее. + # Если баланс без кредита достаточен — пропускаем. Иначе — нужно отдельно обработать + # (см. Секцию 6 «Баланс после зачёта»). + if user.balance < amount: + await state.clear() + await cb.message.edit_text( + INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), + reply_markup=deposit_kb, + ) + return + + # 1. Создать/продлить/заменить подписку (Remnawave + DB subscription + credit) + try: + result: BuyResult = await deps.user_service.buy_subscription( + session, ctx, deps.rw_sdk, cb.from_user, amount=amount + ) + except Exception: + logger.exception( + "buy_subscription упал: user_id=%s, amount=%s. Баланс не тронут.", + cb.from_user.id, + amount, + ) + await state.clear() + await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu) + return + + # 2. Списать баланс (после успешной операции) + await deps.user_repository.decrease_user_balance(session, user.id, amount) + + await state.clear() + + # 3. Показать соответствующее сообщение + if result.is_renewal: + text = SUCCESSFULLY_RENEWED_SUBSCRIPTION + elif result.is_replace and result.credit_issued > 0: + text = SUBSCRIPTION_REPLACED_WITH_CREDIT.format(credit=result.credit_issued) + elif result.is_replace and result.credit_issued == 0: + text = SUBSCRIPTION_REACTIVATED + else: + text = SUCCESSFULLY_CREATED_SUBSCRIPTION + + await cb.message.edit_text(text, reply_markup=return_to_menu) +``` + +**Конкретные шаги:** +1. Добавить три новых константы в файл с текстами. +2. Обновить `proceed_to_checkout` согласно коду выше. +3. Убедиться, что `BuyResult` импортирован из `services/user_service.py`. +4. Проверить, что `INSUFFICIENT_FUNDS` и `deposit_kb` импортированы. + +--- + +### Фаза 5 — Граничный случай: истёкшая подписка в Remnawave + +**Файл:** `services/user_service.py` + +**Цель:** Корректно обработать ситуацию, когда `get_user_by_telegram_id` возвращает +`RWUserInfo`, но пользователь уже неактивен (`status == "EXPIRED"` или +`expire_at <= now`). + +**Поведение (уже заложено в Ветку C Фазы 3):** +- `is_expired = rw_user.expire_at <= now or rw_user.status in ("EXPIRED", "DISABLED")` +- Если `is_expired=True` → кредит = 0, `new_expire = now + duration`. +- Если `status == "DISABLED"` → вызвать `enable_user` перед обновлением. +- `delta_days = (new_expire - rw_user.expire_at).days` корректно считается как положительное + число (например, если expire_at был 5 дней назад, а мы добавляем 30, delta=35). + +**Специальный случай: статус `"EXPIRED"` в Remnawave** + +Некоторые версии Remnawave при истечении подписки не удаляют пользователя, а меняют +его статус на `"EXPIRED"`. В этом случае: + +```python +# Явная проверка и включение пользователя если нужно +if rw_user.status in ("DISABLED", "EXPIRED"): + await rw.enable_user(rw_sdk, rw_user.uuid) + # enable_user должен переводить пользователя в ACTIVE; + # иначе нужно добавить хелпер set_expire_at или использовать update_user напрямую. +``` + +**Конкретные шаги:** +1. Убедиться, что Ветка C (Фаза 3) содержит проверку `rw_user.status in ("EXPIRED", "DISABLED")`. +2. Проверить, что `rw.enable_user` действительно реактивирует истёкшего пользователя + в используемой версии Remnawave SDK (зависит от конфигурации панели). +3. Если `enable_user` не переводит из `EXPIRED` в `ACTIVE` автоматически — добавить + хелпер `rw.reset_expire(sdk, uuid, new_expire: datetime)` поверх нужного API-эндпоинта RW. +4. Логировать факт реактивации отдельно от обычной замены плана: + ```python + logger.info( + "Реактивация истёкшей подписки: user_id=%s, old_expire=%s, new_expire=%s", + user.id, rw_user.expire_at, new_expire, + ) + ``` + +--- + +## 5. Изменения модели данных + +### 5.1 Дополнение к Gap 7 из FINANCE_REFINEMENT + +FINANCE_REFINEMENT Фаза 3 (Gap 7) добавляет в `Subscription` поля: +`devices`, `duration_months`, `plan_price_paid`, `whitelists`, `rw_uuid`. + +**Настоящий документ требует одно дополнительное поле:** `duration_days`. + +Обоснование: `duration_months` нужен для автопродления (повторить тот же срок). +`duration_days` нужен для расчёта зачёта (формула использует дни, а не месяцы, +потому что месяцы имеют разное количество дней и кредит должен быть точным). + +| Поле | Тип | NULL? | Назначение | +|------|-----|-------|-----------| +| `devices` | `int` | NOT NULL | Кол-во HWID-устройств в плане. Для автопродления и `is_same_plan`. | +| `duration_months` | `int` | NOT NULL | Срок в месяцах (1, 3, 6, 12). Для автопродления. | +| `duration_days` | `int` | NOT NULL | Фактические дни оплаченного периода (30, 90, 180, 365). Для `calculate_remaining_credit`. | +| `plan_price_paid` | `int` | NOT NULL | Списанная сумма в рублях. Для зачёта и аудита. | +| `whitelists` | `bool` | NOT NULL, default False | Whitelist addon в плане. Для `is_same_plan` и автопродления. | +| `rw_uuid` | `str` | NULL | UUID в Remnawave. Для прямых обращений без повторного GET. | + +### 5.2 Обновление модели SQLAlchemy + +```python +# db/models/subscriptions.py + +from sqlalchemy import Boolean, Integer, Text +from sqlalchemy.orm import Mapped, mapped_column + +class Subscription(Base): + __tablename__ = "subscriptions" + + user_id: Mapped[int] = mapped_column(primary_key=True) + end_date: Mapped[datetime] = mapped_column(nullable=False) + autorenew: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + status: Mapped[SubscriptionStatus] = mapped_column(nullable=False) + + # ── Поля плана (добавлены в FINANCE_REFINEMENT Gap 7 + настоящий документ) ── + devices: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + duration_months: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + duration_days: Mapped[int] = mapped_column(Integer, nullable=False, default=30) + plan_price_paid: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + whitelists: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + rw_uuid: Mapped[str | None] = mapped_column(Text, nullable=True, unique=True) +``` + +### 5.3 Alembic-миграция + +Если FINANCE_REFINEMENT Gap 7 уже применён (без `duration_days`), создать +отдельную миграцию: + +```bash +alembic revision --autogenerate -m "add_duration_days_to_subscriptions" +``` + +Проверить и при необходимости скорректировать автогенерированный файл: + +```python +# alembic/versions/xxxx_add_duration_days_to_subscriptions.py + +def upgrade() -> None: + op.add_column( + "subscriptions", + sa.Column("duration_days", sa.Integer(), nullable=False, server_default="30"), + ) + # Удалить server_default после заполнения существующих строк (опционально): + # op.alter_column("subscriptions", "duration_days", server_default=None) + +def downgrade() -> None: + op.drop_column("subscriptions", "duration_days") +``` + +Если Gap 7 ещё не применялся — добавить `duration_days` в ту же миграцию вместе +с остальными полями Gap 7. + +### 5.4 Обновление `subscription_repository` + +```python +# repositories/subscriptions.py + +async def create_subscription( + self, + session, + *, + user_id: int, + end_date: datetime, + autorenew: bool, + status: SubscriptionStatus, + devices: int, + duration_months: int, + duration_days: int, + plan_price_paid: int, + whitelists: bool, + rw_uuid: str | None = None, +) -> Subscription: + sub = Subscription( + user_id=user_id, + end_date=end_date, + autorenew=autorenew, + status=status, + devices=devices, + duration_months=duration_months, + duration_days=duration_days, + plan_price_paid=plan_price_paid, + whitelists=whitelists, + rw_uuid=rw_uuid, + ) + session.add(sub) + await session.flush() + return sub + + +async def update_sub( + self, + session, + *, + user_id: int, + end_date: datetime | None = None, + autorenew: bool | None = None, + status: SubscriptionStatus | None = None, + devices: int | None = None, + duration_months: int | None = None, + duration_days: int | None = None, + plan_price_paid: int | None = None, + whitelists: bool | None = None, +) -> Subscription | None: + sub = await self.get_subscription_by_user_id(session, user_id) + if sub is None: + return None + if end_date is not None: + sub.end_date = end_date + if autorenew is not None: + sub.autorenew = autorenew + if status is not None: + sub.status = status + if devices is not None: + sub.devices = devices + if duration_months is not None: + sub.duration_months = duration_months + if duration_days is not None: + sub.duration_days = duration_days + if plan_price_paid is not None: + sub.plan_price_paid = plan_price_paid + if whitelists is not None: + sub.whitelists = whitelists + await session.flush() + return sub +``` + +--- + +## 6. Граничные случаи и обработка ошибок + +### 6.1 Remnawave недоступен во время замены + +**Проблема:** `buy_subscription` уже начислил кредит (`increase_user_balance`), но +следующий вызов `rw.add_days` / `rw.set_hwid_limit` / `rw.update_squads` упал с +исключением. + +**Решение:** Порядок операций в Ветке C критически важен: + +``` +1. calculate_remaining_credit() ← только вычисление, без side effects +2. increase_user_balance(credit) ← DB операция +3. rw.enable_user() если нужно ← RW +4. rw.add_days(delta) ← RW ← если здесь падает: +5. rw.set_hwid_limit() ← RW кредит уже начислен! +6. rw.update_squads() ← RW +7. update_sub() в БД ← DB +``` + +Если шаги 4–6 падают, кредит уже на балансе, но подписка в RW не обновлена. +Пользователь не в убытке (деньги вернулись), а оператор получает ошибку в логах. + +**Рекомендация:** Обернуть шаги 4–6 в отдельный `try/except` с компенсирующей +транзакцией — снять начисленный кредит обратно при неудаче RW: + +```python +# Ветка C: обёртка для компенсации +try: + if rw_user.status == "DISABLED": + await rw.enable_user(rw_sdk, rw_user.uuid) + if delta_days != 0: + await rw.add_days(rw_sdk, rw_user.uuid, delta_days) + await rw.set_hwid_limit(rw_sdk, rw_user.uuid, subscription.devices) + await rw.update_squads(rw_sdk, rw_user.uuid, new_squads) +except Exception as rw_error: + # RW недоступен — откатываем кредит + if credit > 0: + await self.user_repository.decrease_user_balance(session, user.id, credit) + logger.warning( + "Кредит %s₽ отозван для user_id=%s: RW вызов упал (%s)", + credit, user.id, rw_error, + ) + raise # Передаём исключение в хендлер, который не будет списывать баланс +``` + +Это обеспечивает полный откат: кредит не начислен, баланс (для покупки) не списан, +подписка не изменена — состояние пользователя как до нажатия кнопки. + +--- + +### 6.2 Отрицательный `delta_days` (даунгрейд с одновременным сокращением срока) + +**Пример:** У пользователя 6 устройств, 60 дней остатка. Покупает 3 устройства на +30 дней. `new_expire = now + 30 дней`, `delta_days = (now+30) - (now+60) = -30`. + +Remnawave SDK принимает отрицательные значения `add_days` в большинстве реализаций +(внутри вызывается `expire_at += timedelta(days=days)`, что работает для отрицательных). + +**Действие:** Перед выпуском в продакшен проверить на тестовом Remnawave-инстансе, +что вызов `add_days(uuid, -30)` корректно уменьшает срок. Если нет — добавить хелпер: + +```python +# services/rw.py — добавить если нужно: +async def set_expire_at(sdk, user_uuid: str, expire_at: datetime) -> bool: + """ + Устанавливает точную дату истечения через прямой PATCH /users/{uuid}. + Использовать если add_days с отрицательным значением не поддерживается. + """ + try: + result = await sdk.users.update_user(uuid=user_uuid, expire_at=expire_at) + return result is not None + except Exception as e: + logger.warning("set_expire_at failed for %s: %s", user_uuid, e) + return False +``` + +--- + +### 6.3 Зачёт делает покупку «доступной» (баланс + кредит ≥ цены) + +**Сценарий:** У пользователя 50₽ на балансе. Хочет купить план за 300₽. Баланс проверяется +в хендлере: 50 < 300 → «Недостаточно средств». Но у пользователя есть активная подписка, +за которую при замене он получил бы 140₽ кредита. 50 + 140 = 190 < 300 → всё равно нет. + +Это не является багом — зачёт начисляется **только внутри `buy_subscription`**, поэтому +хендлер всегда проверяет баланс **без учёта будущего кредита**. Это консервативная, +безопасная проверка. + +Если требуется «провести» пользователя несмотря на недостаточный баланс при условии +достаточности (баланс + ожидаемый кредит ≥ цены), это потребует отдельного API-вызова +для предварительного расчёта кредита в хендлере до проверки баланса. **Не реализовывать +без явного требования от продукта** — усложняет код и может привести к race condition. + +--- + +### 6.4 Округление зачёта + +Формула использует `math.floor`, что всегда даёт значение ≤ «математически точного» кредита. +Это означает, что бизнес иногда получает на 1₽ больше, чем строго пропорционально. Это +намеренно и является стандартной практикой (дробных рублей не существует, округление в пользу +бизнеса корректно для платёжных систем). + +Пример: `plan_price_paid=150, remaining_days=29, duration_days=30` +→ `credit = floor(150 × 29 / 30) = floor(145.0) = 145₽` (не 145₽ 0 коп). + +--- + +### 6.5 Конкурентные покупки (двойное нажатие) + +Обрабатывается FSM-блокировкой из FINANCE_REFINEMENT Фаза 2 (Bug 6): состояние +переключается на `SubscriptionStorage.checkout` **первым действием** в хендлере, что +делает повторный callback необрабатываемым. + +Дополнительная защита на уровне БД: если два запроса всё же прошли одновременно, +`update_sub` работает по `user_id` (PK) — второй UPDATE применится поверх первого, +а не создаст дубль. Кредит может быть начислен дважды только при параллельном исполнении +обоих `buy_subscription` одновременно — FSM-lock предотвращает это. + +--- + +### 6.6 Пользователь существует в БД, но отсутствует в Remnawave + +**Сценарий:** Запись в `subscriptions` есть, но `get_user_by_telegram_id` вернул `None` +(пользователь удалён вручную из панели администратором). + +**Поведение текущего кода:** Выполняется ветка `create_user` — создаётся новый RW-пользователь. +Это корректно. Запись в `subscriptions` будет обновлена с новым `rw_uuid`. + +**Единственный риск:** Если у старого RW-пользователя было активное соединение, оно будет +потеряно. Пользователю нужно будет скачать новую конфигурацию. Это приемлемо как редкий +случай ручного вмешательства администратора. + +--- + +## 7. Сводная таблица задач + +| # | Задача | Фаза | Файлы | Зависимость | Сложность | +|---|--------|------|-------|-------------|-----------| +| SF-0 | Завершить P1, P2, P3 (FINANCE_REFINEMENT) | 0 | Различные | — | 🔴 Средняя | +| SF-1 | Создать `subscription_calculator.py` + `is_same_plan` | 1 | `services/subscription_calculator.py` | SF-0 | 🟡 Лёгкая | +| SF-2 | Добавить `calculate_remaining_credit` | 2 | `services/subscription_calculator.py` | SF-1 | 🟡 Лёгкая | +| SF-3 | Добавить `BuyResult` датакласс | 3 | `services/user_service.py` | SF-2 | 🟢 Мин | +| SF-4 | Переписать `buy_subscription` (3 ветки) | 3 | `services/user_service.py` | SF-3 | 🔴 Средняя | +| SF-5 | Добавить `increase_user_balance` в репозиторий | 3 | `repositories/users.py` | SF-0 | 🟢 Мин | +| SF-6 | Добавить `get_subscription_by_user_id` в репозиторий | 3 | `repositories/subscriptions.py` | SF-0 | 🟢 Мин | +| SF-7 | Обновить `proceed_to_checkout` (BuyResult + amount) | 4 | `bot/handlers/buy.py` | SF-4 | 🟡 Лёгкая | +| SF-8 | Добавить новые текстовые константы | 4 | `bot/texts.py` | SF-7 | 🟢 Мин | +| SF-9 | Обработка статусов EXPIRED/DISABLED в Ветке C | 5 | `services/user_service.py` | SF-4 | 🟢 Мин | +| SF-10 | Добавить `duration_days` в миграцию Alembic | 5 | `alembic/versions/` | SF-0 | 🟡 Лёгкая | +| SF-11 | Unit-тесты для `subscription_calculator.py` | — | `tests/` | SF-2 | 🟡 Лёгкая | +| SF-12 | Проверить `add_days(negative)` в RW на тест-инстансе | — | `services/rw.py` | SF-4 | 🟢 Мин | + +**Обозначения сложности:** +- 🟢 Минимальная — 1–15 строк, без новых зависимостей +- 🟡 Лёгкая — до 60 строк, возможно новый файл или небольшой рефакторинг +- 🔴 Средняя — существенная переработка существующей логики или новая миграция + +--- + +## 8. Рекомендуемый порядок реализации + +### Спринт 1 — Предварительные условия + новый модуль (~1–2 дня) + +Выполнить Фазу 0 (если FINANCE_REFINEMENT ещё не реализован) или убедиться, что +все три prerequisite выполнены. Затем: +- Создать `services/subscription_calculator.py` с `is_same_plan` и + `calculate_remaining_credit` (SF-1, SF-2). +- Написать unit-тесты для обеих функций (SF-11). +- Добавить `duration_days` в модель и миграцию (SF-10). + +### Спринт 2 — Переработка `buy_subscription` (~1–2 дня) + +- Добавить `BuyResult`, `increase_user_balance`, `get_subscription_by_user_id` (SF-3, SF-5, SF-6). +- Переписать `buy_subscription` с тремя ветками (SF-4). +- Добавить обработку EXPIRED/DISABLED (SF-9). +- Проверить `add_days(negative)` на тест-инстансе Remnawave (SF-12). + +### Спринт 3 — UI и финализация (~0.5 дня) + +- Обновить `proceed_to_checkout` для работы с `BuyResult` (SF-7). +- Добавить новые текстовые константы (SF-8). +- Сквозное тестирование всех трёх сценариев: создание, продление, замена. + +--- + +> **Итоговый инвариант системы после реализации:** +> +> - Пользователь **никогда не получает бесплатный апгрейд** за счёт бизнеса. +> - Пользователь **никогда не теряет оплаченные дни** без компенсации. +> - Срок подписки при смене плана всегда равен `now + new_duration` (не аддитивен к остатку). +> - Кредит отображается явно — прозрачно для пользователя. +> - При недоступности Remnawave — состояние пользователя остаётся неизменным. + +--- + +*Документ создан на основе анализа кодовой базы и описания проблемы. Актуализировать +при изменении API Remnawave или схемы тарификации.* \ No newline at end of file diff --git a/repositories/bills.py b/repositories/bills.py index 275c415..1516f44 100644 --- a/repositories/bills.py +++ b/repositories/bills.py @@ -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() diff --git a/repositories/subscriptions.py b/repositories/subscriptions.py new file mode 100644 index 0000000..9695608 --- /dev/null +++ b/repositories/subscriptions.py @@ -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() diff --git a/repositories/transactions.py b/repositories/transactions.py new file mode 100644 index 0000000..f7126d4 --- /dev/null +++ b/repositories/transactions.py @@ -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()) diff --git a/repositories/users.py b/repositories/users.py index ebe1363..70bdfb4 100644 --- a/repositories/users.py +++ b/repositories/users.py @@ -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 diff --git a/schemas/di.py b/schemas/di.py index 19b77ac..38c1c52 100644 --- a/schemas/di.py +++ b/schemas/di.py @@ -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,10 +14,11 @@ 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 diff --git a/schemas/subscriptions.py b/schemas/subscriptions.py index cc9abc1..2bcc004 100644 --- a/schemas/subscriptions.py +++ b/schemas/subscriptions.py @@ -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 diff --git a/services/rw.py b/services/rw.py index 34c8e47..7abd37a 100644 --- a/services/rw.py +++ b/services/rw.py @@ -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 diff --git a/services/user_service.py b/services/user_service.py index d9da2e4..80651d5 100644 --- a/services/user_service.py +++ b/services/user_service.py @@ -1,6 +1,7 @@ +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 @@ -12,8 +13,10 @@ from bot.texts import ( 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.rw import RWUserInfo, get_user_by_telegram_id +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 diff --git a/tests/send_pally_webhook.py b/tests/send_pally_webhook.py new file mode 100644 index 0000000..8ba5d1c --- /dev/null +++ b/tests/send_pally_webhook.py @@ -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()