Compare commits

...

29 Commits

Author SHA1 Message Date
245741b182 feat(subscription): improve subscription sync and renewal logic. 2026-06-17 18:10:56 +07:00
7ab0e71108 style(keyboards): add emoji to device management button 2026-06-17 15:46:58 +07:00
3adcc90b74 chore(docker): remove exposed PostgreSQL port from compose 2026-06-17 15:45:01 +07:00
93f05e46b5 feat(bot): implement HWID device list and deletion 2026-06-17 15:40:35 +07:00
agony
edfe1e6874 feat(subs): UNFINISHED hwid control 2026-06-16 16:24:02 +07:00
greendevilll
7ab2293fe2 fix(notifications): multiply duration days by 30 for admin log accuracy 2026-06-06 23:49:41 +07:00
9552813f16 fix(autorenewal): remove duplicated response handling in toggle_autorenewal 2026-06-04 13:44:09 +07:00
4c5a16a3ca feat(admin): add admin notification system for deposits, purchases, and renewals 2026-06-04 12:35:32 +07:00
5ec9491e22 feat(pally): support customer-pays-fees scenario with BalanceAmount validation 2026-05-31 15:23:31 +07:00
18f994aed9 fix(pally): use InvId as primary bill identifier instead of custom field 2026-05-31 15:17:48 +07:00
936d1ad260 fix(api): enhance pally webhook with comprehensive error handling and logging 2026-05-31 15:11:54 +07:00
2a0f5c454e debugging 2026-05-31 14:53:49 +07:00
2103e6a882 Revert to b5976d0b21 2026-05-31 14:47:53 +07:00
102a0ebf96 fix(pally): strip 'payment-' prefix from InvId before database lookup 2026-05-31 14:44:43 +07:00
18f4997ca6 fix(pally): use InvId instead of custom field for order ID lookup 2026-05-31 14:37:34 +07:00
b5976d0b21 fix(pally): align webhook handler with official API spec 2026-05-31 14:25:05 +07:00
763e041a8b fix(api): change pally webhook endpoint from /success to /result 2026-05-31 14:14:17 +07:00
f9d3fbf937 refactor(api): update pally webhook endpoint path 2026-05-31 14:05:27 +07:00
a02af3b0f0 chore(docker): bind pally service port to localhost only 2026-05-31 13:16:04 +07:00
a0c8167da6 chore(docker): use .env instead of prod.env 2026-05-31 12:56:07 +07:00
717a1549e9 feat(ui): enhance text formatting with premium emojis and improve FAQ clarity 2026-05-24 15:43:54 +07:00
18866fc645 chore(texts): reorder constants and remove outdated mapping section 2026-05-23 21:26:20 +07:00
1228ec8b56 refactor(autorenewal): clean up imports and simplify error handling 2026-05-18 17:58:34 +07:00
a9a650f37b feat(config): mask sensitive credentials with SecretStr type 2026-05-18 17:48:34 +07:00
56f10d19c5 feat(autorenewal): implement automatic subscription renewal system 2026-05-18 17:32:04 +07:00
281a460974 refactoring: polished some files, .env.example update 2026-05-18 17:00:17 +07:00
acb8662a66 Refactor bot handlers and keyboards; implement admin features
- Removed unused prehandling, referrals, support, and states files.
- Updated admin and client handlers to improve structure and functionality.
- Introduced admin filters and routers for better access control.
- Added new billing and deposit functionalities for client interactions.
- Enhanced keyboard layouts for admin and client menus.
- Implemented promotion handling for admin users.
- Updated configuration to include admin settings.
- Improved utility functions for quote fetching and menu text building.
- Added common handlers for undefined commands and message deletions.
2026-05-13 10:34:06 +07:00
e9f84d9377 fix: enhance insufficient funds message and update subscription controls logic 2026-05-03 07:03:53 +07:00
ac61cec78c fix: added proxy support for the API telegram bot instance + removed deprecated plans 2026-05-02 13:51:27 +07:00
51 changed files with 1362 additions and 2775 deletions

View File

@@ -1,25 +1,47 @@
# Telegram Bot Token (Required) # Database
# Get it from @BotFather on Telegram POSTGRES_USER=
BOT_TOKEN=your_bot_token_here POSTGRES_PASSWORD=
POSTGRES_DB=
POSTGRES_URL=postgresql+asyncpg://user:password@localhost:5432/database_name
# Remnawave Configuration (Required)
REMNAWAVE_URL=https://your-remnawave-instance.com
REMNAWAVE_TOKEN=your_remnawave_api_token_here
SUBSCRIPTION_URL=https://your-subscription-page.com
# PostgreSQL Database Configuration # Telegram bot
# These variables are used by docker-compose to set up the database container BOT_TOKEN=your_telegram_bot_token
POSTGRES_USER=malenia
POSTGRES_PASSWORD=change_me_to_a_secure_password
POSTGRES_DB=malenia_db
# Proxy Configuration (Optional) # Comma-separated list of Telegram user IDs
# Leave empty if no proxy is needed ADMINS=[123456789,987654321]
PROXY=
# Pricing and Limits Configuration (Optional - defaults provided in code) # 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
# Referral system
REFERAL_BONUS=10
DEPOSIT_THRESHOLD=50
# Optional proxy
PROXY=http://user:password@host:port
# Remnawave
REMNAWAVE_URL=https://example.com
SUBSCRIPTION_URL=https://example.com/subscription
REMNAWAVE_TOKEN=your_remnawave_token
# Device pricing
MIN_DEVICES=3 MIN_DEVICES=3
MAX_DEVICES=12 MAX_DEVICES=12
PER_DEVICE_COST=50 PER_DEVICE_COST=50
WHITELIST_COST=100 WHITELIST_COST=100
WHITELIST_DEVICE_THRESHOLD=6 WHITELIST_DEVICE_THRESHOLD=6
# Squads
GENERAL_SQUAD=2de0b5ea-49b9-4181-916f-ae67b9c83b80
WHITELIST_SQUAD=e4435baa-64d3-4b29-a18e-81ad1c60d977
# Autorenewal
AUTORENEW_DAYS_BEFORE=1

5
.gitignore vendored
View File

@@ -130,6 +130,7 @@ celerybeat.pid
# Environments # Environments
.env .env
*.env
dev.env dev.env
.venv .venv
env/ env/
@@ -173,4 +174,6 @@ cython_debug/
.ruff_cache/ .ruff_cache/
# PyPI configuration file # PyPI configuration file
.pypirc .pypirc
plans

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13.0

View File

@@ -0,0 +1,40 @@
"""add_autorenew_to_balancetxtype
Revision ID: 0d71262de0d5
Revises: f5fbc1975807
Create Date: 2026-05-18 17:26:23.302066
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '0d71262de0d5'
down_revision: Union[str, Sequence[str], None] = 'f5fbc1975807'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TYPE balancetxtype ADD VALUE 'autorenew'")
def downgrade() -> None:
# PostgreSQL не поддерживает удаление значений из ENUM напрямую.
# Для даунгрейда нужно пересоздать тип, как в миграции f0668f8e7310.
op.execute("""
CREATE TYPE balancetxtype_old AS ENUM (
'deposit', 'purchase', 'refund', 'referral', 'manual'
)
""")
op.execute("ALTER TABLE balance_transactions ALTER COLUMN tx_type DROP DEFAULT")
op.execute("""
ALTER TABLE balance_transactions
ALTER COLUMN tx_type TYPE balancetxtype_old
USING (tx_type::text::balancetxtype_old)
""")
op.execute("DROP TYPE balancetxtype")
op.execute("ALTER TYPE balancetxtype_old RENAME TO balancetxtype")

View File

@@ -0,0 +1,28 @@
"""add_autorenew_to_balancetxtype
Revision ID: bb4199d71f9e
Revises: 0d71262de0d5
Create Date: 2026-05-18 17:26:23.454401
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'bb4199d71f9e'
down_revision: Union[str, Sequence[str], None] = '0d71262de0d5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
pass
def downgrade() -> None:
"""Downgrade schema."""
pass

View File

@@ -0,0 +1,42 @@
"""id->unique, deleted deprecated tables
Revision ID: f5fbc1975807
Revises: 92dfa9b572f1
Create Date: 2026-05-18 17:08:56.792369
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'f5fbc1975807'
down_revision: Union[str, Sequence[str], None] = '92dfa9b572f1'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('tickets')
op.create_unique_constraint(None, 'bills', ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'bills', type_='unique')
op.create_table('tickets',
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
sa.Column('creator_id', sa.BIGINT(), autoincrement=False, nullable=False),
sa.Column('thread_id', sa.BIGINT(), autoincrement=False, nullable=True),
sa.Column('status', postgresql.ENUM('OPEN', 'CLOSED', 'PENDING', name='ticketstatus'), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('tickets_pkey')),
sa.UniqueConstraint('id', name=op.f('tickets_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False),
sa.UniqueConstraint('thread_id', name=op.f('tickets_thread_id_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
# ### end Alembic commands ###

View File

@@ -3,6 +3,7 @@ from contextlib import asynccontextmanager
from aiogram import Bot from aiogram import Bot
from aiogram.client.default import DefaultBotProperties from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession
from fastapi import FastAPI from fastapi import FastAPI
from api.routes import routers from api.routes import routers
@@ -18,8 +19,9 @@ from services.user_service import UserService
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator: async def lifespan(app: FastAPI) -> AsyncGenerator:
# Bot Init # Bot Init
bot_default = DefaultBotProperties(parse_mode="HTML") aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
bot = Bot(token=settings.bot_token, default=bot_default) default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
app.state.bot = bot app.state.bot = bot
user_repository = UserRepository() user_repository = UserRepository()

View File

@@ -1,4 +1,6 @@
# ruff: noqa: N803
import hashlib import hashlib
import hmac
import logging import logging
import math import math
@@ -14,72 +16,189 @@ 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="/checkout/pally") router = APIRouter(prefix="/pally")
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@router.post("/callback") @router.post("/result")
async def pally_callback( async def pally_callback( # noqa: PLR0911, PLR0912
id: str = Form(...), InvId: str = Form(...),
bill_id: str = Form(...), OutSum: str = Form(...),
status: str = Form(...), Commission: str = Form(...),
req_amount: str = Form(...), TrsId: str = Form(...),
currency: str = Form(...), Status: str = Form(...),
order_id: str | None = Form(None), CurrencyIn: str = Form(...),
signature: 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), session: AsyncSession = Depends(get_db),
bot: Bot = Depends(get_bot), bot: Bot = Depends(get_bot),
deps: APIDependenciesDTO = Depends(get_deps), deps: APIDependenciesDTO = Depends(get_deps),
): ):
oid = order_id or "" # FIXED: Use InvId (which contains the order_id from bill creation) instead of custom field
raw_string = f"{id}{status}{req_amount}{currency}{oid}{settings.pally_token}" # The InvId field contains the db_bill.id that was passed as order_id during bill creation
md5_hash = hashlib.md5(raw_string.encode("utf-8")).hexdigest() bill_id_str = InvId
expected_signature = hashlib.sha1(md5_hash.encode("utf-8")).hexdigest()
if signature != expected_signature: logger.info(
logger.critical("Invalid signature for bill %s", bill_id) "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()
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.") raise HTTPException(403, detail="Invalid signature.")
if status != BillStatus.SUCCESS or not req_amount.isdigit(): # Only process successful payments
logger.info("Bill %s skipped: status=%s, req_amount=%s", bill_id, status, req_amount) if Status != BillStatus.SUCCESS:
logger.info("Bill %s skipped: status=%s", TrsId, Status)
return "OK" return "OK"
logger.info("Received successfully paid bill %s", bill_id) logger.info("Processing successfully paid bill %s", TrsId)
if not oid.isdigit(): # Validate bill ID (from InvId field, which contains the order_id from bill creation)
logger.critical("Invalid order_id format for bill %s", bill_id) 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" 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: if not bill:
logger.critical("Bill %s is not found in db", bill_id) logger.critical("Bill %s not found in database for TrsId %s", bill_id_str, TrsId)
return "OK" return "OK"
# Check if already processed
if bill.status != DepositStatus.PENDING: 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" return "OK"
try: try:
amount = int(req_amount) # 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.info(
logger.warning("requested amount for bill=%s is not equal to db entry", bill.id) "Customer-pays-fees payment: bill_id=%s, expected=%s, gross_paid=%s, net_credited=%s",
return "OK" 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, session,
bill.creator_id, bill.creator_id,
amount=amount, amount=amount,
tx_type=BalanceTxType.DEPOSIT, 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) 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) user = await deps.user_repository.get_user_by_id(session, bill.creator_id)
referal = user.referal_id if user else None referal = user.referal_id if user else None
if referal is not None: if referal is not None:
referal_amount = math.floor(amount * (settings.referal_bonus / 100)) referal_amount = math.floor(amount * (settings.referal_bonus / 100))
@@ -88,19 +207,31 @@ async def pally_callback(
referal, referal,
referal_amount, referal_amount,
tx_type=BalanceTxType.REFERRAL_BONUS, 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) 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( logger.exception(
"Exception caught while processing balances for bill %s (Creator: %s)", "CRITICAL ERROR processing payment for TrsId %s, bill_id %s, user_id %s: %s",
bill_id, TrsId,
bill.id,
bill.creator_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( 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)
return "OK" return "OK"

8
bot/filters/admins.py Normal file
View File

@@ -0,0 +1,8 @@
from aiogram.filters import Filter
from config import settings
class AdminFilter(Filter):
async def __call__(self, event) -> bool:
return event.from_user.id in settings.admins

View File

@@ -1,21 +1,9 @@
from .ads import router as ads_router from .admins import routers as admin_routers
from .billing import router as billing_router from .client import routers as client_routers
from .buy import router as buy_router from .system import routers as system_routers
from .common import router as common_router
from .deposit import router as deposit_router
from .menus import router as menus_router
from .prehandling import router as prehandling_router
from .referals import router as referal_router
from .support import router as support_router
routers = [ __all__ = [
prehandling_router, "admin_routers",
referal_router, "client_routers",
billing_router, "system_routers",
deposit_router,
menus_router,
ads_router,
support_router,
buy_router,
common_router,
] ]

View File

@@ -0,0 +1,4 @@
from .admins import router as general_router
from .ads import router as ads_router
routers = [general_router, ads_router]

View File

@@ -0,0 +1,36 @@
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from bot.filters.admins import AdminFilter
from bot.keyboards.admins import main_menu
from misc.utils import build_adm_main_menu_text
from schemas.di import DependenciesDTO
router = Router()
@router.message(AdminFilter(), Command(commands=["start", "cancel"]))
async def standard_start(
msg: Message, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
users = await deps.user_repository.user_count(session)
await msg.answer(
build_adm_main_menu_text(user_count=users, user_id=msg.from_user.id), reply_markup=main_menu
)
@router.callback_query(AdminFilter(), F.data == "menu:main")
async def main_menu_cb(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
await state.clear()
users = await deps.user_repository.user_count(session)
await cb.message.edit_text(
build_adm_main_menu_text(user_count=users, user_id=cb.from_user.id), reply_markup=main_menu
)

View File

@@ -0,0 +1,65 @@
from aiogram import F, Router
from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message
from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.admins import action_confirmation
from bot.keyboards.common import return_to_menu
from bot.states.admins import AdminStorage
from bot.texts import (
ADM_PROMO_INIT,
ADM_VERIFY_PROMOTION,
CALLBACK_FALLBACK,
GENERAL_PROCESSING,
SUCCESSFULLY_SENT_PROMO,
)
from schemas.di import DependenciesDTO
from schemas.promotions import NewPromotionContext
router = Router()
@router.callback_query(F.data == "promotion")
async def promo_init(cb: CallbackQuery, state: FSMContext):
await state.clear()
await cb.message.edit_text(ADM_PROMO_INIT, reply_markup=return_to_menu)
ctx = NewPromotionContext(cb.message)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.message(AdminStorage.promotion_msg)
async def promo_text(msg: Message, state: FSMContext):
data = await state.get_data()
await state.clear()
edited_msg = await msg.answer(GENERAL_PROCESSING)
ctx: NewPromotionContext | None = data.get("ctx")
if ctx:
await ctx.msg.delete()
ctx.promotion = msg
await edited_msg.edit_text(ADM_VERIFY_PROMOTION, reply_markup=action_confirmation)
await state.set_state(AdminStorage.promotion_msg)
await state.set_data({"ctx": ctx})
@router.callback_query(AdminStorage.promotion_msg, F.data.in_(("approve", "decline")))
async def promo_send(
cb: CallbackQuery, state: FSMContext, session: AsyncSession, deps: DependenciesDTO
):
data = await state.get_data()
await state.clear()
ctx: NewPromotionContext | None = data.get("ctx")
if not (ctx and ctx.promotion and ctx.msg):
await cb.message.edit_text(CALLBACK_FALLBACK, reply_markup=return_to_menu)
return
successful = await deps.user_service.launch_promotion(session, ctx.promotion)
await cb.message.edit_text(
SUCCESSFULLY_SENT_PROMO.format(successful=successful), reply_markup=return_to_menu
)

View File

@@ -1,5 +0,0 @@
from aiogram import Router
router = Router()
# FIXME: Refine newsletter system

View File

@@ -0,0 +1,15 @@
from .billing import router as billing_router
from .buy import router as buy_router
from .deposit import router as deposit_router
from .menus import router as menus_router
from .referals import router as referal_router
from .support import router as support_router
routers = [
referal_router,
billing_router,
deposit_router,
menus_router,
support_router,
buy_router,
]

View File

@@ -5,7 +5,8 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import pay_url, return_to_menu from bot.keyboards.client import pay_url
from bot.keyboards.common import return_to_menu
from bot.states.buy import BillingStorage from bot.states.buy import BillingStorage
from bot.texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK from bot.texts import BILL_CREATED, BILL_CREATION, CALLBACK_FALLBACK
from schemas.billing import BillingContext, BillingProviders from schemas.billing import BillingContext, BillingProviders

View File

@@ -6,7 +6,8 @@ from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery from aiogram.types import CallbackQuery
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from bot.keyboards.client import deposit_kb, devices_selector, duration_selector, return_to_menu from bot.keyboards.client import deposit_kb, devices_selector, duration_selector
from bot.keyboards.common import return_to_menu
from bot.states.buy import SubscriptionStorage from bot.states.buy import SubscriptionStorage
from bot.texts import ( from bot.texts import (
CALLBACK_FALLBACK, CALLBACK_FALLBACK,
@@ -28,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__)
@@ -176,7 +178,10 @@ async def proceed_to_checkout(
if effective_balance < amount: if effective_balance < amount:
await cb.message.edit_text( await cb.message.edit_text(
INSUFFICIENT_FUNDS.format(balance=user.balance, amount=amount), reply_markup=deposit_kb INSUFFICIENT_FUNDS.format(
balance=user.balance, amount=amount, diff=abs(amount - user.balance)
),
reply_markup=deposit_kb,
) )
return return
@@ -219,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

@@ -2,7 +2,8 @@ from aiogram import F, Router
from aiogram.fsm.context import FSMContext from aiogram.fsm.context import FSMContext
from aiogram.types import CallbackQuery, Message from aiogram.types import CallbackQuery, Message
from bot.keyboards.client import payment_gateways, return_to_menu from bot.keyboards.client import payment_gateways
from bot.keyboards.common import return_to_menu
from bot.states.buy import BillingStorage from bot.states.buy import BillingStorage
from bot.states.deposit import DepositStorage from bot.states.deposit import DepositStorage
from bot.texts import ( from bot.texts import (

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, return_to_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 ( from bot.texts import (
CALLBACK_FALLBACK, CALLBACK_FALLBACK,
FAQ_TEXT, FAQ_TEXT,
GENERAL_PROCESSING, GENERAL_PROCESSING,
GENERAL_SUCCESS, HWID_LIST,
NOTHING_PLACEHOLDER, NO_CONNECTIONS,
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])
@@ -121,46 +141,116 @@ async def sub_info(
) )
sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id) sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id)
if not rw_user:
await cb.message.edit_text(NO_SUBSCRIPTION, reply_markup=return_to_menu)
return
if not user: if not user:
await cb.answer(CALLBACK_FALLBACK) await cb.answer(CALLBACK_FALLBACK)
return return
if not sub: 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: 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(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(
rw_user, link=user.subscription_link, autorenew=sub.autorenew rw_user, link=user.subscription_link, autorenew=sub.autorenew
), ),
reply_markup=subscription_controls(sub.autorenew), reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
) )
@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
): ):
await state.clear() await state.clear()
await cb.answer(NOTHING_PLACEHOLDER + " В разработке.")
return
await cb.message.edit_text(GENERAL_PROCESSING) await cb.message.edit_text(GENERAL_PROCESSING)
sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id) sub = await deps.subscriptions_repository.get_subscription_by_user_id(session, cb.from_user.id)
@@ -178,10 +268,13 @@ async def toggle_autorenewal(
session, cb.from_user.id, deps.rw_sdk session, cb.from_user.id, deps.rw_sdk
) )
await cb.answer(GENERAL_SUCCESS) status_text = "включено" if new_autorenew else "отключено"
await cb.answer(f"Автопродление {status_text}")
await cb.message.edit_text( await cb.message.edit_text(
deps.user_service.format_subscription_message( deps.user_service.format_subscription_message(
rw_user, link=user.subscription_link, autorenew=sub.autorenew rw_user, link=user.subscription_link, autorenew=sub.autorenew
), ),
reply_markup=subscription_controls(sub.autorenew), reply_markup=subscription_controls(
show_autorenew=bool(rw_user), current_autorenewal_status=sub.autorenew
),
) )

View File

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

View File

@@ -1,16 +1,16 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder from aiogram.utils.keyboard import InlineKeyboardBuilder
promotion_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder( main_menu: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="📢 Рассылка", callback_data="promotion")],
# [InlineKeyboardButton(text="👤 Управление пользователем", callback_data="usermgmt")],
]
).as_markup()
action_confirmation: InlineKeyboardMarkup = InlineKeyboardBuilder(
[ [
[InlineKeyboardButton(text="", callback_data="approve")], [InlineKeyboardButton(text="", callback_data="approve")],
[InlineKeyboardButton(text="", callback_data="decline")], [InlineKeyboardButton(text="", callback_data="decline")],
] ]
).as_markup() ).as_markup()
ticket_actions: InlineKeyboardMarkup = InlineKeyboardBuilder(
[
[InlineKeyboardButton(text="🔒", callback_data="close")],
[InlineKeyboardButton(text="💸 Пополнить баланс реферала", callback_data="refpay")],
]
).as_markup()

View File

@@ -1,10 +1,13 @@
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
main_menu = InlineKeyboardBuilder( main_menu = InlineKeyboardBuilder(
[ [
[InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")], [InlineKeyboardButton(text="📊 Подписка", callback_data="sub_info")],
@@ -24,12 +27,6 @@ main_menu = InlineKeyboardBuilder(
] ]
).as_markup() ).as_markup()
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_btn]]).as_markup()
payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder( payment_gateways: InlineKeyboardMarkup = InlineKeyboardBuilder(
[ [
[InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")], [InlineKeyboardButton(text="💸 Pally (СБП / Карта)", callback_data="billing:pally")],
@@ -54,18 +51,24 @@ deposit_kb: InlineKeyboardMarkup = InlineKeyboardBuilder(
).as_markup() ).as_markup()
def subscription_controls(current_autorenewal_status: bool) -> InlineKeyboardMarkup: def subscription_controls(
autorenewal_text = ( show_autorenew: bool, current_autorenewal_status: bool | None = None
"🟢 Включить автопродление" ) -> InlineKeyboardMarkup:
if not current_autorenewal_status builder = InlineKeyboardBuilder()
else "🔴 Отключить автопродление"
if show_autorenew:
autorenewal_text = (
"🟢 Включить автопродление"
if not current_autorenewal_status
else "🔴 Отключить автопродление"
)
builder.row(InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal"))
builder.row(
InlineKeyboardButton(text="💻 Управление устройствами", callback_data="hwid_control")
) )
return InlineKeyboardBuilder( builder.row(_return_to_menu_btn)
[ return builder.as_markup()
[InlineKeyboardButton(text=autorenewal_text, callback_data="toggle:autorenewal")],
[_return_to_menu_btn],
]
).as_markup()
def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton: def _return_btn(data: str = "menu:main", text: str = "⬅️ Назад") -> InlineKeyboardButton:
@@ -144,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()

8
bot/keyboards/common.py Normal file
View File

@@ -0,0 +1,8 @@
from aiogram.types import InlineKeyboardButton, InlineKeyboardMarkup
from aiogram.utils.keyboard import InlineKeyboardBuilder
_return_to_menu_btn: InlineKeyboardButton = InlineKeyboardButton(
text="⬅️ Назад", callback_data="menu:main"
)
return_to_menu: InlineKeyboardMarkup = InlineKeyboardBuilder([[_return_to_menu_btn]]).as_markup()

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import contextlib
import logging import logging
from aiogram import Bot, Dispatcher from aiogram import Bot, Dispatcher
@@ -6,8 +7,9 @@ from aiogram.client.default import DefaultBotProperties
from aiogram.client.session.aiohttp import AiohttpSession from aiogram.client.session.aiohttp import AiohttpSession
from remnawave import RemnawaveSDK from remnawave import RemnawaveSDK
from bot.handlers import routers from bot.handlers import admin_routers, client_routers, system_routers
from bot.middlewares import DIMiddleware from bot.middlewares import DIMiddleware
from bot.scheduler import start_scheduler
from config import settings from config import settings
from db.session import async_session from db.session import async_session
from misc.pally import PallyClient from misc.pally import PallyClient
@@ -16,9 +18,11 @@ from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from schemas.di import DependenciesDTO from schemas.di import DependenciesDTO
from services.billing_service import BillingService from services.billing_service import BillingService
from services.renewal_service import RenewalService
from services.user_service import UserService from services.user_service import UserService
logging.basicConfig(level=logging.DEBUG) logging.basicConfig(level=logging.DEBUG)
contextlib.suppress(asyncio.CancelledError)
async def main(): async def main():
@@ -40,6 +44,17 @@ async def main():
token=settings.remnawave_token, token=settings.remnawave_token,
) )
aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None
default = DefaultBotProperties(parse_mode="HTML")
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session)
renewal_service = RenewalService(
subscription_repository=subscriptions_repository,
user_repository=user_repository,
rw_sdk=rw_sdk,
bot=bot,
)
deps = DependenciesDTO( deps = DependenciesDTO(
user_repository=user_repository, user_repository=user_repository,
user_service=user_service, user_service=user_service,
@@ -48,17 +63,24 @@ async def main():
bills_repository=bills_repository, bills_repository=bills_repository,
billing_service=billing_service, billing_service=billing_service,
subscriptions_repository=subscriptions_repository, subscriptions_repository=subscriptions_repository,
renewal_service=renewal_service,
) )
dp.update.middleware.register(DIMiddleware(deps, async_session)) dp.update.middleware.register(DIMiddleware(deps, async_session))
aiohttp_session = AiohttpSession(proxy=settings.proxy) if settings.proxy else None for r in admin_routers:
default = DefaultBotProperties(parse_mode="HTML") dp.include_router(r)
bot = Bot(token=settings.bot_token, default=default, session=aiohttp_session) for r in client_routers:
dp.include_router(r)
for r in system_routers:
dp.include_router(r)
for router in routers: scheduler_task = asyncio.create_task(start_scheduler(renewal_service, async_session))
dp.include_router(router)
await dp.start_polling(bot) try:
await dp.start_polling(bot)
finally:
scheduler_task.cancel()
await scheduler_task
if __name__ == "__main__": if __name__ == "__main__":

29
bot/scheduler.py Normal file
View File

@@ -0,0 +1,29 @@
import asyncio
import logging
from sqlalchemy.ext.asyncio import async_sessionmaker
from services.renewal_service import RenewalService
logger = logging.getLogger(__name__)
async def run_autorenewal(renewal_service: RenewalService, session_factory: async_sessionmaker):
async with session_factory() as session:
try:
results = await renewal_service.process_autorenewals(session)
logger.info("Autorenewal job completed: %s", results)
except Exception:
logger.exception("Autorenewal job failed")
await session.rollback()
async def start_scheduler(
renewal_service: RenewalService,
session_factory: async_sessionmaker,
interval_hours: int = 24,
):
logger.info("Starting autorenewal scheduler with interval %dh", interval_hours)
while True:
await run_autorenewal(renewal_service, session_factory)
await asyncio.sleep(interval_hours * 3600)

View File

@@ -1,5 +0,0 @@
from aiogram.fsm.state import State, StatesGroup
class SupportStorage(StatesGroup):
prompt = State()

View File

@@ -19,27 +19,23 @@ MALENIA_LINK = PremiumEmoji(5276012730636082950, "🔗")
MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙") MALENIA_COINS = PremiumEmoji(5276298354551199712, "🪙")
MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒") MALENIA_LOCKED = PremiumEmoji(5276346642868508688, "🔒")
MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧") MALENIA_SUPPORT = PremiumEmoji(5275968088746006467, "🎧")
MALENIA_SHIELD = PremiumEmoji(5444924740596702937, "☀️")
MALENIA_SILHOUETTE = PremiumEmoji(5447117295631506779, "☀️")
MALENIA_CARD = PremiumEmoji(5447145526451543197, "☀️")
MALENIA_QUESTION = PremiumEmoji(5276157346479903928, "❤️")
MALENIA_CHAT = PremiumEmoji(5445391895599554146, "☀️")
MALENIA_CHECK = PremiumEmoji(5447446814112389179, "☀️")
MALENIA_CROSS = PremiumEmoji(5444977405485687021, "☀️")
MALENIA_WARN = PremiumEmoji(5447571325214303934, "☀️")
MALENIA_BANK = PremiumEmoji(5447344860178717542, "☀️")
# ============================================================ # ============================================================
# ШАБЛОНЫ — ОБЩИЕ БЛОКИ # ОБЩИЕ БЛОКИ
# ============================================================ # ============================================================
_SUPPORT_USER_DESCRIPTION = (
"👤 <b>Профиль:</b> {full_name}\n"
"🔗 <b>Alias:</b> {username_display}\n"
"🆔 <b>UID:</b> <code>{telegram_id}</code>\n\n"
"❤️ <b>Referal UID:</b> <code>{referal_id}</code>\n\n"
"💰 <b>Баланс:</b> <code>{balance}</code>"
)
_DIVIDER = "────────────────────────────\n\n" _DIVIDER = "────────────────────────────\n\n"
# ============================================================
# МАППИНГИ
# ============================================================
# ============================================================ # ============================================================
# СТАТУСЫ ПОДПИСКИ # СТАТУСЫ ПОДПИСКИ
# ============================================================ # ============================================================
@@ -58,8 +54,6 @@ STATUS_EXPIRED = "истёк"
STATUS_LIMITED = "трафик ограничен" STATUS_LIMITED = "трафик ограничен"
STATUS_UNKNOWN = "неизвестный статус" STATUS_UNKNOWN = "неизвестный статус"
GENERAL_SUCCESS = ""
# ============================================================ # ============================================================
# ФОРМАТИРОВАНИЕ ДАННЫХ # ФОРМАТИРОВАНИЕ ДАННЫХ
@@ -80,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}"
@@ -87,47 +88,66 @@ TRAFFIC_NO_LIMIT = "{used}"
# — Прочее # — Прочее
NOTHING_PLACEHOLDER = "" NOTHING_PLACEHOLDER = ""
GENERAL_SUCCESS = ""
# ============================================================ # ============================================================
# ПОЛЬЗОВАТЕЛЬСКИЙ ИНТЕРФЕЙС # ГЛАВНОЕ МЕНЮ
# ============================================================ # ============================================================
MAIN_MENU = ( MAIN_MENU = (
str(MALENIA_LOGO) str(MALENIA_LOGO)
+ " <b>Malenia — <i>{quote}</i></b>\n\n" + " <b>Malenia — <i>{quote}</i></b>\n\n"
+ f"<i>{MALENIA_HEART} Личный кабинет</i>\n" + f"<i>{MALENIA_CARD} Личный кабинет</i>\n"
+ f"<b>{MALENIA_COINS} Ваш баланс:</b> <code>{{balance}}₽</code>\n" + f"<b>{MALENIA_COINS} Ваш баланс:</b> <code>{{balance}}₽</code>\n"
+ f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>" + f"<b>{MALENIA_LINK} Ссылка на подписку: <code>{{link}}</code></b>"
) )
# ============================================================
# FAQ
# ============================================================
FAQ_TEXT = ( FAQ_TEXT = (
"<b>Ваши частые вопросы:</b>\n\n" f"<b>{MALENIA_HEART} Ваши частые вопросы:</b>\n\n"
"<b>— Зачем мне это?</b>\n" f"<b>{MALENIA_QUESTION} — Как продлить подписку?</b>\n"
"Если вас устраивает, что ваш интернет ограничен парой сайтов и банков, то вам не к нам. Но если хочется свободы и анонимности — добро пожаловать.\n\n" f"{MALENIA_CHAT} — Подписка автоматически продляется <b>из бота</b>. Просто пополните баланс на нужную сумму и мы сами продлим вашу подписку. Вы можете выключить автопродление и продлять вручную, если захотите.\n\n"
"<b>— Это безопасно?</b>\n" f"<b>{MALENIA_QUESTION} — Как изменить количество устройств / опцию Обхода Белых Списков и др.?</b>\n"
"Мы не храним ваши данные, не используем их в коммерческих целях и не передаем третьим лицам," f'{MALENIA_CHAT} — Вы можете это сделать в кнопке "Купить". Ваша подписка обновится на новый срок. Подробнее про механику продления вы можете почитать в <a href="https://telegra.ph/FAQ-Pokupka-podpiski-pri-dejstvuyushchej-podpiske-05-24">нашей статье</a>.\n\n'
"но важно понимать, что полной анонимности не существует. Мы делаем всё, чтобы минимизировать риски, но не продаём иллюзий.\n\n" f"<b>{MALENIA_QUESTION} — Что делать если VPN не работает?</b>\n"
"<b>— Почему что-то не работает?</b>\n" f'{MALENIA_CHAT} — Пишите <a href="https://t.me/maleniasupportbot">в поддержку</a>. <b>При обращении, не забывайте уточнять регион и оператора</b>, т.к. ограничения разнятся от этих параметров.\n\n'
"Фильтры постоянно обновляются и подстраиваются под новые методы обхода. А мы подстраиваемся под них.\n" f"<b>{MALENIA_CHAT} Блиц:</b>\n"
"Если что-то перестало работать, скорее всего, это временно, и мы уже работаем над решением. Но обращения в поддержку с описанием проблемы и скриншотами всегда приветствуются, это помогает нам быстрее находить и устранять проблемы.\n\n" "— Информация о подписке в боте.\n"
"<b>— Как работает реферальная система?</b>\n" "— Трафик безлимитен.\n"
"Вы получаете уникальную ссылку, по которой могут прийти ваши друзья. За каждого приведённого друга, который оформит подписку," '— Тарифы начинаются от 3-х устройств. Меньше нельзя. Хотите больше 12? Вам <a href="https://t.me/maleniasupportbot">в поддержку</a>.\n'
"<b>вам будет начисляться процент от их оплаченной подписки</b> на баланс."
) )
# ============================================================
# СОСТОЯНИЯ И ОБРАБОТКА
# ============================================================
CALLBACK_PROCESSING = "" CALLBACK_PROCESSING = ""
GENERAL_PROCESSING = "<i>⏳ Подождите...</i>" GENERAL_PROCESSING = "<i>⏳ Подождите...</i>"
CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться."
# ============================================================
# ОШИБКИ И FALLBACK
# ============================================================
STANDARD_FALLBACK = ( STANDARD_FALLBACK = (
"<b> Ошибка.</b> Прожмите /start. Если не поможет — значит, мы где-то не доглядели." f"<b>{MALENIA_CROSS} Ошибка.</b> Прожмите /start. Если не поможет то скоро починим."
) )
CALLBACK_FALLBACK = "❌ Что-то пошло не так. Повторите попытку или обратитесь в поддержку." CALLBACK_FALLBACK = "❌ Что-то пошло не так. Повторите попытку или обратитесь в поддержку."
CONTEXT_REINITIATED = "🌑 Сессия сброшена. Данные могли обновиться." UNDEFINED_MESSAGE = f"{MALENIA_CROSS} Таких команд у нас нет.\n\n<i>Ищете техподдержку? Вам в /support</i>\n<i>Потерялись? Вам в /start.</i>"
UNDEFINED_MESSAGE = "❌ Таких команд у нас нет.\n\n<i>Ищете техподдержку? Вам в /support</i>\n<i>Потерялись? Вам в /start.</i>"
# ============================================================
# ИНФОРМАЦИЯ О ПОДПИСКЕ
# ============================================================
SUBSCRIPTION_INFORMATION = ( SUBSCRIPTION_INFORMATION = (
"<b>📊 Данные о подписке</b>\n\n" "<b>📊 Данные о подписке</b>\n\n"
@@ -140,7 +160,12 @@ SUBSCRIPTION_INFORMATION = (
+ "<b>💸 Автопродление:</b> {autorenew}" + "<b>💸 Автопродление:</b> {autorenew}"
) )
NO_SUBSCRIPTION = "⚠️ <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>"
# ============================================================ # ============================================================
# РЕФЕРАЛЬНАЯ СИСТЕМА # РЕФЕРАЛЬНАЯ СИСТЕМА
@@ -148,35 +173,39 @@ NO_SUBSCRIPTION = "⚠️ <b>Подписка не найдена.</b>"
REFERALS_MENU = ( REFERALS_MENU = (
"<b>👥 Реферальная система</b>\n\n" "<b>👥 Реферальная система</b>\n\n"
"Делитесь свободой с друзьями и получайте деньги на оплату своей подписки.\n" "Делитесь с друзьями и получайте деньги на оплату своей подписки.\n"
f"<b>{MALENIA_COINS} На вашем счету:</b> " f"<b>{MALENIA_COINS} На вашем счету:</b> "
"<code>{balance}₽</code>\n" "<code>{balance}₽</code>\n"
f"<b>{MALENIA_LINK} Ваша реферальная ссылка:</b> " f"<b>{MALENIA_LINK} Ваша реферальная ссылка:</b> "
"<code>{link}</code>" "<code>{link}</code>"
) )
# ============================================================ # ============================================================
# ОФОРМЛЕНИЕ ПОДПИСКИ # ОФОРМЛЕНИЕ ПОДПИСКИ
# ============================================================ # ============================================================
SUBSCRIPTION_DEVICE_SELECTOR = "Сколько устройств вы хотите подключить?\n\nЦена: <b>{price}₽</b>" SUBSCRIPTION_DEVICE_SELECTOR = (
f"<b>{MALENIA_QUESTION} Сколько устройств вы хотите подключить?</b>\n\nЦена: <b>{{price}}₽</b>"
SUBSCRIPTION_DURATION_SELECTOR = (
"На какой срок хотите оформить подписку?\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 = "<b>💳 Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>" CHECKOUT_PAYMENT_CHOICE = (
f"<b>{MALENIA_BANK} Пополнение баланса:</b>\n\n<i>❓ Выберите способ оплаты:</i>"
)
# ============================================================ # ============================================================
# BILLING # БИЛЛИНГ
# ============================================================ # ============================================================
_BILL_TEMPLATE = ( _BILL_TEMPLATE = (
"<b>💸 Счёт на {amount}₽</b>\n\n" f"<b>{MALENIA_CHECK} Счёт на {{amount}}₽</b>\n\n"
"<i>‼️ Вам придёт уведомление в течение нескольких минут после завершения платежа.</i>\n" "<i>‼️ Вам придёт уведомление в течение нескольких минут после завершения платежа.</i>\n"
"<b>🔒 Способ оплаты:</b> <i>{provider}</i>\n\n" f"<b>{MALENIA_CARD} Способ оплаты:</b> <i>{{provider}}</i>\n\n"
) )
_BILL_LINK = f"<b>{MALENIA_LINK} Ссылка на оплату:</b>" _BILL_LINK = f"<b>{MALENIA_LINK} Ссылка на оплату:</b>"
@@ -185,78 +214,57 @@ BILL_CREATION = _BILL_TEMPLATE + _DIVIDER + GENERAL_PROCESSING
BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK BILL_CREATED = _BILL_TEMPLATE + _DIVIDER + _BILL_LINK
INSUFFICIENT_FUNDS = "<b>⚠️ Недостаточно средств для проведения операции.</b>\n\n💰 Текущий баланс: {balance}\n💸 Требуется: {amount}" 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 = (
"<b>💚 Ваша подписка успешно оплачена!</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 = "<b>💸 Укажите сумму пополнения</b>"
DEPOSIT_AMOUNT_FALLBACK = "<b>❌ Сумма пополнения указана неверно, повторите попытку.</b>"
DEPOSIT_AMOUNT_BELOW_MINIMAL = ( DEPOSIT_AMOUNT_BELOW_MINIMAL = (
"<b> Сумма пополнения ниже минимальной - <code>{threshold}₽</code>.</b>" f"<b>{MALENIA_CROSS} Сумма пополнения ниже минимальной - <code>{{threshold}}₽</code>.</b>"
) )
# ============================================================
# АВТОПРОДЛЕНИЕ
# ============================================================
AUTORENEWAL_INSUFFICIENT_FUNDS = (
f"<b>{MALENIA_WARN} Не удалось продлить подписку автоматически.</b>\n"
"Недостаточно средств на балансе (нужно <code>{price}₽</code>, <code>доступно {balance}₽</code>). Автопродление отключено."
)
AUTORENEWAL_SUCCESS = (
f"<b>{MALENIA_CARD} Подписка продлена автоматически. Списано <code>{{price}}₽</code>.</b>\n"
"Новая дата окончания: <code>{end_date}</code>."
)
# ============================================================ # ============================================================
# ПОДДЕРЖКА # ПОДДЕРЖКА
# ============================================================ # ============================================================
SUPPORT_INIT = "<b>🎧 Тех. Поддержка Malenia</b> — не здесь. Кнопка ниже." SUPPORT_INIT = "<b>🎧 Тех. Поддержка Malenia</b> — не здесь. Кнопка ниже."
SUPPORT_MSG_SENT = "⏳ Приняли ваше сообщение. Мы постараемся ответить на него в ближайшее время.\n<i>Вернуться в меню: /cancel</i>"
SUPPORT_SIGNATURE = "<b>📡 Ответ от Malenia:</b>"
# ============================================================ # ============================================================
# ШАБЛОНЫ — ЗАЯВКИ И ТИКЕТЫ # АДМИН-ПАНЕЛЬ
# ============================================================ # ============================================================
SUPPORT_SUBSCRIPTION = ( ADM_MAIN_MENU = (
"<b>🎫 ВХОДЯЩИЙ ЗАПРОС:</b>\n\n" f"<b>{MALENIA_SHIELD} Malenia. Админ-панель.</b>\n"
+ _SUPPORT_USER_DESCRIPTION "<i>— {quote}</i>\n\n"
+ "\n" f"<b>{MALENIA_SILHOUETTE} Пользователей в боте:</b> <code>{{user_count}}</code>\n"
+ _DIVIDER f"<b>{MALENIA_CARD} Ваш ID:</b> <code>{{user_id}}</code>\n"
+ "🗓️ Срок: <b>{duration} мес.</b>\n"
+ "📱 Устройства: <b>{devices}</b>\n"
+ "🏳️ Whitelists: <b>{whitelist_description}</b>\n\n"
+ "💰 К оплате: <b>{price}₽</b>"
) )
ADMIN_TICKET_WITH_RW = ( ADM_PROMO_INIT = "<b>📢 Введите/перешлите текст рассылки:</b>"
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION ADM_VERIFY_PROMOTION = (
+ "\n" "<b>❓ Отправить рассылку? <i>Это действие будет невозможно отменить.</i></b>"
+ _DIVIDER
+ "📊 <b>ТЕХ. ДАННЫЕ (Remnawave)</b>\n\n"
+ "{status_icon} <b>Статус:</b> {status}\n"
+ "📅 <b>Deadline:</b> {expire_date}\n"
+ "⏳ <b>Остаток:</b> {days_left}\n"
+ "📊 <b>Трафик:</b> {used_traffic}\n"
+ "📱 <b>HWID:</b> {hwid_limit}\n"
+ "🎯 <b>Squads:</b> {squads}\n\n"
+ _DIVIDER
+ "🔑 <b>UUID:</b> <code>{remnawave_uuid}</code>"
) )
ADMIN_TICKET_WITHOUT_RW = ( SUCCESSFULLY_SENT_PROMO = "<b>💚 Успешно отправлена рассылка.</b>\n<i>⚡ Успешных отправок: <code>{successful}</code>.</i>"
"🎫 <b>ТИКЕТ T-{ticket_number:04d}</b>\n\n"
+ _SUPPORT_USER_DESCRIPTION
+ "\n"
+ _DIVIDER
+ NO_SUBSCRIPTION
)
ADMIN_STANDARD_FALLBACK = "<b>❌ Ошибка выполнения.</b> Проверь логи, что-то пошло не по чертежу."
# ============================================================
# АДМИНСКАЯ ХУЙНЯ
# ============================================================
ADMIN_PROMOTION_INIT = "👥 Введите текст рассылки"
ADMIN_PROMOTION_CONFIRMATION = "❓ Отправить сообщение всем пользователям бота?"
SUCCESSFULLY_SENT_PROMOTION = "Успешно разосланно {user_count} пользователям."
SPECIFY_REFERAL_PAYMENT_AMOUNT = "Укажите количество средств для перевода рефералу пользователя."
USER_NO_REFERAL = "У пользователя нет реферала."
INVALID_ARGUMENT_REFPAY = "❌ Укажите в аргументах количество средств, которые необходимо перевести рефералу пользователя."
SUCCESSFUL_REFPAY = "<b>💸 Баланс реферала ({referal_id}) пополнен на {amount}₽</b>"

View File

@@ -11,6 +11,12 @@ class Settings(BaseSettings):
) )
bot_token: str = Field(alias="BOT_TOKEN") 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_shop_id: str = Field(alias="PALLY_SHOP_ID")
pally_token: str = Field(alias="PALLY_TOKEN") pally_token: str = Field(alias="PALLY_TOKEN")
@@ -33,5 +39,7 @@ class Settings(BaseSettings):
general_squad: str = Field("2de0b5ea-49b9-4181-916f-ae67b9c83b80") general_squad: str = Field("2de0b5ea-49b9-4181-916f-ae67b9c83b80")
whitelist_squad: str = Field("e4435baa-64d3-4b29-a18e-81ad1c60d977") whitelist_squad: str = Field("e4435baa-64d3-4b29-a18e-81ad1c60d977")
autorenew_days_before: int = Field(1, alias="AUTORENEW_DAYS_BEFORE")
settings = Settings() # type: ignore settings = Settings() # type: ignore

View File

@@ -17,6 +17,7 @@ class BalanceTxType(StrEnum):
REFUND = "refund" REFUND = "refund"
REFERRAL_BONUS = "referral" REFERRAL_BONUS = "referral"
MANUAL = "manual" MANUAL = "manual"
AUTORENEW = "autorenew"
class BalanceTransaction(Base): class BalanceTransaction(Base):

View File

@@ -1,5 +1,6 @@
services: services:
postgres: postgres:
env_file: .env
image: postgres:16-alpine image: postgres:16-alpine
container_name: maleniabot_db container_name: maleniabot_db
environment: environment:
@@ -20,6 +21,7 @@ services:
retries: 5 retries: 5
bot: bot:
env_file: .env
build: build:
dockerfile: Dockerfile dockerfile: Dockerfile
container_name: maleniabot_bot container_name: maleniabot_bot
@@ -29,6 +31,7 @@ services:
environment: environment:
TZ: UTC TZ: UTC
BOT_TOKEN: ${BOT_TOKEN} BOT_TOKEN: ${BOT_TOKEN}
ADMINS: ${ADMINS}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db} POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-} PROXY: ${PROXY:-}
REMNAWAVE_URL: ${REMNAWAVE_URL} REMNAWAVE_URL: ${REMNAWAVE_URL}
@@ -53,6 +56,7 @@ services:
environment: environment:
TZ: UTC TZ: UTC
BOT_TOKEN: ${BOT_TOKEN} BOT_TOKEN: ${BOT_TOKEN}
ADMINS: ${ADMINS}
POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db} POSTGRES_URL: postgresql+asyncpg://${POSTGRES_USER:-malenia}:${POSTGRES_PASSWORD:-malenia_password}@postgres:5432/${POSTGRES_DB:-malenia_db}
PROXY: ${PROXY:-} PROXY: ${PROXY:-}
REMNAWAVE_URL: ${REMNAWAVE_URL} REMNAWAVE_URL: ${REMNAWAVE_URL}
@@ -66,7 +70,7 @@ services:
PALLY_TOKEN: ${PALLY_TOKEN} PALLY_TOKEN: ${PALLY_TOKEN}
PALLY_SHOP_ID: ${PALLY_SHOP_ID} PALLY_SHOP_ID: ${PALLY_SHOP_ID}
ports: ports:
- "8000:8000" - "127.0.0.1:8000:8000"
restart: unless-stopped restart: unless-stopped
volumes: volumes:

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

View File

@@ -6,17 +6,17 @@ echo
ruff check --fix ruff check --fix
black . black .
echo "[+] checking psql availability" # echo "[+] checking psql availability"
if ! pg_isready -h localhost -p 5432 ; then # if ! pg_isready -h localhost -p 5432 ; then
echo "psql is down." >&2 # echo "psql is down." >&2
exit 1 # exit 1
fi # fi
echo "[+] psql is available!" # echo "[+] psql is available!"
echo # echo
echo "[+] Running migrations..." echo "[+] Running migrations..."
echo echo
alembic upgrade head alembic upgrade head
echo "[+] Starting bot..." echo "[+] Starting bot..."
python main.py python -m bot.main

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
@@ -41,11 +43,33 @@ QUOTES: list[str] = [
"Весь мир на экране. Без лишних вопросов.", "Весь мир на экране. Без лишних вопросов.",
] ]
ADM_QUOTES: list[str] = [
"Они строят стены. Мы пишем для них двери.",
"За каждым коммитом — чьё-то право на выбор.",
"Цензура — это баг. И мы выкатили патч.",
"Обычный день в админке. Глоток свободы для них.",
"Наш труд невидим. Наше влияние безгранично.",
"Мы не пишем код. Мы держим сеть открытой.",
"Пульт управления свободой. Добро пожаловать.",
"Malenia дышит. Благодаря тебе.",
"Пока они запрещают, мы масштабируем.",
"Тихая работа в бэкенде. Громкие результаты в сети.",
"Миллионы терабайт трафика. Одна общая цель.",
"Инструменты создают люди. Свободу создаешь ты.",
"Никакой магии. Только наш код и упорство.",
"Будущее интернета компилируется прямо здесь.",
"Для кого-то — интерфейс. Для нас — линия фронта.",
]
def fetch_quote() -> str: def fetch_quote() -> str:
return choice(QUOTES) return choice(QUOTES)
def adm_fetch_quote() -> str:
return choice(ADM_QUOTES)
def format_bytes(num_bytes: float) -> str: def format_bytes(num_bytes: float) -> str:
"""Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б.""" """Конвертировать байты в читаемый формат: ГБ / МБ / КБ / Б."""
gb_capacity = 1_073_741_824 gb_capacity = 1_073_741_824
@@ -236,6 +260,12 @@ def build_main_menu_text(link: str, balance: int) -> str:
return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance) return texts.MAIN_MENU.format(quote=fetch_quote(), link=link, balance=balance)
def build_adm_main_menu_text(user_count: int, user_id: int):
return texts.ADM_MAIN_MENU.format(
quote=adm_fetch_quote(), user_count=user_count, user_id=user_id
)
def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool: def is_same_plan(rw_user: RWUserInfo, new_plan: SubscriptionPlan) -> bool:
if rw_user.hwid_device_limit != new_plan.devices: if rw_user.hwid_device_limit != new_plan.devices:
return False return False
@@ -243,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}"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
from datetime import datetime from datetime import UTC, datetime, timedelta
from sqlalchemy import select, update from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,6 +7,22 @@ from db.models.subscriptions import Subscription, SubscriptionStatus
class SubscriptionRepository: class SubscriptionRepository:
async def get_subscriptions_for_autorenewal(
self, session: AsyncSession, *, days_before_expiry: int = 1
) -> list[Subscription]:
threshold = datetime.now(UTC) + timedelta(days=days_before_expiry)
stmt = (
select(Subscription)
.where(
Subscription.autorenew == True, # noqa: E712
Subscription.end_date <= threshold,
)
.with_for_update()
)
result = await session.execute(stmt)
return list(result.scalars().all())
async def get_subscription_by_user_id( async def get_subscription_by_user_id(
self, session: AsyncSession, user_id: int self, session: AsyncSession, user_id: int
) -> Subscription | None: ) -> Subscription | None:
@@ -52,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

@@ -1,4 +1,4 @@
from datetime import datetime, timezone from datetime import UTC, datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -17,7 +17,7 @@ class BalanceTXRepository:
balance_after: int, balance_after: int,
description: str, description: str,
) -> BalanceTransaction: ) -> BalanceTransaction:
created_at = datetime.now(timezone.UTC) created_at = datetime.now(UTC)
tx = BalanceTransaction( tx = BalanceTransaction(
user_id=user_id, user_id=user_id,
amount=amount, amount=amount,

View File

@@ -2,6 +2,7 @@ import datetime
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.sql.functions import count
from db.models import User from db.models import User
from db.models.transactions import BalanceTransaction, BalanceTxType from db.models.transactions import BalanceTransaction, BalanceTxType
@@ -111,7 +112,7 @@ class UserRepository:
amount=amount, amount=amount,
tx_type=tx_type, tx_type=tx_type,
balance_before=user.balance, balance_before=user.balance,
balance_after=user.balance + amount, balance_after=user.balance - amount,
description=description, description=description,
created_at=created_at, created_at=created_at,
) )
@@ -121,3 +122,9 @@ class UserRepository:
await session.commit() await session.commit()
return user return user
async def user_count(self, session: AsyncSession) -> int:
stmt = select(count()).select_from(User)
res = await session.execute(stmt)
return int(res.scalar_one_or_none())

View File

@@ -7,6 +7,7 @@ from repositories.bills import BillsRepository
from repositories.subscriptions import SubscriptionRepository from repositories.subscriptions import SubscriptionRepository
from repositories.users import UserRepository from repositories.users import UserRepository
from services.billing_service import BillingService from services.billing_service import BillingService
from services.renewal_service import RenewalService
from services.user_service import UserService from services.user_service import UserService
@@ -19,6 +20,7 @@ class DependenciesDTO:
subscriptions_repository: SubscriptionRepository subscriptions_repository: SubscriptionRepository
pally_client: PallyClient pally_client: PallyClient
rw_sdk: RemnawaveSDK rw_sdk: RemnawaveSDK
renewal_service: RenewalService
@dataclass @dataclass

View File

@@ -5,5 +5,5 @@ from aiogram.types import Message
@dataclass @dataclass
class NewPromotionContext: class NewPromotionContext:
creator_id: int msg: Message
message: Message | None = None promotion: Message | None = None

View File

@@ -1,8 +1,9 @@
from dataclasses import dataclass from dataclasses import dataclass
from pydantic import BaseModel
@dataclass
class NewUserDTO: class NewUserDTO(BaseModel):
id: int id: int
referal: int | None = None referal: int | None = None
link: str | None = None link: str | None = None

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)}"
),
)

195
services/renewal_service.py Normal file
View File

@@ -0,0 +1,195 @@
import datetime
import logging
from aiogram import Bot
from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession
from bot.texts import AUTORENEWAL_INSUFFICIENT_FUNDS, AUTORENEWAL_SUCCESS
from config import settings
from db.models.subscriptions import Subscription
from db.models.transactions import BalanceTxType
from misc import utils
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__)
class RenewalServiceError(Exception):
"""Base exception for renewal service errors."""
class InsufficientBalanceError(RenewalServiceError):
"""Raised when user has insufficient balance for renewal."""
class RenewalService:
def __init__(
self,
subscription_repository: SubscriptionRepository,
user_repository: UserRepository,
rw_sdk: RemnawaveSDK,
bot: Bot,
) -> None:
self.subscription_repository = subscription_repository
self.user_repository = user_repository
self.rw_sdk = rw_sdk
self.bot = bot
async def _calculate_renewal_price(self, subscription: Subscription) -> int:
duration = utils.convert_int_to_duration(subscription.duration_days)
plan = SubscriptionPlan(
devices=subscription.devices,
duration=duration,
whitelists=subscription.whitelists,
)
return utils.calculate_price(plan)
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}")
squads = [InternalSquads.DEFAULT]
if subscription.whitelists:
squads.append(InternalSquads.WHITELISTS)
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)
async def _notify_user(self, user_id: int, message: str) -> None:
try:
await self.bot.send_message(user_id, message)
except Exception:
logger.warning("Failed to notify user %d about renewal", user_id, exc_info=True)
async def _process_single_renewal(
self, session: AsyncSession, subscription: Subscription
) -> bool:
user_id = subscription.user_id
price = await self._calculate_renewal_price(subscription)
user = await self.user_repository.get_user_by_id(session, user_id)
if not user:
logger.error("User %d not found for autorenewal", user_id)
return False
if user.balance < price:
logger.info(
"Insufficient balance for user %d: balance=%d, price=%d",
user_id,
user.balance,
price,
)
await self.subscription_repository.update_subscription_autorenew(
session, user_id, False
)
await self._notify_user(
user_id, AUTORENEWAL_INSUFFICIENT_FUNDS.format(price=price, balance=user.balance)
)
return False
renewed_user = await self.user_repository.decrease_user_balance(
session,
user_id,
price,
tx_type=BalanceTxType.AUTORENEW,
description=f"autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d, whitelists={subscription.whitelists}",
)
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, new_expire)
except RenewalServiceError:
logger.exception("Failed to renew subscription in RW for user %d", user_id)
await self.user_repository.increase_user_balance(
session,
user_id,
price,
tx_type=BalanceTxType.REFUND,
description=f"refund for failed autorenewal: devices={subscription.devices}, duration={subscription.duration_days}d",
)
return False
await self.subscription_repository.update_sub(
session,
user_id,
end_date=new_expire,
autorenew=True,
status=SubscriptionStatus.ACTIVE,
devices=subscription.devices,
whitelists=subscription.whitelists,
duration_days=subscription.duration_days,
plan_price_paid=price,
)
await self._notify_user(
user_id,
AUTORENEWAL_SUCCESS.format(
price=price,
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
async def process_autorenewals(self, session: AsyncSession) -> dict:
subscriptions = await self.subscription_repository.get_subscriptions_for_autorenewal(
session, days_before_expiry=settings.autorenew_days_before
)
logger.info("Found %d subscriptions for autorenewal", len(subscriptions))
results = {"success": 0, "failed": 0, "insufficient_balance": 0}
for subscription in subscriptions:
try:
success = await self._process_single_renewal(session, subscription)
if success:
results["success"] += 1
else:
user = await self.user_repository.get_user_by_id(session, subscription.user_id)
if user and user.balance < await self._calculate_renewal_price(subscription):
results["insufficient_balance"] += 1
else:
results["failed"] += 1
except Exception:
logger.exception("Error processing autorenewal for user %d", subscription.user_id)
results["failed"] += 1
logger.info(
"Autorenewal completed: success=%d, failed=%d, insufficient_balance=%d",
results["success"],
results["failed"],
results["insufficient_balance"],
)
return results

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

@@ -7,6 +7,8 @@ from remnawave import RemnawaveSDK
from sqlalchemy.ext.asyncio import AsyncSession 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 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
@@ -80,11 +82,7 @@ class UserService:
user = await self.user_repository.get_user_by_id(session, user_id) user = await self.user_repository.get_user_by_id(session, user_id)
if not rw_user: link = utils.get_subscription_link(rw_user.short_uuid) if rw_user else None
return user
link = utils.get_subscription_link(rw_user.short_uuid)
user = await self.user_repository.update_user_link(session, user_id, link) user = await self.user_repository.update_user_link(session, user_id, link)
return user return user
@@ -125,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,
@@ -171,6 +169,9 @@ class UserService:
squad_uuids=squads, squad_uuids=squads,
) )
if not rw_user:
raise Exception("RW User was NOT created, manual intervention required.")
db_subscription = await self.subscription_repository.get_subscription_by_user_id( db_subscription = await self.subscription_repository.get_subscription_by_user_id(
session, user.id session, user.id
) )
@@ -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,
@@ -224,3 +225,29 @@ class UserService:
remaining_days = max(0, (rw_user.expire_at - datetime.datetime.now(datetime.UTC)).days) remaining_days = max(0, (rw_user.expire_at - datetime.datetime.now(datetime.UTC)).days)
return math.floor(db_sub.plan_price_paid * remaining_days / db_sub.duration_days) return math.floor(db_sub.plan_price_paid * remaining_days / db_sub.duration_days)
async def launch_promotion(self, session: AsyncSession, promo_msg: Message) -> int:
users = await self.user_repository.get_users(session)
sent = 0
for user in users:
if user.id in settings.admins:
continue
try:
await promo_msg.copy_to(user.id)
sent += 1
except Exception:
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,
]
)

View File

@@ -19,18 +19,16 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
ENDPOINT = "/pally/result"
def calculate_signature( def calculate_signature(
payment_id: str, out_sum: str,
status: str, inv_id: str,
req_amount: str,
currency: str,
order_id: str,
pally_token: str, pally_token: str,
) -> str: ) -> str:
raw = f"{payment_id}{status}{req_amount}{currency}{order_id}{pally_token}" raw = f"{out_sum}:{inv_id}:{pally_token}"
md5 = hashlib.md5(raw.encode()).hexdigest() return hashlib.md5(raw.encode()).hexdigest().upper()
return hashlib.sha1(md5.encode()).hexdigest()
def main(): def main():
@@ -42,37 +40,64 @@ def main():
parser.add_argument( parser.add_argument(
"--status", "--status",
default="SUCCESS", default="SUCCESS",
choices=["SUCCESS", "FAIL", "PROCESS"], choices=["SUCCESS", "FAIL", "UNDERPAID", "OVERPAID"],
help="Статус платежа (по умолчанию SUCCESS)", help="Статус платежа (по умолчанию SUCCESS)",
) )
parser.add_argument(
"--customer-pays-fees",
action="store_true",
help="Симулировать сценарий 'клиент платит комиссию'",
)
args = parser.parse_args() args = parser.parse_args()
pally_token = os.environ["PALLY_TOKEN"] pally_token = os.environ["PALLY_TOKEN"]
# Эти поля могут быть любыми строками — вебхук их не использует содержательно, # ИСПРАВЛЕНО: InvId должен содержать ID счета из БД (это order_id при создании счета)
# только включает в подпись. Главное чтобы совпадали с тем, что пойдёт в подпись. # Именно InvId используется в webhook handler для поиска счета в БД
payment_id = "test_payment_001" inv_id = str(args.bill_id) # InvId = bill ID из нашей БД (order_id при создании)
bill_id = f"test_bill_{args.bill_id}" trs_id = f"LXZv3R{args.bill_id}" # TrsId - уникальный идентификатор счета в Pally
currency = "RUB" 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 = { payload = {
"id": payment_id, "InvId": inv_id, # Содержит ID счета из БД
"bill_id": bill_id, "OutSum": f"{gross_amount:.2f}", # Сумма, которую заплатил клиент (с комиссией)
"status": args.status, "Commission": f"{commission:.2f}",
"req_amount": str(args.amount), "TrsId": trs_id,
"currency": currency, "Status": args.status,
"order_id": order_id, "CurrencyIn": currency,
"signature": signature, "custom": custom, # None в production
"SignatureValue": signature,
} }
print(f"{args.host}/checkout/pally/callback") # Добавляем BalanceAmount только если клиент платит комиссию
if args.customer_pays_fees:
payload["BalanceAmount"] = str(args.amount) # Сумма к зачислению (без комиссии)
payload["BalanceCurrency"] = currency
print(f"{args.host}{ENDPOINT}")
print(f"Отправляю вебхук: {payload}\n") print(f"Отправляю вебхук: {payload}\n")
response = httpx.post(f"{args.host}/checkout/pally/callback", data=payload) response = httpx.post(f"{args.host}{ENDPOINT}", data=payload)
print(f"Статус ответа: {response.status_code}") print(f"Статус ответа: {response.status_code}")
print(f"Тело ответа: {response.text}") print(f"Тело ответа: {response.text}")