Compare commits

...

8 Commits

18 changed files with 483 additions and 104 deletions

View File

@@ -11,6 +11,11 @@ BOT_TOKEN=your_telegram_bot_token
# Comma-separated list of Telegram user IDs # Comma-separated list of Telegram user IDs
ADMINS=[123456789,987654321] ADMINS=[123456789,987654321]
# Optional admin operation logs. Leave empty to disable.
ADMIN_LOG_CHAT_ID=
ADMIN_LOG_DEPOSIT_TOPIC_ID=
ADMIN_LOG_SUBSCRIPTIONS_TOPIC_ID=
# Pally # Pally
PALLY_SHOP_ID=your_pally_shop_id PALLY_SHOP_ID=your_pally_shop_id
PALLY_TOKEN=your_pally_token PALLY_TOKEN=your_pally_token
@@ -40,6 +45,3 @@ WHITELIST_SQUAD=e4435baa-64d3-4b29-a18e-81ad1c60d977
# Autorenewal # Autorenewal
AUTORENEW_DAYS_BEFORE=1 AUTORENEW_DAYS_BEFORE=1
# Autorenewal
AUTORENEW_DAYS_BEFORE=1

4
.gitignore vendored
View File

@@ -174,4 +174,6 @@ cython_debug/
.ruff_cache/ .ruff_cache/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
plans

View File

@@ -1,4 +1,6 @@
# ruff: noqa: N803
import hashlib import hashlib
import hmac
import logging import logging
import math import math
@@ -14,6 +16,7 @@ from db.models.bills import DepositStatus
from db.models.transactions import BalanceTxType from db.models.transactions import BalanceTxType
from misc.pally import BillStatus from misc.pally import BillStatus
from schemas.di import APIDependenciesDTO from schemas.di import APIDependenciesDTO
from services.admin_notifications import notify_deposit
router = APIRouter(prefix="/pally") router = APIRouter(prefix="/pally")
@@ -21,7 +24,7 @@ logger = logging.getLogger(__name__)
@router.post("/result") @router.post("/result")
async def pally_callback( async def pally_callback( # noqa: PLR0911, PLR0912
InvId: str = Form(...), InvId: str = Form(...),
OutSum: str = Form(...), OutSum: str = Form(...),
Commission: str = Form(...), Commission: str = Form(...),
@@ -48,31 +51,43 @@ async def pally_callback(
# FIXED: Use InvId (which contains the order_id from bill creation) instead of custom field # FIXED: Use InvId (which contains the order_id from bill creation) instead of custom field
# The InvId field contains the db_bill.id that was passed as order_id during bill creation # The InvId field contains the db_bill.id that was passed as order_id during bill creation
bill_id_str = InvId bill_id_str = InvId
logger.info( logger.info(
"Pally webhook received - InvId: %s, OutSum: %s, Commission: %s, TrsId: %s, Status: %s, " "Pally webhook received - InvId: %s, OutSum: %s, Commission: %s, TrsId: %s, Status: %s, "
"CurrencyIn: %s, custom: %s, BalanceAmount: %s, SignatureValue: %s", "CurrencyIn: %s, custom: %s, BalanceAmount: %s, SignatureValue: %s",
InvId, OutSum, Commission, TrsId, Status, CurrencyIn, custom, BalanceAmount, SignatureValue InvId,
OutSum,
Commission,
TrsId,
Status,
CurrencyIn,
custom,
BalanceAmount,
SignatureValue,
) )
# Enhanced logging for failed payments # Enhanced logging for failed payments
if Status != BillStatus.SUCCESS: if Status != BillStatus.SUCCESS:
logger.warning( logger.warning(
"Non-success payment received - Status: %s, ErrorCode: %s, ErrorMessage: %s, TrsId: %s", "Non-success payment received - Status: %s, ErrorCode: %s, ErrorMessage: %s, TrsId: %s",
Status, ErrorCode, ErrorMessage, TrsId Status,
ErrorCode,
ErrorMessage,
TrsId,
) )
# Validate signature # Validate signature
raw_string = f"{OutSum}:{InvId}:{settings.pally_token}" raw_string = f"{OutSum}:{InvId}:{settings.pally_token}"
expected_signature = hashlib.md5(raw_string.encode("utf-8")).hexdigest().upper() expected_signature = hashlib.md5(raw_string.encode("utf-8")).hexdigest().upper()
logger.debug("Signature validation - Expected: %s, Received: %s, Raw: %s",
expected_signature, SignatureValue, raw_string)
if SignatureValue != expected_signature: logger.debug("Signature validation for TrsId %s", TrsId)
if not hmac.compare_digest(SignatureValue, expected_signature):
logger.critical( logger.critical(
"SECURITY ALERT: Invalid signature for TrsId %s - Expected: %s, Received: %s, Raw: %s", "SECURITY ALERT: Invalid signature for TrsId %s - Expected: %s, Received: %s",
TrsId, expected_signature, SignatureValue, raw_string TrsId,
expected_signature,
SignatureValue,
) )
raise HTTPException(403, detail="Invalid signature.") raise HTTPException(403, detail="Invalid signature.")
@@ -86,8 +101,7 @@ async def pally_callback(
# Validate bill ID (from InvId field, which contains the order_id from bill creation) # Validate bill ID (from InvId field, which contains the order_id from bill creation)
if not bill_id_str or not bill_id_str.isdigit(): if not bill_id_str or not bill_id_str.isdigit():
logger.critical( logger.critical(
"Invalid or non-numeric bill ID in InvId field for TrsId %s: '%s'", "Invalid or non-numeric bill ID in InvId field for TrsId %s: '%s'", TrsId, bill_id_str
TrsId, bill_id_str
) )
return "OK" return "OK"
@@ -100,8 +114,9 @@ async def pally_callback(
# Check if already processed # Check if already processed
if bill.status != DepositStatus.PENDING: if bill.status != DepositStatus.PENDING:
logger.warning("Bill %s (TrsId: %s) is already processed with status: %s", logger.warning(
bill.id, TrsId, bill.status) "Bill %s (TrsId: %s) is already processed with status: %s", bill.id, TrsId, bill.status
)
return "OK" return "OK"
try: try:
@@ -111,43 +126,59 @@ async def pally_callback(
# Customer pays fees - BalanceAmount is the net amount credited to merchant # Customer pays fees - BalanceAmount is the net amount credited to merchant
credited_amount = int(float(BalanceAmount)) credited_amount = int(float(BalanceAmount))
gross_amount = int(float(OutSum)) gross_amount = int(float(OutSum))
logger.info( logger.info(
"Customer-pays-fees payment: bill_id=%s, expected=%s, gross_paid=%s, net_credited=%s", "Customer-pays-fees payment: bill_id=%s, expected=%s, gross_paid=%s, net_credited=%s",
bill.id, bill.amount, gross_amount, credited_amount bill.id,
bill.amount,
gross_amount,
credited_amount,
) )
# Validate that the net credited amount matches our bill amount # Validate that the net credited amount matches our bill amount
if bill.amount != credited_amount: if bill.amount != credited_amount:
logger.error( logger.error(
"Net amount mismatch for bill %s (TrsId: %s) - Expected: %s, Net credited: %s, Gross paid: %s", "Net amount mismatch for bill %s (TrsId: %s) - Expected: %s, Net credited: %s, Gross paid: %s",
bill.id, TrsId, bill.amount, credited_amount, gross_amount bill.id,
TrsId,
bill.amount,
credited_amount,
gross_amount,
) )
return "OK" return "OK"
amount = credited_amount # Credit the net amount (without fees) amount = credited_amount # Credit the net amount (without fees)
else: else:
# Standard payment - OutSum should match bill amount exactly # Standard payment - OutSum should match bill amount exactly
amount = int(float(OutSum)) amount = int(float(OutSum))
logger.info( logger.info(
"Standard payment: bill_id=%s, expected=%s, received=%s", "Standard payment: bill_id=%s, expected=%s, received=%s",
bill.id, bill.amount, amount bill.id,
bill.amount,
amount,
) )
if bill.amount != amount: if bill.amount != amount:
logger.error( logger.error(
"Amount mismatch for bill %s (TrsId: %s) - Expected: %s, Received: %s", "Amount mismatch for bill %s (TrsId: %s) - Expected: %s, Received: %s",
bill.id, TrsId, bill.amount, amount bill.id,
TrsId,
bill.amount,
amount,
) )
return "OK" return "OK"
logger.info("Processing payment: bill_id=%s, user_id=%s, amount=%s", logger.info(
bill.id, bill.creator_id, amount) "Processing payment: bill_id=%s, user_id=%s, amount=%s",
bill.id,
bill.creator_id,
amount,
)
# Credit user balance # Credit user balance
await deps.user_repository.increase_user_balance( credited_user = await deps.user_repository.increase_user_balance(
session, session,
bill.creator_id, bill.creator_id,
amount=amount, amount=amount,
@@ -155,6 +186,16 @@ async def pally_callback(
description=f"payment via PALLY (TrsId: {TrsId})", description=f"payment via PALLY (TrsId: {TrsId})",
) )
await successful_payment_notification(bot, bill.creator_id, amount) await successful_payment_notification(bot, bill.creator_id, amount)
if credited_user:
await notify_deposit(
bot,
user_id=bill.creator_id,
amount=amount,
balance_before=credited_user.balance - amount,
balance_after=credited_user.balance,
bill_id=bill.id,
payment_id=TrsId,
)
# Process referral bonus # Process referral bonus
user = await deps.user_repository.get_user_by_id(session, bill.creator_id) user = await deps.user_repository.get_user_by_id(session, bill.creator_id)
@@ -169,14 +210,19 @@ async def pally_callback(
description=f"referral reward for user {bill.creator_id} (TrsId: {TrsId})", description=f"referral reward for user {bill.creator_id} (TrsId: {TrsId})",
) )
await successful_payment_notification(bot, referal, referal_amount) await successful_payment_notification(bot, referal, referal_amount)
logger.info("Referral bonus processed: referrer_id=%s, amount=%s", referal, referal_amount) logger.info(
"Referral bonus processed: referrer_id=%s, amount=%s", referal, referal_amount
)
logger.info("Payment processing completed successfully for TrsId %s", TrsId) logger.info("Payment processing completed successfully for TrsId %s", TrsId)
except Exception as e: except Exception as e:
logger.exception( logger.exception(
"CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s", "CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s",
TrsId, bill.id, bill.creator_id, str(e) TrsId,
bill.id,
bill.creator_id,
str(e),
) )
# Don't return early - still mark as success to prevent retries # Don't return early - still mark as success to prevent retries
# The balance operation might have partially succeeded # The balance operation might have partially succeeded
@@ -185,7 +231,7 @@ async def pally_callback(
await deps.bills_repository.update_bill_status( await deps.bills_repository.update_bill_status(
session, int(bill.id), status=DepositStatus.SUCCESS session, int(bill.id), status=DepositStatus.SUCCESS
) )
logger.info("Bill %s marked as SUCCESS for TrsId %s", bill.id, TrsId) logger.info("Bill %s marked as SUCCESS for TrsId %s", bill.id, TrsId)
return "OK" return "OK"

View File

@@ -29,6 +29,7 @@ from misc.utils import (
) )
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from schemas.subscriptions import SubscriptionPlan from schemas.subscriptions import SubscriptionPlan
from services.admin_notifications import notify_subscription_purchase
router = Router() router = Router()
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -223,4 +224,18 @@ async def proceed_to_checkout(
amount, amount,
) )
if deducted_user:
await notify_subscription_purchase(
cb.bot,
user_id=user.id,
username=cb.from_user.username,
amount=amount,
balance_before=deducted_user.balance + amount,
balance_after=deducted_user.balance,
devices=ctx.devices,
duration_days=convert_duration_to_int(ctx.duration) * 30,
whitelists=ctx.whitelists,
credit=credit,
)
await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu) await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu)

View File

@@ -4,20 +4,40 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import close_kb, main_menu, subscription_controls from bot.keyboards.client import (
close_kb,
construct_devices_list,
main_menu,
prompt_hwid_deletion_kb,
subscription_controls,
)
from bot.keyboards.common import return_to_menu from bot.keyboards.common import return_to_menu
from bot.texts import ( from bot.texts import (
CALLBACK_FALLBACK, CALLBACK_FALLBACK,
FAQ_TEXT, FAQ_TEXT,
GENERAL_PROCESSING, GENERAL_PROCESSING,
HWID_LIST,
NO_CONNECTIONS,
NO_SUBSCRIPTION, NO_SUBSCRIPTION,
PROMPT_HWID_DELETION,
STANDARD_FALLBACK,
SUCCESSFUL_HWID_DELETION,
) )
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from misc.utils import build_main_menu_text, format_subscription_link from misc.utils import (
build_main_menu_text,
convert_duration_to_int,
format_subscription_link,
)
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from schemas.subscriptions import InternalSquads, SubscriptionPlan from schemas.subscriptions import (
from services.rw import get_user_by_telegram_id InternalSquads,
SubscriptionDuration,
SubscriptionPlan,
SubscriptionStatus,
)
from services.rw import delete_hwid, get_hwid_list, get_user_by_telegram_id
router = Router() router = Router()
@@ -34,7 +54,7 @@ async def fetch_referal(
data = command.args.split("_") data = command.args.split("_")
if not data: if not data:
await standard_start(msg, command, state) await standard_start(msg, state, session, deps)
return return
referal = int(data[1]) referal = int(data[1])
@@ -126,28 +146,43 @@ async def sub_info(
return return
if not user: if not user:
await cb.answer("1" + CALLBACK_FALLBACK) await cb.answer(CALLBACK_FALLBACK)
return return
dto = SubscriptionPlan(
devices=max(rw_user.hwid_device_limit, 3),
whitelists=bool(rw_user.active_squads.count(InternalSquads.WHITELISTS)),
)
if rw_user and not sub: if rw_user and not sub:
try: try:
dto = SubscriptionPlan(
devices=rw_user.hwid_device_limit,
whitelists=bool(rw_user.active_squads.count(InternalSquads.WHITELISTS)),
discount=0,
)
sub = await deps.subscriptions_repository.create_subscription( sub = await deps.subscriptions_repository.create_subscription(
session, session,
user_id=user.id, user_id=user.id,
end_date=rw_user.expire_at, end_date=rw_user.expire_at,
devices=rw_user.hwid_device_limit, devices=dto.devices,
duration_days=30, duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
whitelists=dto.whitelists, whitelists=dto.whitelists,
plan_price_paid=utils.calculate_price(dto), plan_price_paid=utils.calculate_price(dto),
) )
except Exception: except Exception:
await cb.answer("2" + CALLBACK_FALLBACK) await cb.answer(CALLBACK_FALLBACK)
return return
else:
await deps.subscriptions_repository.update_sub(
session,
cb.from_user.id,
status=(
SubscriptionStatus.ACTIVE
if rw_user.status == "ACTIVE"
else SubscriptionStatus.EXPIRED
),
end_date=rw_user.expire_at,
devices=dto.devices,
whitelists=dto.whitelists,
duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
plan_price_paid=utils.calculate_price(dto),
autorenew=sub.autorenew
)
await cb.message.edit_text( await cb.message.edit_text(
deps.user_service.format_subscription_message( deps.user_service.format_subscription_message(
@@ -159,6 +194,58 @@ async def sub_info(
) )
@router.callback_query(F.data == "hwid_control")
async def hwid_control(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO):
await state.clear()
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
if not rw_user:
await cb.message.edit_text(NO_SUBSCRIPTION, reply_markup=return_to_menu)
return
devices = await get_hwid_list(deps.rw_sdk, rw_user.uuid)
if not devices:
await cb.message.edit_text(NO_CONNECTIONS, reply_markup=return_to_menu)
return
hwid_limit = max(rw_user.hwid_device_limit, 3)
await cb.message.edit_text(
text=HWID_LIST.format(count=len(devices), limit=hwid_limit),
reply_markup=construct_devices_list(devices),
)
@router.callback_query(F.data.startswith("hwid:"))
async def prompt_delete_hwid(cb: CallbackQuery, state: FSMContext):
await state.clear()
hwid = cb.data.split(":")[1]
await cb.message.edit_text(PROMPT_HWID_DELETION, reply_markup=prompt_hwid_deletion_kb(hwid))
@router.callback_query(F.data.startswith("delete_hwid:"))
async def delete_hwid_bot(cb: CallbackQuery, state: FSMContext, deps: DependenciesDTO):
await state.clear()
hwid = cb.data.split(":")[1]
await cb.message.edit_text(GENERAL_PROCESSING, reply_markup=close_kb)
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
if not rw_user:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
return
status = await delete_hwid(deps.rw_sdk, rw_user.uuid, hwid)
if status:
await cb.message.edit_text(SUCCESSFUL_HWID_DELETION, reply_markup=return_to_menu)
else:
await cb.message.edit_text(STANDARD_FALLBACK, reply_markup=return_to_menu)
@router.callback_query(F.data == "toggle:autorenewal") @router.callback_query(F.data == "toggle:autorenewal")
async def toggle_autorenewal( async def toggle_autorenewal(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
@@ -191,19 +278,3 @@ async def toggle_autorenewal(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
), ),
) )
rw_user = await get_user_by_telegram_id(deps.rw_sdk, cb.from_user.id)
user: User | None = await deps.user_service.update_user_link(
session, cb.from_user.id, deps.rw_sdk
)
status_text = "включено" if new_autorenew else "отключено"
await cb.answer(f"Автопродление {status_text}")
await cb.message.edit_text(
deps.user_service.format_subscription_message(
rw_user, link=user.subscription_link, autorenew=sub.autorenew
),
reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
)

View File

@@ -1,4 +1,4 @@
from .common import router as common_router from .common import router as common_router
from .prehandling import router as prehandling_router from .prehandling import router as prehandling_router
routers = [common_router, prehandling_router] routers = [prehandling_router, common_router]

View File

@@ -1,8 +1,9 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
from remnawave.models.hwid import HwidDeviceDto
from config import settings from config import settings
from misc.utils import convert_duration_to_int from misc.utils import convert_duration_to_int, format_hwid_device
from schemas.subscriptions import SubscriptionDuration from schemas.subscriptions import SubscriptionDuration
from .common import _return_to_menu_btn from .common import _return_to_menu_btn
@@ -63,6 +64,9 @@ def subscription_controls(
) )
builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal")) builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal"))
builder.row(
InlineKeyboardButton(text="💻 Управление устройствами", callback_data="hwid_control")
)
builder.row(_return_to_menu_btn) builder.row(_return_to_menu_btn)
return builder.as_markup() return builder.as_markup()
@@ -143,3 +147,25 @@ def pay_url(url: str) -> InlineKeyboardMarkup:
[InlineKeyboardButton(text="", callback_data="menu:main")], [InlineKeyboardButton(text="", callback_data="menu:main")],
] ]
).as_markup() ).as_markup()
def construct_devices_list(devices: list[HwidDeviceDto]) -> InlineKeyboardMarkup:
builder = InlineKeyboardBuilder()
for device in devices:
builder.row(
InlineKeyboardButton(
text=format_hwid_device(device), callback_data=f"hwid:{device.hwid}"
)
)
builder.row(_return_btn(data="sub_info"))
return builder.as_markup()
def prompt_hwid_deletion_kb(hwid: str) -> InlineKeyboardMarkup:
return InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="🗑️ Удалить", callback_data=f"delete_hwid:{hwid}")],
[_return_btn("hwid_control", text="❌ Отмена")],
]
).as_markup()

View File

@@ -74,6 +74,13 @@ NO_SQUADS = "отсутствуют"
# — HWID лимит # — HWID лимит
UNLIMITED_HWID = "без лимитов" UNLIMITED_HWID = "без лимитов"
# — OS эмодзи
WINDOWS = "💻"
LINUX = "🐧"
ANDROID = "💚"
APPLE = "🍎"
UNKNOWN_OS = "👤"
# — Трафик # — Трафик
NO_TRAFFIC_LIMIT = "не ограничен" NO_TRAFFIC_LIMIT = "не ограничен"
TRAFFIC_WITH_LIMIT = "{used} / {limit}" TRAFFIC_WITH_LIMIT = "{used} / {limit}"
@@ -155,6 +162,10 @@ SUBSCRIPTION_INFORMATION = (
NO_SUBSCRIPTION = f"{MALENIA_WARN} <b>Подписка не найдена.</b>" NO_SUBSCRIPTION = f"{MALENIA_WARN} <b>Подписка не найдена.</b>"
NO_CONNECTIONS = f"{MALENIA_SHIELD} <b>У вас нет подключённых устройств.</b>"
HWID_LIST = f"{MALENIA_SHIELD} <b>Ваши устройства ({{count}} / {{limit}}):</b>"
PROMPT_HWID_DELETION = f"{MALENIA_WARN} <b>Вы уверены что хотите удалить это устройство?</b>"
SUCCESSFUL_HWID_DELETION = f"{MALENIA_HEART} <b>Устройство успешно удалено.</b>"
# ============================================================ # ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА # РЕФЕРАЛЬНАЯ СИСТЕМА
@@ -174,15 +185,17 @@ REFERALS_MENU = (
# ОФОРМЛЕНИЕ ПОДПИСКИ # ОФОРМЛЕНИЕ ПОДПИСКИ
# ============================================================ # ============================================================
SUBSCRIPTION_DEVICE_SELECTOR = f"<b>{MALENIA_QUESTION} Сколько устройств вы хотите подключить?</b>\n\nЦена: <b>{{price}}₽</b>" SUBSCRIPTION_DEVICE_SELECTOR = (
f"<b>{MALENIA_QUESTION} Сколько устройств вы хотите подключить?</b>\n\nЦена: <b>{{price}}₽</b>"
SUBSCRIPTION_DURATION_SELECTOR = (
f"<b>{MALENIA_QUESTION} На какой срок хотите оформить подписку?</b>\n\nИтого: <b>{{price}}₽</b> <i>(скидка: {{discount}}%)</i>"
) )
SUBSCRIPTION_DURATION_SELECTOR = f"<b>{MALENIA_QUESTION} На какой срок хотите оформить подписку?</b>\n\nИтого: <b>{{price}}₽</b> <i>(скидка: {{discount}}%)</i>"
WHITELISTS_ALREADY_ON = "Белые списки уже активны." WHITELISTS_ALREADY_ON = "Белые списки уже активны."
CHECKOUT_PAYMENT_CHOICE = f"<b>{MALENIA_BANK} Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>" CHECKOUT_PAYMENT_CHOICE = (
f"<b>{MALENIA_BANK} Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>"
)
# ============================================================ # ============================================================
@@ -203,13 +216,13 @@ BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
INSUFFICIENT_FUNDS = f"<b>{MALENIA_CROSS} Недостаточно средств для проведения операции.</b>\n\n<b>💰 Текущий баланс:</b> <code>{{balance}}₽</code>\n<b>💸 Требуется:</b> <code>{{amount}}₽</code>\n\n<i>{MALENIA_CARD} Пополните баланс на <code>{{diff}}₽</code></i>" INSUFFICIENT_FUNDS = f"<b>{MALENIA_CROSS} Недостаточно средств для проведения операции.</b>\n\n<b>💰 Текущий баланс:</b> <code>{{balance}}₽</code>\n<b>💸 Требуется:</b> <code>{{amount}}₽</code>\n\n<i>{MALENIA_CARD} Пополните баланс на <code>{{diff}}₽</code></i>"
SUCCESSFULLY_CREATED_SUBSCRIPTION = ( SUCCESSFULLY_CREATED_SUBSCRIPTION = f"<b>{MALENIA_HEART} Ваша подписка успешно оплачена!</b> Ссылка на добавления доступна в главном меню."
f"<b>{MALENIA_HEART} Ваша подписка успешно оплачена!</b> Ссылка на добавления доступна в главном меню."
)
DEPOSIT_AMOUNT = f"<b>{MALENIA_COINS} Укажите сумму пополнения:</b>" DEPOSIT_AMOUNT = f"<b>{MALENIA_COINS} Укажите сумму пополнения:</b>"
DEPOSIT_AMOUNT_FALLBACK = f"<b>{MALENIA_CROSS} Сумма пополнения указана неверно, повторите попытку.</b>" DEPOSIT_AMOUNT_FALLBACK = (
f"<b>{MALENIA_CROSS} Сумма пополнения указана неверно, повторите попытку.</b>"
)
DEPOSIT_AMOUNT_BELOW_MINIMAL = ( DEPOSIT_AMOUNT_BELOW_MINIMAL = (
f"<b>{MALENIA_CROSS} Сумма пополнения ниже минимальной - <code>{{threshold}}₽</code>.</b>" f"<b>{MALENIA_CROSS} Сумма пополнения ниже минимальной - <code>{{threshold}}₽</code>.</b>"
@@ -226,7 +239,7 @@ AUTORENEWAL_INSUFFICIENT_FUNDS = (
) )
AUTORENEWAL_SUCCESS = ( AUTORENEWAL_SUCCESS = (
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>" f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>\n"
"Новая дата окончания: <code>{end_date}</code>." "Новая дата окончания: <code>{end_date}</code>."
) )

View File

@@ -12,6 +12,11 @@ class Settings(BaseSettings):
bot_token: str = Field(alias="BOT_TOKEN") bot_token: str = Field(alias="BOT_TOKEN")
admins: list[int] = Field(alias="ADMINS") admins: list[int] = Field(alias="ADMINS")
admin_log_chat_id: int | None = Field(None, alias="ADMIN_LOG_CHAT_ID")
admin_log_deposit_topic_id: int | None = Field(None, alias="ADMIN_LOG_DEPOSIT_TOPIC_ID")
admin_log_subscriptions_topic_id: int | None = Field(
None, alias="ADMIN_LOG_SUBSCRIPTIONS_TOPIC_ID"
)
pally_shop_id: str = Field(alias="PALLY_SHOP_ID") pally_shop_id: str = Field(alias="PALLY_SHOP_ID")
pally_token: str = Field(alias="PALLY_TOKEN") pally_token: str = Field(alias="PALLY_TOKEN")
@@ -36,7 +41,5 @@ class Settings(BaseSettings):
autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE") autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE")
autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE")
settings = Settings() # type: ignore settings = Settings() # type: ignore

0
entrypoints/api.sh Normal file → Executable file
View File

0
entrypoints/startup.sh Normal file → Executable file
View File

View File

@@ -3,6 +3,8 @@ import urllib.parse
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from random import choice from random import choice
from remnawave.models.hwid import HwidDeviceDto
from bot import texts from bot import texts
from config import settings from config import settings
from schemas.subscriptions import InternalSquads, SubscriptionDuration, SubscriptionPlan from schemas.subscriptions import InternalSquads, SubscriptionDuration, SubscriptionPlan
@@ -271,3 +273,25 @@ def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool:
active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads} active_squad_uuids = {squad["uuid"] for squad in rw_user.active_squads}
current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids
return current_has_whitelist == new_plan.whitelists return current_has_whitelist == new_plan.whitelists
def _format_os_emoji(os: str) -> str:
os = os.lower()
if os == "windows":
return texts.WINDOWS
if os == "linux":
return texts.LINUX
if os == "ios":
return texts.APPLE
if os == "android":
return texts.ANDROID
return texts.UNKNOWN_OS
def format_hwid_device(device: HwidDeviceDto) -> str:
os = device.platform
model = device.device_model
client = device.user_agent.split("/")[0]
emoji = _format_os_emoji(os)
return f"{emoji} {os} | {model} | {client}"

View File

@@ -16,7 +16,6 @@ class SubscriptionRepository:
select(Subscription) select(Subscription)
.where( .where(
Subscription.autorenew == True, # noqa: E712 Subscription.autorenew == True, # noqa: E712
Subscription.status == SubscriptionStatus.ACTIVE,
Subscription.end_date <= threshold, Subscription.end_date <= threshold,
) )
.with_for_update() .with_for_update()
@@ -69,7 +68,7 @@ class SubscriptionRepository:
whitelists: bool, whitelists: bool,
duration_days: int, duration_days: int,
plan_price_paid: int, plan_price_paid: int,
autorenew: bool = False, autorenew: bool,
status: SubscriptionStatus = SubscriptionStatus.ACTIVE, status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
) -> Subscription | None: ) -> Subscription | None:
stmt = ( stmt = (

View File

@@ -0,0 +1,111 @@
import logging
from html import escape
from aiogram import Bot
from config import settings
logger = logging.getLogger(__name__)
async def _send_admin_log(bot: Bot, topic_id: int | None, text: str) -> None:
if settings.admin_log_chat_id is None or topic_id is None:
return
try:
await bot.send_message(
settings.admin_log_chat_id,
text,
message_thread_id=topic_id,
disable_web_page_preview=True,
)
except Exception:
logger.warning("Failed to send admin log notification", exc_info=True)
def _format_username(username: str | None) -> str:
if not username:
return "-"
return "@" + escape(username)
async def notify_deposit(
bot: Bot,
*,
user_id: int,
amount: int,
balance_before: int,
balance_after: int,
bill_id: int,
payment_id: str,
) -> None:
await _send_admin_log(
bot,
settings.admin_log_deposit_topic_id,
"\n".join(
[
"<b>Пополнение баланса</b>",
f"Пользователь: <code>{user_id}</code>",
f"Сумма: <b>{amount}₽</b>",
f"Баланс: {balance_before}₽ -> {balance_after}",
f"Bill ID: <code>{bill_id}</code>",
f"Payment ID: <code>{escape(payment_id)}</code>",
]
),
)
async def notify_subscription_purchase(
bot: Bot,
*,
user_id: int,
username: str | None,
amount: int,
balance_before: int,
balance_after: int,
devices: int,
duration_days: int,
whitelists: bool,
credit: int,
) -> None:
lines = [
"<b>Покупка подписки</b>",
f"Пользователь: <code>{user_id}</code> ({_format_username(username)})",
f"Стоимость: <b>{amount}₽</b>",
f"Баланс: {balance_before}₽ -> {balance_after}",
f"Устройства: {devices}",
f"Срок: {duration_days} дн.",
f"Whitelist: {'да' if whitelists else 'нет'}",
]
if credit > 0:
lines.append(f"Зачет неиспользованных дней: {credit}")
await _send_admin_log(bot, settings.admin_log_subscriptions_topic_id, "\n".join(lines))
async def notify_subscription_renewal(
bot: Bot,
*,
user_id: int,
amount: int,
balance_before: int,
balance_after: int,
devices: int,
duration_days: int,
whitelists: bool,
end_date: str,
) -> None:
await _send_admin_log(
bot,
settings.admin_log_subscriptions_topic_id,
(
"<b>Продление подписки</b>\n\n"
f"Пользователь: <code>{user_id}</code>\n"
f"Стоимость: <b>{amount}₽</b>\n"
f"Баланс: {balance_before}₽ -> {balance_after}\n"
f"Устройства: {devices}\n"
f"Срок: {duration_days} дн.\n"
f"Whitelist: {'да' if whitelists else 'нет'}\n"
f"Новая дата окончания: {escape(end_date)}"
),
)

View File

@@ -14,6 +14,7 @@ from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus
from services import rw as rw_service from services import rw as rw_service
from services.admin_notifications import notify_subscription_renewal
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -48,7 +49,9 @@ class RenewalService:
) )
return utils.calculate_price(plan) return utils.calculate_price(plan)
async def _renew_subscription_in_rw(self, subscription: Subscription) -> None: async def _renew_subscription_in_rw(
self, subscription: Subscription, expire_at: datetime.datetime
) -> None:
rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id) rw_user = await rw_service.get_user_by_telegram_id(self.rw_sdk, subscription.user_id)
if not rw_user: if not rw_user:
raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}") raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}")
@@ -57,7 +60,7 @@ class RenewalService:
if subscription.whitelists: if subscription.whitelists:
squads.append(InternalSquads.WHITELISTS) squads.append(InternalSquads.WHITELISTS)
await rw_service.add_days(self.rw_sdk, rw_user.uuid, subscription.duration_days) await rw_service.update_expire_at(self.rw_sdk, rw_user.uuid, expire_at)
await rw_service.update_squads(self.rw_sdk, rw_user.uuid, squads) await rw_service.update_squads(self.rw_sdk, rw_user.uuid, squads)
await rw_service.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices) await rw_service.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices)
@@ -88,10 +91,12 @@ class RenewalService:
await self.subscription_repository.update_subscription_autorenew( await self.subscription_repository.update_subscription_autorenew(
session, user_id, False session, user_id, False
) )
await self._notify_user(user_id, AUTORENEWAL_INSUFFICIENT_FUNDS) await self._notify_user(
user_id, AUTORENEWAL_INSUFFICIENT_FUNDS.format(price=price, balance=user.balance)
)
return False return False
result = await self.user_repository.decrease_user_balance( renewed_user = await self.user_repository.decrease_user_balance(
session, session,
user_id, user_id,
price, price,
@@ -99,12 +104,18 @@ class RenewalService:
description=f"autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d, whitelists={subscription.whitelists}", description=f"autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d, whitelists={subscription.whitelists}",
) )
if not result: if not renewed_user:
logger.error("Failed to decrease balance for user %d", user_id) logger.error("Failed to decrease balance for user %d", user_id)
return False return False
now_utc = datetime.datetime.now(datetime.UTC)
if subscription.end_date > now_utc:
new_expire = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
else:
new_expire = now_utc + datetime.timedelta(days=subscription.duration_days)
try: try:
await self._renew_subscription_in_rw(subscription) await self._renew_subscription_in_rw(subscription, new_expire)
except RenewalServiceError: except RenewalServiceError:
logger.exception("Failed to renew subscription in RW for user %d", user_id) logger.exception("Failed to renew subscription in RW for user %d", user_id)
await self.user_repository.increase_user_balance( await self.user_repository.increase_user_balance(
@@ -116,11 +127,10 @@ class RenewalService:
) )
return False return False
new_end_date = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
await self.subscription_repository.update_sub( await self.subscription_repository.update_sub(
session, session,
user_id, user_id,
end_date=new_end_date, end_date=new_expire,
autorenew=True, autorenew=True,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=subscription.devices, devices=subscription.devices,
@@ -133,9 +143,20 @@ class RenewalService:
user_id, user_id,
AUTORENEWAL_SUCCESS.format( AUTORENEWAL_SUCCESS.format(
price=price, price=price,
end_date=new_end_date.strftime("%d.%m.%Y"), end_date=new_expire.strftime("%d.%m.%Y"),
), ),
) )
await notify_subscription_renewal(
self.bot,
user_id=user_id,
amount=price,
balance_before=renewed_user.balance + price,
balance_after=renewed_user.balance,
devices=subscription.devices,
duration_days=subscription.duration_days,
whitelists=subscription.whitelists,
end_date=new_expire.strftime("%d.%m.%Y"),
)
logger.info("Successfully renewed subscription for user %d", user_id) logger.info("Successfully renewed subscription for user %d", user_id)
return True return True

View File

@@ -4,7 +4,13 @@ from datetime import UTC, datetime, timedelta
from uuid import UUID from uuid import UUID
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from remnawave.models import CreateUserRequestDto, UpdateUserRequestDto from remnawave.models import (
CreateUserRequestDto,
DeleteUserHwidDeviceResponseDto,
HWIDDeleteRequest,
UpdateUserRequestDto,
)
from remnawave.models.hwid import HwidDeviceDto
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -349,3 +355,32 @@ async def create_user(
except Exception as exc: except Exception as exc:
logger.warning("Failed to create user username=%s: %s", username, exc) logger.warning("Failed to create user username=%s: %s", username, exc)
return None return None
async def get_hwid_list(sdk: RemnawaveSDK, user_uuid: str) -> list[HwidDeviceDto] | None:
try:
resp = await sdk.hwid.get_hwid_user(user_uuid)
return resp.devices
except Exception:
logger.exception("failed to fetch hwid list for user=%s", user_uuid)
return
async def delete_hwid(sdk: RemnawaveSDK, user_uuid: str, hwid: str) -> bool:
body = HWIDDeleteRequest(user_uuid=user_uuid, hwid=hwid)
try:
resp = await sdk.hwid.delete_hwid_to_user(body)
return isinstance(resp, DeleteUserHwidDeviceResponseDto)
except Exception:
logger.exception("failed to delete hwid=%s for user=%s", hwid, user_uuid)
return False
async def update_expire_at(sdk: RemnawaveSDK, user_uuid: str, expire_at: datetime):
dto = UpdateUserRequestDto(uuid=user_uuid, expire_at=expire_at) # type: ignore
try:
await sdk.users.update_user(body=dto)
return True
except Exception:
logger.exception("failed to update expire_at=%s for user=%s", str(expire_at), user_uuid)
return False

View File

@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION
from config import settings from config import settings
from db.models import Subscription
from db.models.users import User from db.models.users import User
from misc import utils from misc import utils
from repositories import UserRepository from repositories import UserRepository
@@ -122,7 +123,7 @@ class UserService:
session, session,
user.id, user.id,
end_date=rw_user.expire_at + duration, end_date=rw_user.expire_at + duration,
autorenew=False, autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices), devices=int(subscription.devices),
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
@@ -179,7 +180,7 @@ class UserService:
session, session,
user.id, user.id,
end_date=expire, end_date=expire,
autorenew=False, autorenew=db_subscription.autorenew,
status=SubscriptionStatus.ACTIVE, status=SubscriptionStatus.ACTIVE,
devices=int(subscription.devices), devices=int(subscription.devices),
whitelists=subscription.whitelists, whitelists=subscription.whitelists,
@@ -239,3 +240,14 @@ class UserService:
logger.exception("caught an exception while launching promo, skipping user...") logger.exception("caught an exception while launching promo, skipping user...")
return sent return sent
def validate_db_subscription(self, subscription: Subscription, rw_user: rw.RWUserInfo):
return all(
[
(rw_user.status != "ACTIVE" and subscription.status == SubscriptionStatus.EXPIRED)
or (
rw_user.status == "ACTIVE" and subscription.status == SubscriptionStatus.ACTIVE
),
rw_user.expire_at == subscription.end_date,
]
)

View File

@@ -21,6 +21,7 @@ load_dotenv()
ENDPOINT = "/pally/result" ENDPOINT = "/pally/result"
def calculate_signature( def calculate_signature(
out_sum: str, out_sum: str,
inv_id: str, inv_id: str,
@@ -43,9 +44,9 @@ def main():
help="Статус платежа (по умолчанию SUCCESS)", help="Статус платежа (по умолчанию SUCCESS)",
) )
parser.add_argument( parser.add_argument(
"--customer-pays-fees", "--customer-pays-fees",
action="store_true", action="store_true",
help="Симулировать сценарий 'клиент платит комиссию'" help="Симулировать сценарий 'клиент платит комиссию'",
) )
args = parser.parse_args() args = parser.parse_args()
@@ -54,18 +55,18 @@ def main():
# ИСПРАВЛЕНО: InvId должен содержать ID счета из БД (это order_id при создании счета) # ИСПРАВЛЕНО: InvId должен содержать ID счета из БД (это order_id при создании счета)
# Именно InvId используется в webhook handler для поиска счета в БД # Именно InvId используется в webhook handler для поиска счета в БД
inv_id = str(args.bill_id) # InvId = bill ID из нашей БД (order_id при создании) inv_id = str(args.bill_id) # InvId = bill ID из нашей БД (order_id при создании)
trs_id = f"LXZv3R{args.bill_id}" # TrsId - уникальный идентификатор счета в Pally trs_id = f"LXZv3R{args.bill_id}" # TrsId - уникальный идентификатор счета в Pally
currency = "RUB" currency = "RUB"
custom = None # custom field не используется в production (остается None) custom = None # custom field не используется в production (остается None)
# Симуляция комиссии # Симуляция комиссии
if args.customer_pays_fees: if args.customer_pays_fees:
# Симулируем комиссию ~10% (как в вашем продакшене: 50 -> 55.4) # Симулируем комиссию ~10% (как в вашем продакшене: 50 -> 55.4)
commission_rate = 0.108 # ~10.8% commission_rate = 0.108 # ~10.8%
gross_amount = args.amount * (1 + commission_rate) gross_amount = args.amount * (1 + commission_rate)
commission = gross_amount - args.amount commission = gross_amount - args.amount
print(f"🔄 Симулируется сценарий 'клиент платит комиссию':") print("customer pays fee:")
print(f" Сумма заказа: {args.amount} RUB") print(f" Сумма заказа: {args.amount} RUB")
print(f" Комиссия: {commission:.2f} RUB") print(f" Комиссия: {commission:.2f} RUB")
print(f" Итого к оплате: {gross_amount:.2f} RUB") print(f" Итого к оплате: {gross_amount:.2f} RUB")
@@ -75,9 +76,7 @@ def main():
commission = 0.00 commission = 0.00
# Сигнатура рассчитывается с использованием InvId (который содержит bill ID) # Сигнатура рассчитывается с использованием InvId (который содержит bill ID)
signature = calculate_signature( signature = calculate_signature(f"{gross_amount:.2f}", inv_id, pally_token)
f"{gross_amount:.2f}", inv_id, pally_token
)
# Используем правильные имена полей согласно документации pally.info # Используем правильные имена полей согласно документации pally.info
payload = { payload = {
@@ -90,7 +89,7 @@ def main():
"custom": custom, # None в production "custom": custom, # None в production
"SignatureValue": signature, "SignatureValue": signature,
} }
# Добавляем BalanceAmount только если клиент платит комиссию # Добавляем BalanceAmount только если клиент платит комиссию
if args.customer_pays_fees: if args.customer_pays_fees:
payload["BalanceAmount"] = str(args.amount) # Сумма к зачислению (без комиссии) payload["BalanceAmount"] = str(args.amount) # Сумма к зачислению (без комиссии)