From 4c5a16a3ca430bc591495453ee973cd639e7707f Mon Sep 17 00:00:00 2001 From: hexdev Date: Thu, 4 Jun 2026 12:35:32 +0700 Subject: [PATCH] feat(admin): add admin notification system for deposits, purchases, and renewals --- .env.example | 8 ++- api/routes/pally.py | 110 ++++++++++++++++++++++--------- bot/handlers/client/buy.py | 15 +++++ bot/handlers/system/__init__.py | 2 +- bot/texts.py | 20 +++--- config.py | 7 +- entrypoints/api.sh | 0 entrypoints/startup.sh | 0 services/admin_notifications.py | 111 ++++++++++++++++++++++++++++++++ services/renewal_service.py | 16 ++++- tests/send_pally_webhook.py | 19 +++--- 11 files changed, 249 insertions(+), 59 deletions(-) mode change 100644 => 100755 entrypoints/api.sh mode change 100644 => 100755 entrypoints/startup.sh create mode 100644 services/admin_notifications.py diff --git a/.env.example b/.env.example index 610a024..80fad2b 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ BOT_TOKEN=your_telegram_bot_token # Comma-separated list of Telegram user IDs 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_SHOP_ID=your_pally_shop_id PALLY_TOKEN=your_pally_token @@ -40,6 +45,3 @@ WHITELIST_SQUAD=e4435baa-64d3-4b29-a18e-81ad1c60d977 # Autorenewal AUTORENEW_DAYS_BEFORE=1 - -# Autorenewal -AUTORENEW_DAYS_BEFORE=1 \ No newline at end of file diff --git a/api/routes/pally.py b/api/routes/pally.py index 084910c..649eeb5 100644 --- a/api/routes/pally.py +++ b/api/routes/pally.py @@ -1,4 +1,6 @@ +# ruff: noqa: N803 import hashlib +import hmac import logging import math @@ -14,6 +16,7 @@ from db.models.bills import DepositStatus from db.models.transactions import BalanceTxType from misc.pally import BillStatus from schemas.di import APIDependenciesDTO +from services.admin_notifications import notify_deposit router = APIRouter(prefix="/pally") @@ -21,7 +24,7 @@ logger = logging.getLogger(__name__) @router.post("/result") -async def pally_callback( +async def pally_callback( # noqa: PLR0911, PLR0912 InvId: str = Form(...), OutSum: 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 # The InvId field contains the db_bill.id that was passed as order_id during bill creation bill_id_str = InvId - + logger.info( "Pally webhook received - InvId: %s, OutSum: %s, Commission: %s, TrsId: %s, Status: %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 if Status != BillStatus.SUCCESS: logger.warning( "Non-success payment received - Status: %s, ErrorCode: %s, ErrorMessage: %s, TrsId: %s", - Status, ErrorCode, ErrorMessage, TrsId + Status, + ErrorCode, + ErrorMessage, + TrsId, ) - + # Validate signature raw_string = f"{OutSum}:{InvId}:{settings.pally_token}" 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( - "SECURITY ALERT: Invalid signature for TrsId %s - Expected: %s, Received: %s, Raw: %s", - TrsId, expected_signature, SignatureValue, raw_string + "SECURITY ALERT: Invalid signature for TrsId %s - Expected: %s, Received: %s", + TrsId, + expected_signature, + SignatureValue, ) 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) if not bill_id_str or not bill_id_str.isdigit(): logger.critical( - "Invalid or non-numeric bill ID in InvId field for TrsId %s: '%s'", - TrsId, bill_id_str + "Invalid or non-numeric bill ID in InvId field for TrsId %s: '%s'", TrsId, bill_id_str ) return "OK" @@ -100,8 +114,9 @@ async def pally_callback( # Check if already processed if bill.status != DepositStatus.PENDING: - logger.warning("Bill %s (TrsId: %s) is already processed with status: %s", - bill.id, TrsId, bill.status) + logger.warning( + "Bill %s (TrsId: %s) is already processed with status: %s", bill.id, TrsId, bill.status + ) return "OK" try: @@ -111,43 +126,59 @@ async def pally_callback( # Customer pays fees - BalanceAmount is the net amount credited to merchant credited_amount = int(float(BalanceAmount)) gross_amount = int(float(OutSum)) - + logger.info( "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 if bill.amount != credited_amount: logger.error( "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" - + amount = credited_amount # Credit the net amount (without fees) - + else: # Standard payment - OutSum should match bill amount exactly amount = int(float(OutSum)) - + logger.info( "Standard payment: bill_id=%s, expected=%s, received=%s", - bill.id, bill.amount, amount + bill.id, + bill.amount, + amount, ) - + if bill.amount != amount: logger.error( "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" - logger.info("Processing payment: bill_id=%s, user_id=%s, amount=%s", - bill.id, bill.creator_id, amount) + logger.info( + "Processing payment: bill_id=%s, user_id=%s, amount=%s", + bill.id, + bill.creator_id, + amount, + ) # Credit user balance - await deps.user_repository.increase_user_balance( + credited_user = await deps.user_repository.increase_user_balance( session, bill.creator_id, amount=amount, @@ -155,6 +186,16 @@ async def pally_callback( description=f"payment via PALLY (TrsId: {TrsId})", ) 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 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})", ) 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) except Exception as e: logger.exception( "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 # The balance operation might have partially succeeded @@ -185,7 +231,7 @@ async def pally_callback( await deps.bills_repository.update_bill_status( session, int(bill.id), status=DepositStatus.SUCCESS ) - + logger.info("Bill %s marked as SUCCESS for TrsId %s", bill.id, TrsId) return "OK" diff --git a/bot/handlers/client/buy.py b/bot/handlers/client/buy.py index 8108d96..df4c220 100644 --- a/bot/handlers/client/buy.py +++ b/bot/handlers/client/buy.py @@ -29,6 +29,7 @@ from misc.utils import ( ) from schemas.di import DependenciesDTO from schemas.subscriptions import SubscriptionPlan +from services.admin_notifications import notify_subscription_purchase router = Router() logger = logging.getLogger(__name__) @@ -223,4 +224,18 @@ async def proceed_to_checkout( 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), + whitelists=ctx.whitelists, + credit=credit, + ) + await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu) diff --git a/bot/handlers/system/__init__.py b/bot/handlers/system/__init__.py index 01b687b..a27e3fe 100644 --- a/bot/handlers/system/__init__.py +++ b/bot/handlers/system/__init__.py @@ -1,4 +1,4 @@ from .common import router as common_router from .prehandling import router as prehandling_router -routers = [common_router, prehandling_router] +routers = [prehandling_router, common_router] diff --git a/bot/texts.py b/bot/texts.py index b62bf73..d1a71df 100644 --- a/bot/texts.py +++ b/bot/texts.py @@ -174,15 +174,17 @@ REFERALS_MENU = ( # ОФОРМЛЕНИЕ ПОДПИСКИ # ============================================================ -SUBSCRIPTION_DEVICE_SELECTOR = f"{MALENIA_QUESTION} Сколько устройств вы хотите подключить?\n\nЦена: {{price}}₽" - -SUBSCRIPTION_DURATION_SELECTOR = ( - f"{MALENIA_QUESTION} На какой срок хотите оформить подписку?\n\nИтого: {{price}}₽ (скидка: {{discount}}%)" +SUBSCRIPTION_DEVICE_SELECTOR = ( + f"{MALENIA_QUESTION} Сколько устройств вы хотите подключить?\n\nЦена: {{price}}₽" ) +SUBSCRIPTION_DURATION_SELECTOR = f"{MALENIA_QUESTION} На какой срок хотите оформить подписку?\n\nИтого: {{price}}₽ (скидка: {{discount}}%)" + WHITELISTS_ALREADY_ON = "Белые списки уже активны." -CHECKOUT_PAYMENT_CHOICE = f"{MALENIA_BANK} Пополнение баланса:\n\n❓ Выберите способ оплаты:" +CHECKOUT_PAYMENT_CHOICE = ( + f"{MALENIA_BANK} Пополнение баланса:\n\n❓ Выберите способ оплаты:" +) # ============================================================ @@ -203,13 +205,13 @@ BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK INSUFFICIENT_FUNDS = f"{MALENIA_CROSS} Недостаточно средств для проведения операции.\n\n💰 Текущий баланс: {{balance}}₽\n💸 Требуется: {{amount}}₽\n\n{MALENIA_CARD} Пополните баланс на {{diff}}₽" -SUCCESSFULLY_CREATED_SUBSCRIPTION = ( - f"{MALENIA_HEART} Ваша подписка успешно оплачена! Ссылка на добавления доступна в главном меню." -) +SUCCESSFULLY_CREATED_SUBSCRIPTION = f"{MALENIA_HEART} Ваша подписка успешно оплачена! Ссылка на добавления доступна в главном меню." DEPOSIT_AMOUNT = f"{MALENIA_COINS} Укажите сумму пополнения:" -DEPOSIT_AMOUNT_FALLBACK = f"{MALENIA_CROSS} Сумма пополнения указана неверно, повторите попытку." +DEPOSIT_AMOUNT_FALLBACK = ( + f"{MALENIA_CROSS} Сумма пополнения указана неверно, повторите попытку." +) DEPOSIT_AMOUNT_BELOW_MINIMAL = ( f"{MALENIA_CROSS} Сумма пополнения ниже минимальной - {{threshold}}₽." diff --git a/config.py b/config.py index 695a8ee..cbdf0a7 100644 --- a/config.py +++ b/config.py @@ -12,6 +12,11 @@ class Settings(BaseSettings): bot_token: str = Field(alias="BOT_TOKEN") 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_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") - settings = Settings() # type: ignore diff --git a/entrypoints/api.sh b/entrypoints/api.sh old mode 100644 new mode 100755 diff --git a/entrypoints/startup.sh b/entrypoints/startup.sh old mode 100644 new mode 100755 diff --git a/services/admin_notifications.py b/services/admin_notifications.py new file mode 100644 index 0000000..667f2ad --- /dev/null +++ b/services/admin_notifications.py @@ -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( + [ + "Пополнение баланса", + f"Пользователь: {user_id}", + f"Сумма: {amount}₽", + f"Баланс: {balance_before}₽ -> {balance_after}₽", + f"Bill ID: {bill_id}", + f"Payment ID: {escape(payment_id)}", + ] + ), + ) + + +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 = [ + "Покупка подписки", + f"Пользователь: {user_id} ({_format_username(username)})", + f"Стоимость: {amount}₽", + 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, + ( + "Продление подписки", + f"Пользователь: {user_id}", + f"Стоимость: {amount}₽", + f"Баланс: {balance_before}₽ -> {balance_after}₽", + f"Устройства: {devices}", + f"Срок: {duration_days} дн.", + f"Whitelist: {'да' if whitelists else 'нет'}", + f"Новая дата окончания: {escape(end_date)}", + ), + ) diff --git a/services/renewal_service.py b/services/renewal_service.py index 4be75e9..b13c275 100644 --- a/services/renewal_service.py +++ b/services/renewal_service.py @@ -14,6 +14,7 @@ from repositories.subscriptions import SubscriptionRepository from repositories.users import UserRepository from schemas.subscriptions import InternalSquads, SubscriptionPlan, SubscriptionStatus from services import rw as rw_service +from services.admin_notifications import notify_subscription_renewal logger = logging.getLogger(__name__) @@ -91,7 +92,7 @@ class RenewalService: await self._notify_user(user_id, AUTORENEWAL_INSUFFICIENT_FUNDS) return False - result = await self.user_repository.decrease_user_balance( + renewed_user = await self.user_repository.decrease_user_balance( session, user_id, price, @@ -99,7 +100,7 @@ class RenewalService: 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) return False @@ -136,6 +137,17 @@ class RenewalService: end_date=new_end_date.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_end_date.strftime("%d.%m.%Y"), + ) logger.info("Successfully renewed subscription for user %d", user_id) return True diff --git a/tests/send_pally_webhook.py b/tests/send_pally_webhook.py index 5fd6341..d87146a 100644 --- a/tests/send_pally_webhook.py +++ b/tests/send_pally_webhook.py @@ -21,6 +21,7 @@ load_dotenv() ENDPOINT = "/pally/result" + def calculate_signature( out_sum: str, inv_id: str, @@ -43,9 +44,9 @@ def main(): help="Статус платежа (по умолчанию SUCCESS)", ) parser.add_argument( - "--customer-pays-fees", + "--customer-pays-fees", action="store_true", - help="Симулировать сценарий 'клиент платит комиссию'" + help="Симулировать сценарий 'клиент платит комиссию'", ) args = parser.parse_args() @@ -54,18 +55,18 @@ def main(): # ИСПРАВЛЕНО: InvId должен содержать ID счета из БД (это order_id при создании счета) # Именно InvId используется в webhook handler для поиска счета в БД 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" custom = None # custom field не используется в production (остается None) - + # Симуляция комиссии if args.customer_pays_fees: # Симулируем комиссию ~10% (как в вашем продакшене: 50 -> 55.4) commission_rate = 0.108 # ~10.8% gross_amount = args.amount * (1 + commission_rate) commission = gross_amount - args.amount - - print(f"🔄 Симулируется сценарий 'клиент платит комиссию':") + + print(f"customer pays fee:") print(f" Сумма заказа: {args.amount} RUB") print(f" Комиссия: {commission:.2f} RUB") print(f" Итого к оплате: {gross_amount:.2f} RUB") @@ -75,9 +76,7 @@ def main(): commission = 0.00 # Сигнатура рассчитывается с использованием InvId (который содержит bill ID) - signature = calculate_signature( - f"{gross_amount:.2f}", inv_id, pally_token - ) + signature = calculate_signature(f"{gross_amount:.2f}", inv_id, pally_token) # Используем правильные имена полей согласно документации pally.info payload = { @@ -90,7 +89,7 @@ def main(): "custom": custom, # None в production "SignatureValue": signature, } - + # Добавляем BalanceAmount только если клиент платит комиссию if args.customer_pays_fees: payload["BalanceAmount"] = str(args.amount) # Сумма к зачислению (без комиссии)