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