Compare commits
15 Commits
b5976d0b21
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 245741b182 | |||
| 7ab0e71108 | |||
| 3adcc90b74 | |||
| 93f05e46b5 | |||
|
|
edfe1e6874 | ||
|
|
7ab2293fe2 | ||
| 9552813f16 | |||
| 4c5a16a3ca | |||
| 5ec9491e22 | |||
| 18f994aed9 | |||
| 936d1ad260 | |||
| 2a0f5c454e | |||
| 2103e6a882 | |||
| 102a0ebf96 | |||
| 18f4997ca6 |
@@ -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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -174,4 +174,6 @@ cython_debug/
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
.pypirc
|
||||
|
||||
plans
|
||||
@@ -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(...),
|
||||
@@ -30,56 +33,172 @@ async def pally_callback(
|
||||
CurrencyIn: str = Form(...),
|
||||
custom: str | None = Form(None),
|
||||
SignatureValue: str = Form(...),
|
||||
# Optional fields for additional information
|
||||
AccountType: str | None = Form(None),
|
||||
AccountNumber: str | None = Form(None),
|
||||
BalanceAmount: str | None = Form(None),
|
||||
BalanceCurrency: str | None = Form(None),
|
||||
PayerPhone: str | None = Form(None),
|
||||
PayerEmail: str | None = Form(None),
|
||||
PayerName: str | None = Form(None),
|
||||
PayerComment: str | None = Form(None),
|
||||
ErrorCode: int | None = Form(None),
|
||||
ErrorMessage: str | None = Form(None),
|
||||
session: AsyncSession = Depends(get_db),
|
||||
bot: Bot = Depends(get_bot),
|
||||
deps: APIDependenciesDTO = Depends(get_deps),
|
||||
):
|
||||
oid = custom or ""
|
||||
# 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,
|
||||
)
|
||||
|
||||
# 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,
|
||||
)
|
||||
|
||||
# Validate signature
|
||||
raw_string = f"{OutSum}:{InvId}:{settings.pally_token}"
|
||||
expected_signature = hashlib.md5(raw_string.encode("utf-8")).hexdigest().upper()
|
||||
|
||||
if SignatureValue != expected_signature:
|
||||
logger.critical("Invalid signature for TrsId %s", TrsId)
|
||||
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",
|
||||
TrsId,
|
||||
expected_signature,
|
||||
SignatureValue,
|
||||
)
|
||||
raise HTTPException(403, detail="Invalid signature.")
|
||||
|
||||
# Only process successful payments
|
||||
if Status != BillStatus.SUCCESS:
|
||||
logger.info("Bill %s skipped: status=%s", TrsId, Status)
|
||||
return "OK"
|
||||
|
||||
logger.info("Received successfully paid bill %s", TrsId)
|
||||
logger.info("Processing successfully paid bill %s", TrsId)
|
||||
|
||||
if not oid.isdigit():
|
||||
logger.critical("Invalid custom field format for TrsId %s", TrsId)
|
||||
# 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
|
||||
)
|
||||
return "OK"
|
||||
|
||||
bill = await deps.bills_repository.get_bill_by_id(session, int(oid))
|
||||
# Find bill in database
|
||||
bill = await deps.bills_repository.get_bill_by_id(session, int(bill_id_str))
|
||||
|
||||
if not bill:
|
||||
logger.critical("Bill %s is not found in db", TrsId)
|
||||
logger.critical("Bill %s not found in database for TrsId %s", bill_id_str, TrsId)
|
||||
return "OK"
|
||||
|
||||
# Check if already processed
|
||||
if bill.status != DepositStatus.PENDING:
|
||||
logger.warning("bill=%s is already paid according to the db", bill.id)
|
||||
logger.warning(
|
||||
"Bill %s (TrsId: %s) is already processed with status: %s", bill.id, TrsId, bill.status
|
||||
)
|
||||
return "OK"
|
||||
|
||||
try:
|
||||
amount = int(float(OutSum))
|
||||
# Handle fee scenarios: use BalanceAmount if available (net amount after fees),
|
||||
# otherwise use OutSum (gross amount paid by customer)
|
||||
if BalanceAmount is not None:
|
||||
# Customer pays fees - BalanceAmount is the net amount credited to merchant
|
||||
credited_amount = int(float(BalanceAmount))
|
||||
gross_amount = int(float(OutSum))
|
||||
|
||||
if bill.amount != amount:
|
||||
logger.warning("requested amount for bill=%s is not equal to db entry", bill.id)
|
||||
return "OK"
|
||||
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,
|
||||
)
|
||||
|
||||
await deps.user_repository.increase_user_balance(
|
||||
# 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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
if bill.amount != amount:
|
||||
logger.error(
|
||||
"Amount mismatch for bill %s (TrsId: %s) - Expected: %s, Received: %s",
|
||||
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,
|
||||
)
|
||||
|
||||
# Credit user balance
|
||||
credited_user = await deps.user_repository.increase_user_balance(
|
||||
session,
|
||||
bill.creator_id,
|
||||
amount=amount,
|
||||
tx_type=BalanceTxType.DEPOSIT,
|
||||
description="personal balance deposit via PALLY",
|
||||
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)
|
||||
|
||||
referal = user.referal_id if user else None
|
||||
if referal is not None:
|
||||
referal_amount = math.floor(amount * (settings.referal_bonus / 100))
|
||||
@@ -88,19 +207,31 @@ async def pally_callback(
|
||||
referal,
|
||||
referal_amount,
|
||||
tx_type=BalanceTxType.REFERRAL_BONUS,
|
||||
description=f"referal reward for referal_id={bill.creator_id}",
|
||||
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
|
||||
)
|
||||
|
||||
except Exception:
|
||||
logger.info("Payment processing completed successfully for TrsId %s", TrsId)
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(
|
||||
"Exception caught while processing balances for TrsId %s (Creator: %s)",
|
||||
"CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s",
|
||||
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
|
||||
|
||||
# Update bill status to success
|
||||
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"
|
||||
|
||||
@@ -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) * 30,
|
||||
whitelists=ctx.whitelists,
|
||||
credit=credit,
|
||||
)
|
||||
|
||||
await cb.message.edit_text(SUCCESSFULLY_CREATED_SUBSCRIPTION, reply_markup=return_to_menu)
|
||||
|
||||
@@ -4,20 +4,40 @@ from aiogram.fsm.context import FSMContext
|
||||
from aiogram.types import CallbackQuery, Message
|
||||
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.texts import (
|
||||
CALLBACK_FALLBACK,
|
||||
FAQ_TEXT,
|
||||
GENERAL_PROCESSING,
|
||||
HWID_LIST,
|
||||
NO_CONNECTIONS,
|
||||
NO_SUBSCRIPTION,
|
||||
PROMPT_HWID_DELETION,
|
||||
STANDARD_FALLBACK,
|
||||
SUCCESSFUL_HWID_DELETION,
|
||||
)
|
||||
from db.models.users import User
|
||||
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.subscriptions import InternalSquads, SubscriptionPlan
|
||||
from services.rw import get_user_by_telegram_id
|
||||
from schemas.subscriptions import (
|
||||
InternalSquads,
|
||||
SubscriptionDuration,
|
||||
SubscriptionPlan,
|
||||
SubscriptionStatus,
|
||||
)
|
||||
from services.rw import delete_hwid, get_hwid_list, get_user_by_telegram_id
|
||||
|
||||
router = Router()
|
||||
|
||||
@@ -34,7 +54,7 @@ async def fetch_referal(
|
||||
|
||||
data = command.args.split("_")
|
||||
if not data:
|
||||
await standard_start(msg, command, state)
|
||||
await standard_start(msg, state, session, deps)
|
||||
return
|
||||
|
||||
referal = int(data[1])
|
||||
@@ -126,28 +146,43 @@ async def sub_info(
|
||||
return
|
||||
|
||||
if not user:
|
||||
await cb.answer("1" + CALLBACK_FALLBACK)
|
||||
await cb.answer(CALLBACK_FALLBACK)
|
||||
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:
|
||||
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(
|
||||
session,
|
||||
user_id=user.id,
|
||||
end_date=rw_user.expire_at,
|
||||
devices=rw_user.hwid_device_limit,
|
||||
duration_days=30,
|
||||
devices=dto.devices,
|
||||
duration_days=convert_duration_to_int(SubscriptionDuration.MONTH) * 30,
|
||||
whitelists=dto.whitelists,
|
||||
plan_price_paid=utils.calculate_price(dto),
|
||||
)
|
||||
except Exception:
|
||||
await cb.answer("2" + CALLBACK_FALLBACK)
|
||||
await cb.answer(CALLBACK_FALLBACK)
|
||||
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(
|
||||
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")
|
||||
async def toggle_autorenewal(
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
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
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
|
||||
from aiogram.utils.keyboard import InlineKeyboardBuilder
|
||||
from remnawave.models.hwid import HwidDeviceDto
|
||||
|
||||
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 .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="💻 Управление устройствами", callback_data="hwid_control")
|
||||
)
|
||||
builder.row(_return_to_menu_btn)
|
||||
return builder.as_markup()
|
||||
|
||||
@@ -143,3 +147,25 @@ def pay_url(url: str) -> InlineKeyboardMarkup:
|
||||
[InlineKeyboardButton(text="❌", callback_data="menu:main")],
|
||||
]
|
||||
).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()
|
||||
|
||||
33
bot/texts.py
33
bot/texts.py
@@ -74,6 +74,13 @@ NO_SQUADS = "отсутствуют"
|
||||
# — HWID лимит
|
||||
UNLIMITED_HWID = "без лимитов"
|
||||
|
||||
# — OS эмодзи
|
||||
WINDOWS = "💻"
|
||||
LINUX = "🐧"
|
||||
ANDROID = "💚"
|
||||
APPLE = "🍎"
|
||||
UNKNOWN_OS = "👤"
|
||||
|
||||
# — Трафик
|
||||
NO_TRAFFIC_LIMIT = "не ограничен"
|
||||
TRAFFIC_WITH_LIMIT = "{used} / {limit}"
|
||||
@@ -155,6 +162,10 @@ SUBSCRIPTION_INFORMATION = (
|
||||
|
||||
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_DURATION_SELECTOR = (
|
||||
f"<b>{MALENIA_QUESTION} На какой срок хотите оформить подписку?</b>\n\nИтого: <b>{{price}}₽</b> <i>(скидка: {{discount}}%)</i>"
|
||||
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>"
|
||||
|
||||
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>"
|
||||
|
||||
SUCCESSFULLY_CREATED_SUBSCRIPTION = (
|
||||
f"<b>{MALENIA_HEART} Ваша подписка успешно оплачена!</b> Ссылка на добавления доступна в главном меню."
|
||||
)
|
||||
SUCCESSFULLY_CREATED_SUBSCRIPTION = f"<b>{MALENIA_HEART} Ваша подписка успешно оплачена!</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 = (
|
||||
f"<b>{MALENIA_CROSS} Сумма пополнения ниже минимальной - <code>{{threshold}}₽</code>.</b>"
|
||||
@@ -226,7 +239,7 @@ AUTORENEWAL_INSUFFICIENT_FUNDS = (
|
||||
)
|
||||
|
||||
AUTORENEWAL_SUCCESS = (
|
||||
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>"
|
||||
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>\n"
|
||||
"Новая дата окончания: <code>{end_date}</code>."
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
0
entrypoints/api.sh
Normal file → Executable file
0
entrypoints/api.sh
Normal file → Executable file
0
entrypoints/startup.sh
Normal file → Executable file
0
entrypoints/startup.sh
Normal file → Executable file
@@ -3,6 +3,8 @@ import urllib.parse
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from random import choice
|
||||
|
||||
from remnawave.models.hwid import HwidDeviceDto
|
||||
|
||||
from bot import texts
|
||||
from config import settings
|
||||
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}
|
||||
current_has_whitelist = InternalSquads.WHITELISTS in active_squad_uuids
|
||||
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}"
|
||||
|
||||
@@ -16,7 +16,6 @@ class SubscriptionRepository:
|
||||
select(Subscription)
|
||||
.where(
|
||||
Subscription.autorenew == True, # noqa: E712
|
||||
Subscription.status == SubscriptionStatus.ACTIVE,
|
||||
Subscription.end_date <= threshold,
|
||||
)
|
||||
.with_for_update()
|
||||
@@ -69,7 +68,7 @@ class SubscriptionRepository:
|
||||
whitelists: bool,
|
||||
duration_days: int,
|
||||
plan_price_paid: int,
|
||||
autorenew: bool = False,
|
||||
autorenew: bool,
|
||||
status: SubscriptionStatus = SubscriptionStatus.ACTIVE,
|
||||
) -> Subscription | None:
|
||||
stmt = (
|
||||
|
||||
111
services/admin_notifications.py
Normal file
111
services/admin_notifications.py
Normal 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)}"
|
||||
),
|
||||
)
|
||||
@@ -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__)
|
||||
|
||||
@@ -48,7 +49,9 @@ class RenewalService:
|
||||
)
|
||||
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)
|
||||
if not rw_user:
|
||||
raise RenewalServiceError(f"RW user not found for user_id={subscription.user_id}")
|
||||
@@ -57,7 +60,7 @@ class RenewalService:
|
||||
if subscription.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.set_hwid_limit(self.rw_sdk, rw_user.uuid, subscription.devices)
|
||||
|
||||
@@ -88,10 +91,12 @@ class RenewalService:
|
||||
await self.subscription_repository.update_subscription_autorenew(
|
||||
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
|
||||
|
||||
result = await self.user_repository.decrease_user_balance(
|
||||
renewed_user = await self.user_repository.decrease_user_balance(
|
||||
session,
|
||||
user_id,
|
||||
price,
|
||||
@@ -99,12 +104,18 @@ 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
|
||||
|
||||
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:
|
||||
await self._renew_subscription_in_rw(subscription)
|
||||
await self._renew_subscription_in_rw(subscription, new_expire)
|
||||
except RenewalServiceError:
|
||||
logger.exception("Failed to renew subscription in RW for user %d", user_id)
|
||||
await self.user_repository.increase_user_balance(
|
||||
@@ -116,11 +127,10 @@ class RenewalService:
|
||||
)
|
||||
return False
|
||||
|
||||
new_end_date = subscription.end_date + datetime.timedelta(days=subscription.duration_days)
|
||||
await self.subscription_repository.update_sub(
|
||||
session,
|
||||
user_id,
|
||||
end_date=new_end_date,
|
||||
end_date=new_expire,
|
||||
autorenew=True,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
devices=subscription.devices,
|
||||
@@ -133,9 +143,20 @@ class RenewalService:
|
||||
user_id,
|
||||
AUTORENEWAL_SUCCESS.format(
|
||||
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)
|
||||
return True
|
||||
|
||||
@@ -4,7 +4,13 @@ from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
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__)
|
||||
|
||||
@@ -349,3 +355,32 @@ async def create_user(
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to create user username=%s: %s", username, exc)
|
||||
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
|
||||
|
||||
@@ -8,6 +8,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from bot.texts import NO_SUBSCRIPTION, NOTHING_PLACEHOLDER, SUBSCRIPTION_INFORMATION
|
||||
from config import settings
|
||||
from db.models import Subscription
|
||||
from db.models.users import User
|
||||
from misc import utils
|
||||
from repositories import UserRepository
|
||||
@@ -122,7 +123,7 @@ class UserService:
|
||||
session,
|
||||
user.id,
|
||||
end_date=rw_user.expire_at + duration,
|
||||
autorenew=False,
|
||||
autorenew=db_subscription.autorenew,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
devices=int(subscription.devices),
|
||||
whitelists=subscription.whitelists,
|
||||
@@ -179,7 +180,7 @@ class UserService:
|
||||
session,
|
||||
user.id,
|
||||
end_date=expire,
|
||||
autorenew=False,
|
||||
autorenew=db_subscription.autorenew,
|
||||
status=SubscriptionStatus.ACTIVE,
|
||||
devices=int(subscription.devices),
|
||||
whitelists=subscription.whitelists,
|
||||
@@ -239,3 +240,14 @@ class UserService:
|
||||
logger.exception("caught an exception while launching promo, skipping user...")
|
||||
|
||||
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,
|
||||
]
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ load_dotenv()
|
||||
|
||||
ENDPOINT = "/pally/result"
|
||||
|
||||
|
||||
def calculate_signature(
|
||||
out_sum: str,
|
||||
inv_id: str,
|
||||
@@ -39,34 +40,61 @@ def main():
|
||||
parser.add_argument(
|
||||
"--status",
|
||||
default="SUCCESS",
|
||||
choices=["SUCCESS", "FAIL", "PROCESS"],
|
||||
choices=["SUCCESS", "FAIL", "UNDERPAID", "OVERPAID"],
|
||||
help="Статус платежа (по умолчанию SUCCESS)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--customer-pays-fees",
|
||||
action="store_true",
|
||||
help="Симулировать сценарий 'клиент платит комиссию'",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
pally_token = os.environ["PALLY_TOKEN"]
|
||||
|
||||
# Эти поля могут быть любыми строками — вебхук их не использует содержательно,
|
||||
# только включает в подпись. Главное чтобы совпадали с тем, что пойдёт в подпись.
|
||||
payment_id = "test_payment_001"
|
||||
bill_id = f"test_bill_{args.bill_id}"
|
||||
# ИСПРАВЛЕНО: 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
|
||||
currency = "RUB"
|
||||
order_id = str(args.bill_id) # order_id == id счёта в нашей БД
|
||||
custom = None # custom field не используется в production (остается None)
|
||||
|
||||
signature = calculate_signature(
|
||||
payment_id, args.status, str(args.amount), currency, order_id, pally_token
|
||||
)
|
||||
# Симуляция комиссии
|
||||
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("customer pays fee:")
|
||||
print(f" Сумма заказа: {args.amount} RUB")
|
||||
print(f" Комиссия: {commission:.2f} RUB")
|
||||
print(f" Итого к оплате: {gross_amount:.2f} RUB")
|
||||
print(f" К зачислению: {args.amount} RUB")
|
||||
else:
|
||||
gross_amount = args.amount
|
||||
commission = 0.00
|
||||
|
||||
# Сигнатура рассчитывается с использованием InvId (который содержит bill ID)
|
||||
signature = calculate_signature(f"{gross_amount:.2f}", inv_id, pally_token)
|
||||
|
||||
# Используем правильные имена полей согласно документации pally.info
|
||||
payload = {
|
||||
"id": payment_id,
|
||||
"bill_id": bill_id,
|
||||
"status": args.status,
|
||||
"req_amount": str(args.amount),
|
||||
"currency": currency,
|
||||
"order_id": order_id,
|
||||
"signature": signature,
|
||||
"InvId": inv_id, # Содержит ID счета из БД
|
||||
"OutSum": f"{gross_amount:.2f}", # Сумма, которую заплатил клиент (с комиссией)
|
||||
"Commission": f"{commission:.2f}",
|
||||
"TrsId": trs_id,
|
||||
"Status": args.status,
|
||||
"CurrencyIn": currency,
|
||||
"custom": custom, # None в production
|
||||
"SignatureValue": signature,
|
||||
}
|
||||
|
||||
# Добавляем BalanceAmount только если клиент платит комиссию
|
||||
if args.customer_pays_fees:
|
||||
payload["BalanceAmount"] = str(args.amount) # Сумма к зачислению (без комиссии)
|
||||
payload["BalanceCurrency"] = currency
|
||||
|
||||
print(f"{args.host}{ENDPOINT}")
|
||||
print(f"Отправляю вебхук: {payload}\n")
|
||||
response = httpx.post(f"{args.host}{ENDPOINT}", data=payload)
|
||||
|
||||
Reference in New Issue
Block a user