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) # Сумма к зачислению (без комиссии)