Compare commits

..

24 Commits

Author SHA1 Message Date
Egor 74923515cc Merge pull request #17 from Fr1ngg/YooKassa
YooKassa Интеграция
2025-08-29 11:59:37 +03:00
Egor b3f3c415df Update README.md 2025-08-29 11:56:46 +03:00
Egor 0b9de65cbc Update yookassa_service.py 2025-08-29 11:54:52 +03:00
Egor 1c26171b52 Update yookassa_webhook.py 2025-08-29 11:53:32 +03:00
Egor a6bc0afdd3 Update yookassa.py 2025-08-29 11:51:51 +03:00
Egor 966cfe6565 Update config.py 2025-08-29 11:50:12 +03:00
Egor d5b1c5805e Update requirements.txt 2025-08-29 11:48:31 +03:00
Egor a4ce5ff9ca Update .env.example 2025-08-29 11:47:46 +03:00
Egor 8e4b70578b Add files via upload 2025-08-29 11:46:33 +03:00
Egor 5367874062 Update models.py 2025-08-27 13:20:32 +03:00
Egor 321696b5b5 Update remnawave_api.py 2025-08-27 13:19:07 +03:00
Egor b642a5550c Update universal_migration.py 2025-08-27 13:16:13 +03:00
Egor 0e97632099 Update texts.py 2025-08-27 05:50:43 +03:00
Egor 4a3c6c4124 Update formatters.py 2025-08-27 05:50:18 +03:00
Egor 3bf9c143d4 Update monitoring_service.py 2025-08-27 05:49:44 +03:00
Egor 285e5e256e Update balance.py 2025-08-26 18:48:03 +03:00
Egor 5b06b89d7a bedolaga screens 2025-08-26 17:01:38 +03:00
Egor 7e749fa096 Merge pull request #16 from Fr1ngg/FixDbCollisions
Fix db collisions
2025-08-26 03:27:43 +03:00
Egor 13ac3f0e74 Create subscription_utils.py 2025-08-26 03:22:47 +03:00
Egor 32c551999c Update models.py 2025-08-26 03:21:26 +03:00
Egor ff2c09d3be Update subscription.py 2025-08-26 03:20:47 +03:00
Egor 4c97ac4497 Update subscription.py 2025-08-26 03:20:05 +03:00
Egor 86516186d9 Update main.py 2025-08-26 03:18:31 +03:00
Egor e0581bdc3c Create universal_migration.py 2025-08-26 03:14:26 +03:00
47 changed files with 1909 additions and 59 deletions
+55
View File
@@ -83,6 +83,61 @@ TRIBUTE_DONATE_LINK=https://t.me/tribute/app?startapp=XXXX
TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_PORT=8081
# === НОВЫЕ НАСТРОЙКИ YOOKASSA ===
# Включение/выключение YooKassa
YOOKASSA_ENABLED=false
# Основные настройки YooKassa (получить в личном кабинете)
YOOKASSA_SHOP_ID=your_shop_id_here
YOOKASSA_SECRET_KEY=your_secret_key_here
# URL для возврата после оплаты (необязательно, по умолчанию t.me/your_bot)
YOOKASSA_RETURN_URL=https://yourdomain.com/payment-success
# Email по умолчанию для чеков (если пользователь не указал свой)
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
# full_payment - полная оплата
# partial_payment - частичная оплата
# advance - аванс
# full_prepayment - полная предоплата
# partial_prepayment - частичная предоплата
# credit - передача в кредит
# credit_payment - оплата кредита
YOOKASSA_PAYMENT_SUBJECT=service
# Предметы расчета:
# commodity - товар
# excise - подакцизный товар
# job - работа
# service - услуга
# gambling_bet - ставка в азартной игре
# gambling_prize - выигрыш в азартной игре
# lottery - лотерейный билет
# lottery_prize - выигрыш в лотерее
# intellectual_activity - результат интеллектуальной деятельности
# payment - платеж
# agent_commission - агентское вознаграждение
# composite - составной предмет расчета
# another - другое
# Webhook для получения уведомлений от YooKassa
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=ваш_секретный_ключ_для_webhook
WEBHOOK_URL=https://example.com
WEBHOOK_PATH=/webhook
+55
View File
@@ -132,6 +132,61 @@ TRIBUTE_WEBHOOK_PATH=/tribute-webhook
TRIBUTE_WEBHOOK_PORT=8081
TRIBUTE_WEBHOOK_SECRET=your_webhook_secret
# 💳 YOOKASSA
# Включение/выключение YooKassa
YOOKASSA_ENABLED=false
# Основные настройки YooKassa (получить в личном кабинете)
YOOKASSA_SHOP_ID=your_shop_id_here
YOOKASSA_SECRET_KEY=your_secret_key_here
# URL для возврата после оплаты (необязательно, по умолчанию t.me/your_bot)
YOOKASSA_RETURN_URL=https://yourdomain.com/payment-success
# Email по умолчанию для чеков (если пользователь не указал свой)
YOOKASSA_DEFAULT_RECEIPT_EMAIL=receipts@yourdomain.com
# Настройки чеков для налоговой
YOOKASSA_VAT_CODE=1
# Коды НДС:
# 1 - НДС не облагается
# 2 - НДС 0%
# 3 - НДС 10%
# 4 - НДС 20%
# 5 - НДС 10/110
# 6 - НДС 20/120
YOOKASSA_PAYMENT_MODE=full_payment
# Способы расчета:
# full_payment - полная оплата
# partial_payment - частичная оплата
# advance - аванс
# full_prepayment - полная предоплата
# partial_prepayment - частичная предоплата
# credit - передача в кредит
# credit_payment - оплата кредита
YOOKASSA_PAYMENT_SUBJECT=service
# Предметы расчета:
# commodity - товар
# excise - подакцизный товар
# job - работа
# service - услуга
# gambling_bet - ставка в азартной игре
# gambling_prize - выигрыш в азартной игре
# lottery - лотерейный билет
# lottery_prize - выигрыш в лотерее
# intellectual_activity - результат интеллектуальной деятельности
# payment - платеж
# agent_commission - агентское вознаграждение
# composite - составной предмет расчета
# another - другое
# Webhook для получения уведомлений от YooKassa
YOOKASSA_WEBHOOK_PATH=/yookassa-webhook
YOOKASSA_WEBHOOK_PORT=8082
YOOKASSA_WEBHOOK_SECRET=ваш_секретный_ключ_для_webhook
# 🚀 Режим работы кнопки "Подключиться"
# guide - открывает гайд подключения c настройками и парамтерами из app-config.json (режим 1)
# miniapp_subscription - открывает ссылку подписки в мини-приложении (режим 2)
+24
View File
@@ -73,6 +73,18 @@ class Settings(BaseSettings):
TRIBUTE_WEBHOOK_PATH: str = "/tribute-webhook"
TRIBUTE_WEBHOOK_PORT: int = 8081
YOOKASSA_ENABLED: bool = False
YOOKASSA_SHOP_ID: Optional[str] = None
YOOKASSA_SECRET_KEY: Optional[str] = None
YOOKASSA_RETURN_URL: Optional[str] = None
YOOKASSA_DEFAULT_RECEIPT_EMAIL: Optional[str] = None
YOOKASSA_VAT_CODE: int = 1
YOOKASSA_PAYMENT_MODE: str = "full_payment"
YOOKASSA_PAYMENT_SUBJECT: str = "service"
YOOKASSA_WEBHOOK_PATH: str = "/yookassa-webhook"
YOOKASSA_WEBHOOK_PORT: int = 8082
YOOKASSA_WEBHOOK_SECRET: Optional[str] = None
CONNECT_BUTTON_MODE: str = "guide"
MINIAPP_CUSTOM_URL: str = ""
@@ -174,6 +186,18 @@ class Settings(BaseSettings):
def get_fixed_traffic_limit(self) -> int:
return self.FIXED_TRAFFIC_LIMIT_GB
def is_yookassa_enabled(self) -> bool:
return (self.YOOKASSA_ENABLED and
self.YOOKASSA_SHOP_ID is not None and
self.YOOKASSA_SECRET_KEY is not None)
def get_yookassa_return_url(self) -> str:
if self.YOOKASSA_RETURN_URL:
return self.YOOKASSA_RETURN_URL
elif self.WEBHOOK_URL:
return f"{self.WEBHOOK_URL}/payment-success"
return "https://t.me/"
model_config = {
"env_file": ".env",
"env_file_encoding": "utf-8"
+18 -3
View File
@@ -19,6 +19,8 @@ async def get_subscription_by_user_id(db: AsyncSession, user_id: int) -> Optiona
select(Subscription)
.options(selectinload(Subscription.user))
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
.limit(1)
)
subscription = result.scalar_one_or_none()
@@ -98,17 +100,30 @@ async def extend_subscription(
subscription: Subscription,
days: int
) -> Subscription:
current_time = datetime.utcnow()
subscription.extend_subscription(days)
logger.info(f"🔄 Продление подписки {subscription.id} на {days} дней")
logger.info(f"📊 Текущие параметры: статус={subscription.status}, окончание={subscription.end_date}")
if subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
logger.info(f"📅 Подписка активна, добавляем {days} дней к текущей дате окончания")
else:
subscription.end_date = current_time + timedelta(days=days)
logger.info(f"📅 Подписка истекла, устанавливаем новую дату окончания")
if subscription.status == SubscriptionStatus.EXPIRED.value:
subscription.status = SubscriptionStatus.ACTIVE.value
logger.info(f"🔄 Статус изменён с EXPIRED на ACTIVE")
subscription.updated_at = current_time
subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(subscription)
logger.info(f" Подписка пользователя {subscription.user_id} продлена на {days} дней")
logger.info(f" Подписка продлена до: {subscription.end_date}")
logger.info(f"📊 Новые параметры: статус={subscription.status}, окончание={subscription.end_date}")
return subscription
+276
View File
@@ -0,0 +1,276 @@
import logging
from typing import Optional, List
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, update, and_
from sqlalchemy.orm import selectinload
from app.database.models import YooKassaPayment, User, Transaction
logger = logging.getLogger(__name__)
async def create_yookassa_payment(
db: AsyncSession,
user_id: int,
yookassa_payment_id: str,
amount_kopeks: int,
currency: str,
description: str,
status: str,
confirmation_url: Optional[str] = None,
metadata_json: Optional[dict] = None,
payment_method_type: Optional[str] = None,
yookassa_created_at: Optional[datetime] = None,
test_mode: bool = False
) -> YooKassaPayment:
payment = YooKassaPayment(
user_id=user_id,
yookassa_payment_id=yookassa_payment_id,
amount_kopeks=amount_kopeks,
currency=currency,
description=description,
status=status,
confirmation_url=confirmation_url,
metadata_json=metadata_json,
payment_method_type=payment_method_type,
yookassa_created_at=yookassa_created_at,
test_mode=test_mode
)
db.add(payment)
await db.commit()
await db.refresh(payment)
logger.info(f"Создан платеж YooKassa: {yookassa_payment_id} на {amount_kopeks/100}₽ для пользователя {user_id}")
return payment
async def get_yookassa_payment_by_id(
db: AsyncSession,
yookassa_payment_id: str
) -> Optional[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
return result.scalar_one_or_none()
async def get_yookassa_payment_by_local_id(
db: AsyncSession,
local_id: int
) -> Optional[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.id == local_id)
)
return result.scalar_one_or_none()
async def update_yookassa_payment_status(
db: AsyncSession,
yookassa_payment_id: str,
status: str,
is_paid: bool = False,
is_captured: bool = False,
captured_at: Optional[datetime] = None,
payment_method_type: Optional[str] = None
) -> Optional[YooKassaPayment]:
update_data = {
"status": status,
"is_paid": is_paid,
"is_captured": is_captured,
"updated_at": datetime.utcnow()
}
if captured_at:
update_data["captured_at"] = captured_at
if payment_method_type:
update_data["payment_method_type"] = payment_method_type
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(**update_data)
)
await db.commit()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Обновлен статус платежа YooKassa {yookassa_payment_id}: {status}, paid={is_paid}")
return payment
async def link_yookassa_payment_to_transaction(
db: AsyncSession,
yookassa_payment_id: str,
transaction_id: int
) -> Optional[YooKassaPayment]:
await db.execute(
update(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
.values(transaction_id=transaction_id, updated_at=datetime.utcnow())
)
await db.commit()
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user), selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
logger.info(f"Платеж YooKassa {yookassa_payment_id} связан с транзакцией {transaction_id}")
return payment
async def get_user_yookassa_payments(
db: AsyncSession,
user_id: int,
limit: int = 50,
offset: int = 0
) -> List[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.transaction))
.where(YooKassaPayment.user_id == user_id)
.order_by(YooKassaPayment.created_at.desc())
.limit(limit)
.offset(offset)
)
return result.scalars().all()
async def get_pending_yookassa_payments(
db: AsyncSession,
user_id: Optional[int] = None,
limit: int = 100
) -> List[YooKassaPayment]:
query = select(YooKassaPayment).options(selectinload(YooKassaPayment.user))
conditions = [YooKassaPayment.status.in_(["pending", "waiting_for_capture"])]
if user_id:
conditions.append(YooKassaPayment.user_id == user_id)
result = await db.execute(
query.where(and_(*conditions))
.order_by(YooKassaPayment.created_at.desc())
.limit(limit)
)
return result.scalars().all()
async def get_succeeded_yookassa_payments_without_transaction(
db: AsyncSession,
limit: int = 50
) -> List[YooKassaPayment]:
result = await db.execute(
select(YooKassaPayment)
.options(selectinload(YooKassaPayment.user))
.where(
and_(
YooKassaPayment.status == "succeeded",
YooKassaPayment.is_paid == True,
YooKassaPayment.transaction_id == None
)
)
.order_by(YooKassaPayment.captured_at.desc())
.limit(limit)
)
return result.scalars().all()
async def delete_yookassa_payment(
db: AsyncSession,
yookassa_payment_id: str
) -> bool:
result = await db.execute(
select(YooKassaPayment)
.where(YooKassaPayment.yookassa_payment_id == yookassa_payment_id)
)
payment = result.scalar_one_or_none()
if payment:
await db.delete(payment)
await db.commit()
logger.info(f"Удален платеж YooKassa: {yookassa_payment_id}")
return True
return False
async def get_yookassa_payments_stats(
db: AsyncSession,
user_id: Optional[int] = None
) -> dict:
from sqlalchemy import func, case
query = select(
func.count(YooKassaPayment.id).label('total_payments'),
func.sum(YooKassaPayment.amount_kopeks).label('total_amount_kopeks'),
func.sum(
case(
(YooKassaPayment.status == 'succeeded', YooKassaPayment.amount_kopeks),
else_=0
)
).label('succeeded_amount_kopeks'),
func.count(
case(
(YooKassaPayment.status == 'succeeded', 1),
else_=None
)
).label('succeeded_count'),
func.count(
case(
(YooKassaPayment.status == 'pending', 1),
else_=None
)
).label('pending_count'),
func.count(
case(
(YooKassaPayment.status.in_(['canceled', 'failed']), 1),
else_=None
)
).label('failed_count')
).select_from(YooKassaPayment)
if user_id:
query = query.where(YooKassaPayment.user_id == user_id)
result = await db.execute(query)
stats = result.first()
return {
'total_payments': stats.total_payments or 0,
'total_amount_kopeks': stats.total_amount_kopeks or 0,
'total_amount_rubles': (stats.total_amount_kopeks or 0) / 100,
'succeeded_amount_kopeks': stats.succeeded_amount_kopeks or 0,
'succeeded_amount_rubles': (stats.succeeded_amount_kopeks or 0) / 100,
'succeeded_count': stats.succeeded_count or 0,
'pending_count': stats.pending_count or 0,
'failed_count': stats.failed_count or 0,
'success_rate': (stats.succeeded_count / stats.total_payments * 100) if stats.total_payments > 0 else 0
}
+55 -10
View File
@@ -44,8 +44,57 @@ class PromoCodeType(Enum):
class PaymentMethod(Enum):
TELEGRAM_STARS = "telegram_stars"
TRIBUTE = "tribute"
YOOKASSA = "yookassa"
MANUAL = "manual"
class YooKassaPayment(Base):
__tablename__ = "yookassa_payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
yookassa_payment_id = Column(String(255), unique=True, nullable=False, index=True)
amount_kopeks = Column(Integer, nullable=False)
currency = Column(String(3), default="RUB", nullable=False)
description = Column(Text, nullable=True)
status = Column(String(50), nullable=False)
is_paid = Column(Boolean, default=False)
is_captured = Column(Boolean, default=False)
confirmation_url = Column(Text, nullable=True)
metadata_json = Column(JSON, nullable=True)
transaction_id = Column(Integer, ForeignKey("transactions.id"), nullable=True)
payment_method_type = Column(String(50), nullable=True)
refundable = Column(Boolean, default=False)
test_mode = Column(Boolean, default=False)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
yookassa_created_at = Column(DateTime, nullable=True)
captured_at = Column(DateTime, nullable=True)
user = relationship("User", backref="yookassa_payments")
transaction = relationship("Transaction", backref="yookassa_payment")
@property
def amount_rubles(self) -> float:
return self.amount_kopeks / 100
@property
def is_pending(self) -> bool:
return self.status == "pending"
@property
def is_succeeded(self) -> bool:
return self.status == "succeeded" and self.is_paid
@property
def is_failed(self) -> bool:
return self.status in ["canceled", "failed"]
@property
def can_be_captured(self) -> bool:
return self.status == "waiting_for_capture"
def __repr__(self):
return f"<YooKassaPayment(id={self.id}, yookassa_id={self.yookassa_payment_id}, amount={self.amount_rubles}₽, status={self.status})>"
class User(Base):
__tablename__ = "users"
@@ -55,31 +104,27 @@ class User(Base):
username = Column(String(255), nullable=True)
first_name = Column(String(255), nullable=True)
last_name = Column(String(255), nullable=True)
status = Column(String(20), default=UserStatus.ACTIVE.value)
language = Column(String(5), default="ru")
balance_kopeks = Column(Integer, default=0)
used_promocodes = Column(Integer, default=0)
has_had_paid_subscription = Column(Boolean, default=False, nullable=False)
referred_by_id = Column(Integer, ForeignKey("users.id"), nullable=True)
referral_code = Column(String(20), unique=True, nullable=True)
created_at = Column(DateTime, default=func.now())
updated_at = Column(DateTime, default=func.now(), onupdate=func.now())
last_activity = Column(DateTime, default=func.now())
remnawave_uuid = Column(String(255), nullable=True, unique=True)
broadcasts = relationship("BroadcastHistory", back_populates="admin")
referrals = relationship("User", backref="referrer", remote_side=[id], foreign_keys="User.referred_by_id")
subscription = relationship("Subscription", back_populates="user", uselist=False)
transactions = relationship("Transaction", back_populates="user")
referral_earnings = relationship("ReferralEarning", foreign_keys="ReferralEarning.user_id", back_populates="user")
lifetime_used_traffic_bytes = Column(BigInteger, default=0)
last_remnawave_sync = Column(DateTime, nullable=True)
trojan_password = Column(String(255), nullable=True)
vless_uuid = Column(String(255), nullable=True)
ss_password = Column(String(255), nullable=True)
@property
def balance_rubles(self) -> float:
@@ -104,7 +149,7 @@ class Subscription(Base):
__tablename__ = "subscriptions"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, unique=True)
status = Column(String(20), default=SubscriptionStatus.TRIAL.value)
is_trial = Column(Boolean, default=True)
+375
View File
@@ -0,0 +1,375 @@
import logging
from sqlalchemy import text, inspect
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.database import engine
logger = logging.getLogger(__name__)
async def get_database_type():
return engine.dialect.name
async def check_table_exists(table_name: str) -> bool:
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
result = await conn.execute(text(f"""
SELECT name FROM sqlite_master
WHERE type='table' AND name='{table_name}'
"""))
return result.fetchone() is not None
elif db_type == 'postgresql':
result = await conn.execute(text("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = :table_name
"""), {"table_name": table_name})
return result.fetchone() is not None
elif db_type == 'mysql':
result = await conn.execute(text("""
SELECT table_name FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = :table_name
"""), {"table_name": table_name})
return result.fetchone() is not None
return False
except Exception as e:
logger.error(f"Ошибка проверки существования таблицы {table_name}: {e}")
return False
async def check_column_exists(table_name: str, column_name: str) -> bool:
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
result = await conn.execute(text(f"PRAGMA table_info({table_name})"))
columns = result.fetchall()
return any(col[1] == column_name for col in columns)
elif db_type == 'postgresql':
result = await conn.execute(text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :table_name
AND column_name = :column_name
"""), {"table_name": table_name, "column_name": column_name})
return result.fetchone() is not None
elif db_type == 'mysql':
result = await conn.execute(text("""
SELECT COLUMN_NAME
FROM information_schema.COLUMNS
WHERE TABLE_NAME = :table_name
AND COLUMN_NAME = :column_name
"""), {"table_name": table_name, "column_name": column_name})
return result.fetchone() is not None
return False
except Exception as e:
logger.error(f"Ошибка проверки существования колонки {column_name}: {e}")
return False
async def create_yookassa_payments_table():
table_exists = await check_table_exists('yookassa_payments')
if table_exists:
logger.info("Таблица yookassa_payments уже существует")
return True
try:
async with engine.begin() as conn:
db_type = await get_database_type()
if db_type == 'sqlite':
create_sql = """
CREATE TABLE yookassa_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
description TEXT NULL,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT 0,
is_captured BOOLEAN DEFAULT 0,
confirmation_url TEXT NULL,
metadata_json TEXT NULL,
transaction_id INTEGER NULL,
payment_method_type VARCHAR(50) NULL,
refundable BOOLEAN DEFAULT 0,
test_mode BOOLEAN DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
yookassa_created_at DATETIME NULL,
captured_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
"""
elif db_type == 'postgresql':
create_sql = """
CREATE TABLE yookassa_payments (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
amount_kopeks INTEGER NOT NULL,
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
description TEXT NULL,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT FALSE,
is_captured BOOLEAN DEFAULT FALSE,
confirmation_url TEXT NULL,
metadata_json JSONB NULL,
transaction_id INTEGER NULL,
payment_method_type VARCHAR(50) NULL,
refundable BOOLEAN DEFAULT FALSE,
test_mode BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
yookassa_created_at TIMESTAMP NULL,
captured_at TIMESTAMP NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
"""
elif db_type == 'mysql':
create_sql = """
CREATE TABLE yookassa_payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
yookassa_payment_id VARCHAR(255) UNIQUE NOT NULL,
amount_kopeks INT NOT NULL,
currency VARCHAR(3) DEFAULT 'RUB' NOT NULL,
description TEXT NULL,
status VARCHAR(50) NOT NULL,
is_paid BOOLEAN DEFAULT FALSE,
is_captured BOOLEAN DEFAULT FALSE,
confirmation_url TEXT NULL,
metadata_json JSON NULL,
transaction_id INT NULL,
payment_method_type VARCHAR(50) NULL,
refundable BOOLEAN DEFAULT FALSE,
test_mode BOOLEAN DEFAULT FALSE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
yookassa_created_at DATETIME NULL,
captured_at DATETIME NULL,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (transaction_id) REFERENCES transactions(id)
);
CREATE INDEX idx_yookassa_payments_user_id ON yookassa_payments(user_id);
CREATE INDEX idx_yookassa_payments_yookassa_id ON yookassa_payments(yookassa_payment_id);
CREATE INDEX idx_yookassa_payments_status ON yookassa_payments(status);
"""
else:
logger.error(f"Неподдерживаемый тип БД для создания таблицы: {db_type}")
return False
await conn.execute(text(create_sql))
logger.info("Таблица yookassa_payments успешно создана")
return True
except Exception as e:
logger.error(f"Ошибка создания таблицы yookassa_payments: {e}")
return False
async def add_remnawave_v2_columns():
columns_to_add = {
'lifetime_used_traffic_bytes': 'BIGINT DEFAULT 0',
'last_remnawave_sync': 'TIMESTAMP NULL',
'trojan_password': 'VARCHAR(255) NULL',
'vless_uuid': 'VARCHAR(255) NULL',
'ss_password': 'VARCHAR(255) NULL'
}
logger.info("=== ПРОВЕРКА КОЛОНОК REMNAWAVE V2.1.5 ===")
try:
async with engine.begin() as conn:
db_type = await get_database_type()
columns_added = 0
for column_name, column_def in columns_to_add.items():
exists = await check_column_exists('users', column_name)
if not exists:
logger.info(f"Добавление колонки {column_name} в таблицу users")
if db_type == 'sqlite':
if column_def.startswith('BIGINT'):
column_def = column_def.replace('BIGINT', 'INTEGER')
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
elif db_type == 'mysql':
column_def = column_def.replace('TIMESTAMP', 'DATETIME')
try:
await conn.execute(text(f"ALTER TABLE users ADD COLUMN {column_name} {column_def}"))
columns_added += 1
logger.info(f"Колонка {column_name} успешно добавлена")
except Exception as e:
logger.error(f"Ошибка добавления колонки {column_name}: {e}")
continue
else:
logger.debug(f"Колонка {column_name} уже существует")
if columns_added > 0:
logger.info(f"Добавлено {columns_added} новых колонок для RemnaWave v2.1.5")
else:
logger.info("Все колонки RemnaWave v2.1.5 уже существуют")
return columns_added
except Exception as e:
logger.error(f"Ошибка при добавлении колонок RemnaWave v2.1.5: {e}")
return 0
async def fix_subscription_duplicates_universal():
async with engine.begin() as conn:
db_type = await get_database_type()
logger.info(f"Обнаружен тип базы данных: {db_type}")
try:
result = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
"""))
duplicates = result.fetchall()
if not duplicates:
logger.info("Дублирующихся подписок не найдено")
return 0
logger.info(f"Найдено {len(duplicates)} пользователей с дублирующимися подписками")
total_deleted = 0
for user_id_row, count in duplicates:
user_id = user_id_row
if db_type == 'sqlite':
delete_result = await conn.execute(text("""
DELETE FROM subscriptions
WHERE user_id = :user_id AND id NOT IN (
SELECT MAX(id)
FROM subscriptions
WHERE user_id = :user_id
)
"""), {"user_id": user_id})
elif db_type in ['postgresql', 'mysql']:
delete_result = await conn.execute(text("""
DELETE FROM subscriptions
WHERE user_id = :user_id AND id NOT IN (
SELECT max_id FROM (
SELECT MAX(id) as max_id
FROM subscriptions
WHERE user_id = :user_id
) as subquery
)
"""), {"user_id": user_id})
else:
subs_result = await conn.execute(text("""
SELECT id FROM subscriptions
WHERE user_id = :user_id
ORDER BY created_at DESC, id DESC
"""), {"user_id": user_id})
sub_ids = [row[0] for row in subs_result.fetchall()]
if len(sub_ids) > 1:
ids_to_delete = sub_ids[1:]
for sub_id in ids_to_delete:
await conn.execute(text("""
DELETE FROM subscriptions WHERE id = :id
"""), {"id": sub_id})
delete_result = type('Result', (), {'rowcount': len(ids_to_delete)})()
else:
delete_result = type('Result', (), {'rowcount': 0})()
deleted_count = delete_result.rowcount
total_deleted += deleted_count
logger.info(f"Удалено {deleted_count} дублирующихся подписок для пользователя {user_id}")
logger.info(f"Всего удалено дублирующихся подписок: {total_deleted}")
return total_deleted
except Exception as e:
logger.error(f"Ошибка при очистке дублирующихся подписок: {e}")
raise
async def run_universal_migration():
logger.info("=== НАЧАЛО УНИВЕРСАЛЬНОЙ МИГРАЦИИ ===")
try:
db_type = await get_database_type()
logger.info(f"Тип базы данных: {db_type}")
await add_remnawave_v2_columns()
logger.info("=== СОЗДАНИЕ ТАБЛИЦЫ YOOKASSA ===")
yookassa_created = await create_yookassa_payments_table()
if yookassa_created:
logger.info("✅ Таблица YooKassa payments готова")
else:
logger.warning("⚠️ Проблемы с таблицей YooKassa payments")
async with engine.begin() as conn:
total_subs = await conn.execute(text("SELECT COUNT(*) FROM subscriptions"))
unique_users = await conn.execute(text("SELECT COUNT(DISTINCT user_id) FROM subscriptions"))
total_count = total_subs.fetchone()[0]
unique_count = unique_users.fetchone()[0]
logger.info(f"Всего подписок: {total_count}")
logger.info(f"Уникальных пользователей: {unique_count}")
if total_count == unique_count:
logger.info("База данных уже в корректном состоянии")
return True
deleted_count = await fix_subscription_duplicates_universal()
async with engine.begin() as conn:
final_check = await conn.execute(text("""
SELECT user_id, COUNT(*) as count
FROM subscriptions
GROUP BY user_id
HAVING COUNT(*) > 1
"""))
remaining_duplicates = final_check.fetchall()
if remaining_duplicates:
logger.warning(f"Остались дубликаты у {len(remaining_duplicates)} пользователей")
return False
else:
logger.info("=== МИГРАЦИЯ ЗАВЕРШЕНА УСПЕШНО ===")
return True
except Exception as e:
logger.error(f"=== ОШИБКА ВЫПОЛНЕНИЯ МИГРАЦИИ: {e} ===")
return False
+27 -2
View File
@@ -33,7 +33,7 @@ class RemnaWaveUser:
username: str
status: UserStatus
used_traffic_bytes: int
lifetime_used_traffic_bytes: int
lifetime_used_traffic_bytes: int # Новое поле
traffic_limit_bytes: int
traffic_limit_strategy: TrafficLimitStrategy
expire_at: datetime
@@ -46,6 +46,16 @@ class RemnaWaveUser:
active_internal_squads: List[Dict[str, str]]
created_at: datetime
updated_at: datetime
sub_last_user_agent: Optional[str] = None
sub_last_opened_at: Optional[datetime] = None
online_at: Optional[datetime] = None
sub_revoked_at: Optional[datetime] = None
last_traffic_reset_at: Optional[datetime] = None
trojan_password: Optional[str] = None
vless_uuid: Optional[str] = None
ss_password: Optional[str] = None
first_connected_at: Optional[datetime] = None
last_triggered_threshold: int = 0
@dataclass
@@ -498,8 +508,23 @@ class RemnaWaveAPI:
subscription_url=user_data['subscriptionUrl'],
active_internal_squads=user_data['activeInternalSquads'],
created_at=datetime.fromisoformat(user_data['createdAt'].replace('Z', '+00:00')),
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00'))
updated_at=datetime.fromisoformat(user_data['updatedAt'].replace('Z', '+00:00')),
sub_last_user_agent=user_data.get('subLastUserAgent'),
sub_last_opened_at=self._parse_optional_datetime(user_data.get('subLastOpenedAt')),
online_at=self._parse_optional_datetime(user_data.get('onlineAt')),
sub_revoked_at=self._parse_optional_datetime(user_data.get('subRevokedAt')),
last_traffic_reset_at=self._parse_optional_datetime(user_data.get('lastTrafficResetAt')),
trojan_password=user_data.get('trojanPassword'),
vless_uuid=user_data.get('vlessUuid'),
ss_password=user_data.get('ssPassword'),
first_connected_at=self._parse_optional_datetime(user_data.get('firstConnectedAt')),
last_triggered_threshold=user_data.get('lastTriggeredThreshold', 0)
)
def _parse_optional_datetime(self, date_str: Optional[str]) -> Optional[datetime]:
if date_str:
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return None
def _parse_internal_squad(self, squad_data: Dict) -> RemnaWaveInternalSquad:
return RemnaWaveInternalSquad(
+152
View File
@@ -0,0 +1,152 @@
import logging
import json
import hashlib
import hmac
from typing import Optional, Dict, Any
from aiohttp import web
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.payment_service import PaymentService
from app.database.database import get_db
logger = logging.getLogger(__name__)
class YooKassaWebhookHandler:
@staticmethod
def verify_webhook_signature(body: str, signature: str, secret: str) -> bool:
expected_signature = hmac.new(
secret.encode('utf-8'),
body.encode('utf-8'),
hashlib.sha256
).hexdigest()
return hmac.compare_digest(signature, expected_signature)
def __init__(self, payment_service: PaymentService):
self.payment_service = payment_service
async def handle_webhook(self, request: web.Request) -> web.Response:
try:
body = await request.text()
if not body:
logger.warning("Получен пустой webhook от YooKassa")
return web.Response(status=400, text="Empty body")
if hasattr(settings, 'YOOKASSA_WEBHOOK_SECRET') and settings.YOOKASSA_WEBHOOK_SECRET:
signature = request.headers.get('X-YooKassa-Signature')
if not signature:
logger.warning("Webhook без подписи")
return web.Response(status=400, text="Missing signature")
if not YooKassaWebhookHandler.verify_webhook_signature(body, signature, settings.YOOKASSA_WEBHOOK_SECRET):
logger.error("Неверная подпись webhook")
return web.Response(status=400, text="Invalid signature")
try:
webhook_data = json.loads(body)
except json.JSONDecodeError as e:
logger.error(f"Ошибка парсинга JSON webhook YooKassa: {e}")
return web.Response(status=400, text="Invalid JSON")
logger.info(f"Получен webhook YooKassa: {webhook_data.get('event', 'unknown_event')}")
logger.debug(f"Полные данные webhook: {webhook_data}")
event_type = webhook_data.get("event")
if not event_type:
logger.warning("Webhook YooKassa без типа события")
return web.Response(status=400, text="No event type")
if event_type not in ["payment.succeeded", "payment.waiting_for_capture"]:
logger.info(f"Игнорируем событие YooKassa: {event_type}")
return web.Response(status=200, text="OK")
async with get_db() as db:
success = await self.payment_service.process_yookassa_webhook(db, webhook_data)
if success:
logger.info(f"Успешно обработан webhook YooKassa: {event_type}")
return web.Response(status=200, text="OK")
else:
logger.error(f"Ошибка обработки webhook YooKassa: {event_type}")
return web.Response(status=500, text="Processing error")
except Exception as e:
logger.error(f"Критическая ошибка обработки webhook YooKassa: {e}", exc_info=True)
return web.Response(status=500, text="Internal server error")
def setup_routes(self, app: web.Application) -> None:
webhook_path = settings.YOOKASSA_WEBHOOK_PATH
app.router.add_post(webhook_path, self.handle_webhook)
logger.info(f"Настроен webhook YooKassa на пути: {webhook_path}")
def create_yookassa_webhook_app(payment_service: PaymentService) -> web.Application:
app = web.Application()
async def logging_middleware(request, handler):
start_time = request.loop.time()
try:
response = await handler(request)
process_time = request.loop.time() - start_time
logger.info(f"YooKassa webhook {request.method} {request.path_qs} "
f"-> {response.status} ({process_time:.3f}s)")
return response
except Exception as e:
process_time = request.loop.time() - start_time
logger.error(f"YooKassa webhook {request.method} {request.path_qs} "
f"-> ERROR ({process_time:.3f}s): {e}")
raise
app.middlewares.append(logging_middleware)
webhook_handler = YooKassaWebhookHandler(payment_service)
webhook_handler.setup_routes(app)
async def health_check(request):
return web.json_response({"status": "ok", "service": "yookassa_webhook"})
app.router.add_get("/health", health_check)
return app
async def start_yookassa_webhook_server(payment_service: PaymentService) -> None:
if not settings.is_yookassa_enabled():
logger.info("YooKassa отключен, webhook сервер не запускается")
return
try:
from aiohttp import web
app = create_yookassa_webhook_app(payment_service)
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(
runner,
host='0.0.0.0',
port=settings.YOOKASSA_WEBHOOK_PORT
)
await site.start()
logger.info(f"YooKassa webhook сервер запущен на порту {settings.YOOKASSA_WEBHOOK_PORT}")
logger.info(f"Webhook URL: http://localhost:{settings.YOOKASSA_WEBHOOK_PORT}{settings.YOOKASSA_WEBHOOK_PATH}")
except Exception as e:
logger.error(f"Ошибка запуска YooKassa webhook сервера: {e}", exc_info=True)
raise
+210 -8
View File
@@ -55,15 +55,46 @@ async def show_balance_history(
offset = (page - 1) * TRANSACTIONS_PER_PAGE
transactions = await get_user_transactions(
raw_transactions = await get_user_transactions(
db, db_user.id,
limit=TRANSACTIONS_PER_PAGE,
limit=TRANSACTIONS_PER_PAGE * 3,
offset=offset
)
total_count = await get_user_transactions_count(db, db_user.id)
seen_transactions = set()
unique_transactions = []
if not transactions:
for transaction in raw_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_transactions:
seen_transactions.add(transaction_key)
unique_transactions.append(transaction)
if len(unique_transactions) >= TRANSACTIONS_PER_PAGE:
break
all_transactions = await get_user_transactions(db, db_user.id, limit=1000)
seen_all = set()
total_unique = 0
for transaction in all_transactions:
rounded_time = transaction.created_at.replace(second=0, microsecond=0)
transaction_key = (
transaction.amount_kopeks,
transaction.description,
rounded_time
)
if transaction_key not in seen_all:
seen_all.add(transaction_key)
total_unique += 1
if not unique_transactions:
await callback.message.edit_text(
"📊 История операций пуста",
reply_markup=get_back_keyboard(db_user.language)
@@ -73,7 +104,7 @@ async def show_balance_history(
text = "📊 <b>История операций</b>\n\n"
for transaction in transactions:
for transaction in unique_transactions:
emoji = "💰" if transaction.type == TransactionType.DEPOSIT.value else "💸"
amount_text = f"+{texts.format_price(transaction.amount_kopeks)}" if transaction.type == TransactionType.DEPOSIT.value else f"-{texts.format_price(transaction.amount_kopeks)}"
@@ -82,7 +113,7 @@ async def show_balance_history(
text += f"📅 {transaction.created_at.strftime('%d.%m.%Y %H:%M')}\n\n"
keyboard = []
total_pages = (total_count + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
total_pages = (total_unique + TRANSACTIONS_PER_PAGE - 1) // TRANSACTIONS_PER_PAGE
if total_pages > 1:
pagination_row = get_pagination_keyboard(
@@ -126,7 +157,7 @@ async def show_payment_methods(
Выберите удобный для вас способ оплаты:
<b>Telegram Stars</b> - быстро и удобно
💎 <b>Банковская карта</b> - через Tribute
💳 <b>Банковская карта</b> - через YooKassa/Tribute
🛠 <b>Через поддержку</b> - другие способы
Выберите способ пополнения:
@@ -162,6 +193,30 @@ async def start_stars_payment(
await callback.answer()
@error_handler
async def start_yookassa_payment(
callback: types.CallbackQuery,
db_user: User,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await callback.answer("❌ Оплата картой через YooKassa временно недоступна", show_alert=True)
return
await callback.message.edit_text(
"💳 <b>Оплата банковской картой</b>\n\n"
"Введите сумму для пополнения от 100 до 50,000 рублей:",
reply_markup=get_back_keyboard(db_user.language),
parse_mode="HTML"
)
await state.set_state(BalanceStates.waiting_for_amount)
await state.update_data(payment_method="yookassa")
await callback.answer()
@error_handler
async def start_tribute_payment(
callback: types.CallbackQuery,
@@ -278,6 +333,10 @@ async def process_topup_amount(
if payment_method == "stars":
await process_stars_payment_amount(message, db_user, amount_kopeks, state)
elif payment_method == "yookassa":
from app.database.database import get_db
async with get_db() as db:
await process_yookassa_payment_amount(message, db_user, db, amount_kopeks, state)
else:
await message.answer("❌ Неизвестный способ оплаты")
@@ -329,6 +388,139 @@ async def process_stars_payment_amount(
await message.answer("❌ Ошибка создания платежа")
@error_handler
async def process_yookassa_payment_amount(
message: types.Message,
db_user: User,
db: AsyncSession,
amount_kopeks: int,
state: FSMContext
):
texts = get_texts(db_user.language)
if not settings.is_yookassa_enabled():
await message.answer("❌ Оплата через YooKassa временно недоступна")
return
if amount_kopeks < 10000:
await message.answer("❌ Минимальная сумма для оплаты картой: 100 ₽")
return
try:
payment_service = PaymentService(message.bot)
payment_result = await payment_service.create_yookassa_payment(
db=db,
user_id=db_user.id,
amount_kopeks=amount_kopeks,
description=f"Пополнение баланса VPN на {settings.format_price(amount_kopeks)}",
receipt_email=None,
receipt_phone=None,
metadata={
"user_telegram_id": str(db_user.telegram_id),
"user_username": db_user.username or "",
"purpose": "balance_topup"
}
)
if not payment_result:
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
return
confirmation_url = payment_result.get("confirmation_url")
if not confirmation_url:
await message.answer("❌ Ошибка получения ссылки для оплаты. Обратитесь в поддержку.")
await state.clear()
return
keyboard = types.InlineKeyboardMarkup(inline_keyboard=[
[types.InlineKeyboardButton(text="💳 Оплатить картой", url=confirmation_url)],
[types.InlineKeyboardButton(text="📊 Проверить статус", callback_data=f"check_yookassa_{payment_result['local_payment_id']}")],
[types.InlineKeyboardButton(text=texts.BACK, callback_data="balance_topup")]
])
await message.answer(
f"💳 <b>Оплата банковской картой</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"🆔 ID платежа: {payment_result['yookassa_payment_id'][:8]}...\n\n"
f"📱 <b>Инструкция:</b>\n"
f"1. Нажмите кнопку 'Оплатить картой'\n"
f"2. Введите данные вашей карты\n"
f"3. Подтвердите платеж\n"
f"4. Деньги поступят на баланс автоматически\n\n"
f"🔒 Оплата происходит через защищенную систему YooKassa\n"
f"✅ Принимаем карты: Visa, MasterCard, МИР\n\n"
f"❓ Если возникнут проблемы, обратитесь в {settings.SUPPORT_USERNAME}",
reply_markup=keyboard,
parse_mode="HTML"
)
await state.clear()
logger.info(f"Создан платеж YooKassa для пользователя {db_user.telegram_id}: "
f"{amount_kopeks/100}₽, ID: {payment_result['yookassa_payment_id']}")
except Exception as e:
logger.error(f"Ошибка создания YooKassa платежа: {e}")
await message.answer("❌ Ошибка создания платежа. Попробуйте позже или обратитесь в поддержку.")
await state.clear()
@error_handler
async def check_yookassa_payment_status(
callback: types.CallbackQuery,
db: AsyncSession
):
try:
local_payment_id = int(callback.data.split('_')[-1])
from app.database.crud.yookassa import get_yookassa_payment_by_local_id
payment = await get_yookassa_payment_by_local_id(db, local_payment_id)
if not payment:
await callback.answer("❌ Платеж не найден", show_alert=True)
return
status_emoji = {
"pending": "",
"waiting_for_capture": "",
"succeeded": "",
"canceled": "",
"failed": ""
}
status_text = {
"pending": "Ожидает оплаты",
"waiting_for_capture": "Ожидает подтверждения",
"succeeded": "Оплачен",
"canceled": "Отменен",
"failed": "Ошибка"
}
emoji = status_emoji.get(payment.status, "")
status = status_text.get(payment.status, "Неизвестно")
message_text = (f"💳 <b>Статус платежа</b>\n\n"
f"🆔 ID: {payment.yookassa_payment_id[:8]}...\n"
f"💰 Сумма: {settings.format_price(payment.amount_kopeks)}\n"
f"📊 Статус: {emoji} {status}\n"
f"📅 Создан: {payment.created_at.strftime('%d.%m.%Y %H:%M')}\n")
if payment.is_succeeded:
message_text += "\n✅ Платеж успешно завершен!\nСредства зачислены на баланс."
elif payment.is_pending:
message_text += "\n⏳ Платеж ожидает оплаты. Нажмите кнопку 'Оплатить' выше."
elif payment.is_failed:
message_text += f"\n❌ Платеж не прошел. Обратитесь в {settings.SUPPORT_USERNAME}"
await callback.answer(message_text, show_alert=True)
except Exception as e:
logger.error(f"Ошибка проверки статуса платежа: {e}")
await callback.answer("❌ Ошибка проверки статуса", show_alert=True)
def register_handlers(dp: Dispatcher):
dp.callback_query.register(
@@ -356,6 +548,11 @@ def register_handlers(dp: Dispatcher):
F.data == "topup_stars"
)
dp.callback_query.register(
start_yookassa_payment,
F.data == "topup_yookassa"
)
dp.callback_query.register(
start_tribute_payment,
F.data == "topup_tribute"
@@ -366,7 +563,12 @@ def register_handlers(dp: Dispatcher):
F.data == "topup_support"
)
dp.callback_query.register(
check_yookassa_payment_status,
F.data.startswith("check_yookassa_")
)
dp.message.register(
process_topup_amount,
BalanceStates.waiting_for_amount
)
)
+61 -25
View File
@@ -906,19 +906,30 @@ async def confirm_extend_subscription(
db_user: User,
db: AsyncSession
):
days = int(callback.data.split('_')[2])
texts = get_texts(db_user.language)
subscription = db_user.subscription
if not subscription:
await callback.answer("❌ У вас нет активной подписки", show_alert=True)
return
subscription_service = SubscriptionService()
price = await subscription_service.calculate_renewal_price(subscription, days, db)
try:
price = await subscription_service.calculate_renewal_price(subscription, days, db)
except Exception as e:
logger.error(f"❌ ОШИБКА РАСЧЕТА ЦЕНЫ: {e}")
await callback.answer("❌ Ошибка расчета стоимости", show_alert=True)
return
if db_user.balance_kopeks < price:
await callback.answer("❌ Недостаточно средств на балансе", show_alert=True)
return
try:
logger.info(f"🔄 Начинаем продление подписки {subscription.id} на {days} дней за {price/100}")
success = await subtract_user_balance(
db, db_user, price,
f"Продление подписки на {days} дней"
@@ -928,18 +939,40 @@ async def confirm_extend_subscription(
await callback.answer("❌ Ошибка списания средств", show_alert=True)
return
await extend_subscription(db, subscription, days)
current_time = datetime.utcnow()
subscription_service = SubscriptionService()
await subscription_service.update_remnawave_user(db, subscription)
if subscription.end_date > current_time:
subscription.end_date = subscription.end_date + timedelta(days=days)
else:
subscription.end_date = current_time + timedelta(days=days)
await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Продление подписки на {days} дней"
)
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.updated_at = current_time
await db.commit()
await db.refresh(subscription)
await db.refresh(db_user)
try:
remnawave_result = await subscription_service.update_remnawave_user(db, subscription)
if remnawave_result:
logger.info(f"✅ RemnaWave обновлен успешно")
else:
logger.error(f"❌ ОШИБКА ОБНОВЛЕНИЯ REMNAWAVE")
except Exception as e:
logger.error(f"❌ ИСКЛЮЧЕНИЕ ПРИ ОБНОВЛЕНИИ REMNAWAVE: {e}")
try:
transaction = await create_transaction(
db=db,
user_id=db_user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=price,
description=f"Продление подписки на {days} дней"
)
logger.info(f"✅ Транзакция создана: ID {transaction.id}")
except Exception as e:
logger.error(f"❌ ОШИБКА СОЗДАНИЯ ТРАНЗАКЦИИ: {e}")
try:
await process_referral_purchase(
@@ -948,11 +981,9 @@ async def confirm_extend_subscription(
purchase_amount_kopeks=price,
transaction_id=None
)
logger.info(f"✅ Рефералы обработаны")
except Exception as e:
logger.error(f"Ошибка обработки реферальной покупки: {e}")
await db.refresh(db_user)
await db.refresh(subscription)
logger.error(f"❌ ОШИБКА ОБРАБОТКИ РЕФЕРАЛОВ: {e}")
await callback.message.edit_text(
f"✅ Подписка успешно продлена!\n\n"
@@ -965,9 +996,12 @@ async def confirm_extend_subscription(
logger.info(f"✅ Пользователь {db_user.telegram_id} продлил подписку на {days} дней за {price/100}")
except Exception as e:
logger.error(f"Ошибка продления подписки: {e}")
logger.error(f"❌ КРИТИЧЕСКАЯ ОШИБКА ПРОДЛЕНИЯ: {e}")
import traceback
logger.error(f"TRACEBACK: {traceback.format_exc()}")
await callback.message.edit_text(
texts.ERROR,
"❌ Произошла ошибка при продлении подписки. Обратитесь в поддержку.",
reply_markup=get_back_keyboard(db_user.language)
)
@@ -1429,27 +1463,29 @@ async def confirm_purchase(
existing_subscription = db_user.subscription
if existing_subscription and existing_subscription.is_trial:
logger.info(f"🔄 Обновляем триальную подписку пользователя {db_user.telegram_id}")
if existing_subscription:
logger.info(f"🔄 Обновляем существующую подписку пользователя {db_user.telegram_id}")
existing_subscription.is_trial = False
existing_subscription.status = SubscriptionStatus.ACTIVE.value
existing_subscription.traffic_limit_gb = final_traffic_gb
existing_subscription.device_limit = data['devices']
existing_subscription.connected_squads = data['countries']
existing_subscription.extend_subscription(data['period_days'])
existing_subscription.start_date = datetime.utcnow()
existing_subscription.end_date = datetime.utcnow() + timedelta(days=data['period_days'])
existing_subscription.updated_at = datetime.utcnow()
existing_subscription.traffic_used_gb = 0.0
await db.commit()
await db.refresh(existing_subscription)
subscription = existing_subscription
logger.info(f"Триальная подписка обновлена до платной. Новая дата окончания: {subscription.end_date}")
logger.info(f"Подписка обновлена. Новая дата окончания: {subscription.end_date}")
else:
logger.info(f"🆕 Создаем новую платную подписку для пользователя {db_user.telegram_id}")
logger.info(f"🆕 Создаем новую подписку для пользователя {db_user.telegram_id}")
subscription = await create_paid_subscription_with_traffic_mode(
db=db,
user_id=db_user.id,
@@ -1479,7 +1515,7 @@ async def confirm_purchase(
subscription_service = SubscriptionService()
if db_user.remnawave_uuid:
logger.info(f"📝 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
logger.info(f"🔄 Обновляем существующего RemnaWave пользователя {db_user.remnawave_uuid}")
remnawave_user = await subscription_service.update_remnawave_user(db, subscription)
else:
logger.info(f"🆕 Создаем нового RemnaWave пользователя для {db_user.telegram_id}")
+37 -1
View File
@@ -339,10 +339,18 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
)
])
if settings.is_yookassa_enabled():
keyboard.append([
InlineKeyboardButton(
text="💳 Банковская карта (YooKassa)",
callback_data="topup_yookassa"
)
])
if settings.TRIBUTE_ENABLED:
keyboard.append([
InlineKeyboardButton(
text="💳 Банковская карта",
text="💎 Банковская карта (Tribute)",
callback_data="topup_tribute"
)
])
@@ -360,6 +368,34 @@ def get_payment_methods_keyboard(amount_kopeks: int, language: str = "ru") -> In
return InlineKeyboardMarkup(inline_keyboard=keyboard)
def get_yookassa_payment_keyboard(
payment_id: str,
amount_kopeks: int,
confirmation_url: str,
language: str = "ru"
) -> InlineKeyboardMarkup:
return InlineKeyboardMarkup(inline_keyboard=[
[
InlineKeyboardButton(
text="💳 Оплатить",
url=confirmation_url
)
],
[
InlineKeyboardButton(
text="📊 Проверить статус",
callback_data=f"check_yookassa_status_{payment_id}"
)
],
[
InlineKeyboardButton(
text="💰 Мой баланс",
callback_data="menu_balance"
)
]
])
def get_autopay_notification_keyboard(subscription_id: int, language: str = "ru") -> InlineKeyboardMarkup:
texts = get_texts(language)
+1 -1
View File
@@ -283,7 +283,7 @@ class RussianTexts(Texts):
"""
SUBSCRIPTION_EXPIRING_PAID = """
<b>Подписка истекает через {days} дней!</b>
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {end_date}.
+4 -1
View File
@@ -369,7 +369,10 @@ class MonitoringService:
async def _send_subscription_expiring_notification(self, user: User, subscription: Subscription, days: int):
try:
from app.utils.formatters import format_days_declension
texts = get_texts(user.language)
days_text = format_days_declension(days, user.language)
if subscription.autopay_enabled:
autopay_status = "✅ Включен - подписка продлится автоматически"
@@ -379,7 +382,7 @@ class MonitoringService:
action_text = "💡 Включите автоплатеж или продлите подписку вручную"
message = f"""
<b>Подписка истекает через {days} дней!</b>
<b>Подписка истекает через {days_text}!</b>
Ваша платная подписка истекает {subscription.end_date.strftime("%d.%m.%Y %H:%M")}.
+231 -4
View File
@@ -1,11 +1,18 @@
import logging
import hashlib
import hmac
from typing import Optional
from typing import Optional, Dict, Any
from datetime import datetime
from aiogram import Bot
from aiogram.types import LabeledPrice
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.services.yookassa_service import YooKassaService
from app.database.crud.yookassa import create_yookassa_payment, link_yookassa_payment_to_transaction
from app.database.crud.transaction import create_transaction
from app.database.crud.user import add_user_balance
from app.database.models import TransactionType, PaymentMethod
logger = logging.getLogger(__name__)
@@ -14,6 +21,7 @@ class PaymentService:
def __init__(self, bot: Optional[Bot] = None):
self.bot = bot
self.yookassa_service = YooKassaService() if settings.is_yookassa_enabled() else None
async def create_stars_invoice(
self,
@@ -44,6 +52,228 @@ class PaymentService:
logger.error(f"Ошибка создания Stars invoice: {e}")
raise
async def create_yookassa_payment(
self,
db: AsyncSession,
user_id: int,
amount_kopeks: int,
description: str,
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None
) -> Optional[Dict[str, Any]]:
if not self.yookassa_service:
logger.error("YooKassa сервис не инициализирован")
return None
try:
amount_rubles = amount_kopeks / 100
payment_metadata = metadata or {}
payment_metadata.update({
"user_id": str(user_id),
"amount_kopeks": str(amount_kopeks),
"type": "balance_topup"
})
yookassa_response = await self.yookassa_service.create_payment(
amount=amount_rubles,
currency="RUB",
description=description,
metadata=payment_metadata,
receipt_email=receipt_email,
receipt_phone=receipt_phone
)
if not yookassa_response or yookassa_response.get("error"):
logger.error(f"Ошибка создания платежа YooKassa: {yookassa_response}")
return None
yookassa_created_at = None
if yookassa_response.get("created_at"):
try:
yookassa_created_at = datetime.fromisoformat(
yookassa_response["created_at"].replace('Z', '+00:00')
)
except:
pass
local_payment = await create_yookassa_payment(
db=db,
user_id=user_id,
yookassa_payment_id=yookassa_response["id"],
amount_kopeks=amount_kopeks,
currency="RUB",
description=description,
status=yookassa_response["status"],
confirmation_url=yookassa_response.get("confirmation_url"),
metadata_json=payment_metadata,
payment_method_type=None,
yookassa_created_at=yookassa_created_at,
test_mode=yookassa_response.get("test_mode", False)
)
logger.info(f"Создан платеж YooKassa {yookassa_response['id']} на {amount_rubles}₽ для пользователя {user_id}")
return {
"local_payment_id": local_payment.id,
"yookassa_payment_id": yookassa_response["id"],
"confirmation_url": yookassa_response.get("confirmation_url"),
"amount_kopeks": amount_kopeks,
"amount_rubles": amount_rubles,
"status": yookassa_response["status"],
"created_at": local_payment.created_at
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa: {e}")
return None
async def process_yookassa_webhook(self, db: AsyncSession, webhook_data: dict) -> bool:
try:
from app.database.crud.yookassa import (
get_yookassa_payment_by_id,
update_yookassa_payment_status,
link_yookassa_payment_to_transaction
)
from app.database.crud.transaction import create_transaction
from app.database.models import TransactionType, PaymentMethod
payment_object = webhook_data.get("object", {})
yookassa_payment_id = payment_object.get("id")
status = payment_object.get("status")
paid = payment_object.get("paid", False)
if not yookassa_payment_id:
logger.error("Webhook без ID платежа")
return False
payment = await get_yookassa_payment_by_id(db, yookassa_payment_id)
if not payment:
logger.error(f"Платеж не найден в БД: {yookassa_payment_id}")
return False
captured_at = None
if status == "succeeded":
captured_at = datetime.utcnow()
updated_payment = await update_yookassa_payment_status(
db,
yookassa_payment_id,
status,
is_paid=paid,
is_captured=(status == "succeeded"),
captured_at=captured_at,
payment_method_type=payment_object.get("payment_method", {}).get("type")
)
if status == "succeeded" and paid and not updated_payment.transaction_id:
transaction = await create_transaction(
db,
user_id=updated_payment.user_id,
type=TransactionType.DEPOSIT,
amount_kopeks=updated_payment.amount_kopeks,
description=f"Пополнение через YooKassa ({yookassa_payment_id[:8]}...)",
payment_method=PaymentMethod.YOOKASSA,
external_id=yookassa_payment_id,
is_completed=True
)
await link_yookassa_payment_to_transaction(
db, yookassa_payment_id, transaction.id
)
from app.database.crud.user import add_user_balance
await add_user_balance(db, updated_payment.user_id, updated_payment.amount_kopeks)
if self.bot:
try:
await self.bot.send_message(
updated_payment.user.telegram_id,
f"✅ <b>Пополнение успешно!</b>\n\n"
f"💰 Сумма: {settings.format_price(updated_payment.amount_kopeks)}\n"
f" Способ: Банковская карта\n"
f"🆔 Транзакция: {yookassa_payment_id[:8]}...\n\n"
f"Баланс пополнен автоматически!",
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о пополнении: {e}")
return True
except Exception as e:
logger.error(f"Ошибка обработки YooKassa webhook: {e}", exc_info=True)
return False
async def _process_successful_yookassa_payment(
self,
db: AsyncSession,
payment: "YooKassaPayment"
) -> bool:
try:
transaction = await create_transaction(
db=db,
user_id=payment.user_id,
transaction_type=TransactionType.DEPOSIT,
amount_kopeks=payment.amount_kopeks,
description=f"Пополнение через YooKassa: {payment.description}",
payment_method=PaymentMethod.YOOKASSA,
external_id=payment.yookassa_payment_id,
is_completed=True
)
await link_yookassa_payment_to_transaction(
db=db,
yookassa_payment_id=payment.yookassa_payment_id,
transaction_id=transaction.id
)
await add_user_balance(db, payment.user_id, payment.amount_kopeks)
logger.info(f"Успешно обработан платеж YooKassa {payment.yookassa_payment_id}: "
f"пользователь {payment.user_id} получил {payment.amount_kopeks/100}")
if self.bot:
try:
await self._send_payment_success_notification(
payment.user.telegram_id,
payment.amount_kopeks
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления о платеже: {e}")
return True
except Exception as e:
logger.error(f"Ошибка обработки успешного платежа YooKassa {payment.yookassa_payment_id}: {e}")
return False
async def _send_payment_success_notification(
self,
telegram_id: int,
amount_kopeks: int
) -> None:
if not self.bot:
return
try:
message = (f"✅ <b>Платеж успешно завершен!</b>\n\n"
f"💰 Сумма: {settings.format_price(amount_kopeks)}\n"
f"💳 Способ: Банковская карта (YooKassa)\n\n"
f"Средства зачислены на ваш баланс!")
await self.bot.send_message(
chat_id=telegram_id,
text=message,
parse_mode="HTML"
)
except Exception as e:
logger.error(f"Ошибка отправки уведомления пользователю {telegram_id}: {e}")
async def create_tribute_payment(
self,
amount_kopeks: int,
@@ -104,9 +334,6 @@ class PaymentService:
) -> bool:
try:
# Здесь должна быть логика обработки платежа
# Например, пополнение баланса пользователя
logger.info(f"Обработан успешный платеж: {payment_id}, {amount_kopeks/100}₽, {user_id}")
return True
+194
View File
@@ -0,0 +1,194 @@
import uuid
import logging
import asyncio
from typing import Optional, Dict, Any, List
from yookassa import Configuration, Payment as YooKassaPayment
from yookassa.domain.request.payment_request_builder import PaymentRequestBuilder
from yookassa.domain.common.confirmation_type import ConfirmationType
from app.config import settings
logger = logging.getLogger(__name__)
class YooKassaService:
def __init__(self,
shop_id: Optional[str] = None,
secret_key: Optional[str] = None,
configured_return_url: Optional[str] = None,
bot_username_for_default_return: Optional[str] = None):
shop_id = shop_id or getattr(settings, 'YOOKASSA_SHOP_ID', None)
secret_key = secret_key or getattr(settings, 'YOOKASSA_SECRET_KEY', None)
configured_return_url = configured_return_url or getattr(settings, 'YOOKASSA_RETURN_URL', None)
if not shop_id or not secret_key:
logger.warning(
"YooKassa SHOP_ID или SECRET_KEY не настроены в settings. "
"Функционал платежей будет ОТКЛЮЧЕН.")
self.configured = False
else:
try:
Configuration.configure(shop_id, secret_key)
self.configured = True
logger.info(
f"YooKassa SDK сконфигурирован для shop_id: {shop_id[:5]}...")
except Exception as e:
logger.error(f"Ошибка конфигурации YooKassa SDK: {e}",
exc_info=True)
self.configured = False
if configured_return_url:
self.return_url = configured_return_url
elif bot_username_for_default_return:
self.return_url = f"https://t.me/{bot_username_for_default_return}"
logger.info(
f"YOOKASSA_RETURN_URL не установлен, используем бота: {self.return_url}")
else:
self.return_url = "https://t.me/"
logger.warning(
f"КРИТИЧНО: YOOKASSA_RETURN_URL не установлен И username бота не предоставлен. "
f"Используем заглушку: {self.return_url}. Платежи могут работать некорректно.")
logger.info(f"YooKassa Service return_url: {self.return_url}")
async def create_payment(
self,
amount: float,
currency: str,
description: str,
metadata: Dict[str, Any],
receipt_email: Optional[str] = None,
receipt_phone: Optional[str] = None) -> Optional[Dict[str, Any]]:
"""Создает платеж в YooKassa"""
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно создать платеж.")
return None
customer_contact_for_receipt = {}
if receipt_email:
customer_contact_for_receipt["email"] = receipt_email
elif receipt_phone:
customer_contact_for_receipt["phone"] = receipt_phone
elif hasattr(settings, 'YOOKASSA_DEFAULT_RECEIPT_EMAIL') and settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL:
customer_contact_for_receipt["email"] = settings.YOOKASSA_DEFAULT_RECEIPT_EMAIL
else:
logger.error(
"КРИТИЧНО: Не предоставлен email/телефон для чека YooKassa и YOOKASSA_DEFAULT_RECEIPT_EMAIL не установлен.")
return {
"error": True,
"internal_message": "Отсутствуют контактные данные для чека YooKassa и не настроен email по умолчанию."
}
try:
builder = PaymentRequestBuilder()
builder.set_amount({
"value": str(round(amount, 2)),
"currency": currency.upper()
})
builder.set_capture(True)
builder.set_confirmation({
"type": ConfirmationType.REDIRECT,
"return_url": self.return_url
})
builder.set_description(description)
builder.set_metadata(metadata)
receipt_items_list: List[Dict[str, Any]] = [{
"description": description[:128],
"quantity": "1.00",
"amount": {
"value": str(round(amount, 2)),
"currency": currency.upper()
},
"vat_code": str(getattr(settings, 'YOOKASSA_VAT_CODE', 1)),
"payment_mode": getattr(settings, 'YOOKASSA_PAYMENT_MODE', 'full_payment'),
"payment_subject": getattr(settings, 'YOOKASSA_PAYMENT_SUBJECT', 'service')
}]
receipt_data_dict: Dict[str, Any] = {
"customer": customer_contact_for_receipt,
"items": receipt_items_list
}
builder.set_receipt(receipt_data_dict)
idempotence_key = str(uuid.uuid4())
payment_request = builder.build()
logger.info(
f"Создание платежа YooKassa (Idempotence-Key: {idempotence_key}). "
f"Сумма: {amount} {currency}. Метаданные: {metadata}. Чек: {receipt_data_dict}")
loop = asyncio.get_running_loop()
response = await loop.run_in_executor(
None, lambda: YooKassaPayment.create(payment_request, idempotence_key))
logger.info(
f"Ответ YooKassa Payment.create: ID={response.id}, Status={response.status}, Paid={response.paid}")
return {
"id": response.id,
"confirmation_url": response.confirmation.confirmation_url if response.confirmation else None,
"status": response.status,
"metadata": response.metadata,
"amount_value": float(response.amount.value),
"amount_currency": response.amount.currency,
"idempotence_key_used": idempotence_key,
"paid": response.paid,
"refundable": response.refundable,
"created_at": response.created_at.isoformat() if hasattr(
response.created_at, 'isoformat') else str(response.created_at),
"description_from_yk": response.description,
"test_mode": response.test if hasattr(response, 'test') else None
}
except Exception as e:
logger.error(f"Ошибка создания платежа YooKassa: {e}", exc_info=True)
return None
async def get_payment_info(
self, payment_id_in_yookassa: str) -> Optional[Dict[str, Any]]:
if not self.configured:
logger.error("YooKassa не сконфигурирован. Невозможно получить информацию о платеже.")
return None
try:
logger.info(f"Получение информации о платеже YooKassa ID: {payment_id_in_yookassa}")
loop = asyncio.get_running_loop()
payment_info_yk = await loop.run_in_executor(
None, lambda: YooKassaPayment.find_one(payment_id_in_yookassa))
if payment_info_yk:
logger.info(
f"Информация о платеже YooKassa {payment_id_in_yookassa}: "
f"Status={payment_info_yk.status}, Paid={payment_info_yk.paid}")
return {
"id": payment_info_yk.id,
"status": payment_info_yk.status,
"paid": payment_info_yk.paid,
"amount_value": float(payment_info_yk.amount.value),
"amount_currency": payment_info_yk.amount.currency,
"metadata": payment_info_yk.metadata,
"description": payment_info_yk.description,
"refundable": payment_info_yk.refundable,
"created_at": payment_info_yk.created_at.isoformat() if hasattr(
payment_info_yk.created_at, 'isoformat') else str(payment_info_yk.created_at),
"captured_at": payment_info_yk.captured_at.isoformat()
if payment_info_yk.captured_at and hasattr(
payment_info_yk.captured_at, 'isoformat') else None,
"payment_method_type": payment_info_yk.payment_method.type
if payment_info_yk.payment_method else None,
"test_mode": payment_info_yk.test if hasattr(payment_info_yk, 'test') else None
}
else:
logger.warning(f"Платеж не найден в YooKassa ID: {payment_id_in_yookassa}")
return None
except Exception as e:
logger.error(f"Ошибка получения информации о платеже YooKassa {payment_id_in_yookassa}: {e}",
exc_info=True)
return None
+12 -1
View File
@@ -67,6 +67,17 @@ def format_time_ago(dt: Union[datetime, str]) -> str:
else:
return "только что"
def format_days_declension(days: int, language: str = "ru") -> str:
if language != "ru":
return f"{days} day{'s' if days != 1 else ''}"
if days % 10 == 1 and days % 100 != 11:
return f"{days} день"
elif days % 10 in [2, 3, 4] and days % 100 not in [12, 13, 14]:
return f"{days} дня"
else:
return f"{days} дней"
def format_duration(seconds: int) -> str:
if seconds < 60:
@@ -204,4 +215,4 @@ def format_boolean(value: bool, language: str = "ru") -> str:
if language == "ru":
return "✅ Да" if value else "❌ Нет"
else:
return "✅ Yes" if value else "❌ No"
return "✅ Yes" if value else "❌ No"
+97
View File
@@ -0,0 +1,97 @@
import logging
from datetime import datetime
from typing import Optional
from sqlalchemy import select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database.models import Subscription, User
logger = logging.getLogger(__name__)
async def ensure_single_subscription(db: AsyncSession, user_id: int) -> Optional[Subscription]:
result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = result.scalars().all()
if len(subscriptions) <= 1:
return subscriptions[0] if subscriptions else None
latest_subscription = subscriptions[0]
old_subscriptions = subscriptions[1:]
logger.warning(f"🚨 Обнаружено {len(subscriptions)} подписок у пользователя {user_id}. Удаляем {len(old_subscriptions)} старых.")
for old_sub in old_subscriptions:
await db.delete(old_sub)
logger.info(f"🗑️ Удалена подписка ID {old_sub.id} от {old_sub.created_at}")
await db.commit()
await db.refresh(latest_subscription)
logger.info(f"✅ Оставлена подписка ID {latest_subscription.id} от {latest_subscription.created_at}")
return latest_subscription
async def update_or_create_subscription(
db: AsyncSession,
user_id: int,
**subscription_data
) -> Subscription:
existing_subscription = await ensure_single_subscription(db, user_id)
if existing_subscription:
for key, value in subscription_data.items():
if hasattr(existing_subscription, key):
setattr(existing_subscription, key, value)
existing_subscription.updated_at = datetime.utcnow()
await db.commit()
await db.refresh(existing_subscription)
logger.info(f"🔄 Обновлена существующая подписка ID {existing_subscription.id}")
return existing_subscription
else:
new_subscription = Subscription(
user_id=user_id,
**subscription_data
)
db.add(new_subscription)
await db.commit()
await db.refresh(new_subscription)
logger.info(f"🆕 Создана новая подписка ID {new_subscription.id}")
return new_subscription
async def cleanup_duplicate_subscriptions(db: AsyncSession) -> int:
result = await db.execute(
select(Subscription.user_id)
.group_by(Subscription.user_id)
.having(func.count(Subscription.id) > 1)
)
users_with_duplicates = result.scalars().all()
total_deleted = 0
for user_id in users_with_duplicates:
subscriptions_result = await db.execute(
select(Subscription)
.where(Subscription.user_id == user_id)
.order_by(Subscription.created_at.desc())
)
subscriptions = subscriptions_result.scalars().all()
for old_subscription in subscriptions[1:]:
await db.delete(old_subscription)
total_deleted += 1
logger.info(f"🗑️ Удалена дублирующаяся подписка ID {old_subscription.id} пользователя {user_id}")
await db.commit()
logger.info(f"🧹 Очищено {total_deleted} дублирующихся подписок")
return total_deleted
+22 -3
View File
@@ -1,6 +1,7 @@
import asyncio
import logging
import sys
import os
from pathlib import Path
sys.path.append(str(Path(__file__).parent))
@@ -10,6 +11,7 @@ from app.config import settings
from app.database.database import init_db
from app.services.monitoring_service import monitoring_service
from app.external.webhook_server import WebhookServer
from app.database.universal_migration import run_universal_migration
async def main():
@@ -23,7 +25,7 @@ async def main():
)
logger = logging.getLogger(__name__)
logger.info("🚀 Запуск VPN бота...")
logger.info("🚀 Запуск Bedolaga Remnawave Bot...")
webhook_server = None
@@ -31,12 +33,29 @@ async def main():
logger.info("📊 Инициализация базы данных...")
await init_db()
skip_migration = os.getenv('SKIP_MIGRATION', 'false').lower() == 'true'
if not skip_migration:
logger.info("🔧 Выполняем проверку и миграцию базы данных...")
try:
migration_success = await run_universal_migration()
if migration_success:
logger.info("✅ Миграция базы данных завершена успешно")
else:
logger.warning("⚠️ Миграция завершилась с предупреждениями, но продолжаем запуск")
except Exception as migration_error:
logger.error(f"❌ Ошибка выполнения миграции: {migration_error}")
logger.warning("⚠️ Продолжаем запуск без миграции")
else:
logger.info("ℹ️ Миграция пропущена (SKIP_MIGRATION=true)")
logger.info("🤖 Настройка бота...")
bot, dp = await setup_bot()
monitoring_service.bot = bot
# Инициализируем webhook сервер если Tribute включен
if settings.TRIBUTE_ENABLED:
logger.info("🌐 Запуск webhook сервера для Tribute...")
webhook_server = WebhookServer(bot)
@@ -78,4 +97,4 @@ if __name__ == "__main__":
print("\n🛑 Бот остановлен пользователем")
except Exception as e:
print(f"❌ Критическая ошибка: {e}")
sys.exit(1)
sys.exit(1)
+3
View File
@@ -12,6 +12,9 @@ pydantic-settings==2.1.0
python-dotenv==1.0.0
redis==5.0.1
# YooKassa SDK
yookassa==3.0.0
# Логирование и мониторинг
structlog==23.2.0
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 240 KiB