Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c9efd5127 | |||
| 4663097a24 | |||
| dc51a55c98 | |||
| 275f249bbd | |||
| 7a9264b173 | |||
| 32d58b04b9 | |||
| 7ca96195a7 | |||
| 5752b5e7c6 | |||
| e6f577697b | |||
| f4a776319e | |||
| 2649e12f64 | |||
| 4a5cacda38 | |||
| 79161eaae4 | |||
| 289cbe966e | |||
| 7ccfb66690 | |||
| 536525c9c0 | |||
| 4186159a61 | |||
| 6349b2f442 | |||
| bfbefeb1e2 | |||
| f9f07f360c | |||
| 770b31d3d0 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.26.0"
|
||||
".": "3.27.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,35 @@
|
||||
# Changelog
|
||||
|
||||
## [3.27.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.26.0...v3.27.0) (2026-03-09)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* auto-resume disabled daily subscriptions on balance topup ([770b31d](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/770b31d3d05c22411b64ddbea3c304e34d879f5b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add method query param to return_url and latest-payment endpoint ([32d58b0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/32d58b04b9a37473f43ae07cc32d4e18b161e3b9))
|
||||
* add table existence guards to migrations for optional payment tables ([f4a7763](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f4a776319eaccbce108a1f22462da6cd592fe0f3))
|
||||
* admin tariff server selection - 64-byte overflow and callback routing conflicts ([536525c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/536525c9c0a7701321bc3b83d6cef125c6f343ba))
|
||||
* align tariff pricing with calculate_renewal_price reference ([6349b2f](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/6349b2f4426abd49e3bd63364f3d2b204a486282))
|
||||
* conditional log messages and sanitize panel_error in user deletion ([289cbe9](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/289cbe966e42afe74c8d1b936139941ff84e008b))
|
||||
* encode payment status in provider return URLs and wire failed_url ([275f249](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/275f249bbdf28d065e1b856e4d8ec7e73af4e1aa))
|
||||
* enforce tariff device_price and max_device_limit across all purchase paths ([f9f07f3](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/f9f07f360c36ce1eade8a27fa0fa5bf22808db93))
|
||||
* keep DB session alive in Tribute payment notification handler ([4186159](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4186159a61a40003454afc9c0faf848582cfb037))
|
||||
* latest-payment endpoint returns all payments, not just pending ([7a9264b](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7a9264b1731cf8935c9e3985f41a1df919dfbf83))
|
||||
* pass cabinet return_url to payment providers for top-up redirects ([7ca9619](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ca96195a7240ce0c3bd613c20344d79e5219c74))
|
||||
* propagate tariff squad changes to existing subscriptions and fix user deletion from Remnawave ([7ccfb66](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/7ccfb66690c93df0c9c694935b16a280ca8ae812))
|
||||
* renewal cost estimate double-counts servers and traffic in tariff mode ([bfbefeb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bfbefeb1e20a191f604bbcbc79b14d8c6e4cd5bd))
|
||||
* resolve concurrent AsyncSession bug and sanitize error responses ([4a5cacd](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/4a5cacda386e7fa60ad7b6393aa3372384bee128))
|
||||
* use parsed HTML length for Telegram caption limit checks ([2649e12](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2649e12f64b8f825a3db85b95da6a335b0f8eec6))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* move squad propagation to service layer with parallel Remnawave sync ([79161ea](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/79161eaae4d67c82c45b6ea3654c0b15c8b785a4))
|
||||
|
||||
## [3.26.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.25.0...v3.26.0) (2026-03-08)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.26.0" # x-release-please-version
|
||||
ARG VERSION="v3.27.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -374,7 +374,7 @@ async def _sync_subscription_to_panel(
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error syncing user to panel', user_id=user.id, error=e)
|
||||
return {'error': str(e)}
|
||||
return {'error': 'Ошибка синхронизации пользователя с панелью'}
|
||||
|
||||
|
||||
# === List & Search ===
|
||||
@@ -1718,7 +1718,7 @@ async def delete_user_device(
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error deleting device for user', hwid=hwid, user_id=user_id, error=e)
|
||||
return DeleteDeviceResponse(success=False, message=str(e))
|
||||
return DeleteDeviceResponse(success=False, message='Ошибка удаления устройства')
|
||||
|
||||
|
||||
@router.delete('/{user_id}/devices', response_model=ResetDevicesResponse)
|
||||
@@ -1762,7 +1762,7 @@ async def reset_user_devices(
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error resetting devices for user', user_id=user_id, error=e)
|
||||
return ResetDevicesResponse(success=False, message=str(e))
|
||||
return ResetDevicesResponse(success=False, message='Ошибка сброса устройств')
|
||||
|
||||
|
||||
# === Delete User ===
|
||||
@@ -1830,28 +1830,32 @@ async def full_delete_user(
|
||||
detail='User not found',
|
||||
)
|
||||
|
||||
panel_error: str | None = None
|
||||
deleted_from_panel = False
|
||||
|
||||
# Pre-fetch admin.id to avoid MissingGreenlet after transaction rollback
|
||||
admin_id_val = admin.id
|
||||
|
||||
# UserService.delete_user_account handles both bot DB and Remnawave panel
|
||||
user_service = UserService()
|
||||
success = await user_service.delete_user_account(db, user_id, admin_id_val)
|
||||
|
||||
if success:
|
||||
deleted_from_panel = request.delete_from_panel and user.remnawave_uuid is not None
|
||||
delete_result = await user_service.delete_user_account(
|
||||
db, user_id, admin_id_val, force_panel_delete=request.delete_from_panel
|
||||
)
|
||||
|
||||
reason_text = f' (reason: {request.reason})' if request.reason else ''
|
||||
logger.info('Admin fully deleted user', admin_id=admin_id_val, user_id=user_id, reason_text=reason_text)
|
||||
logger.info(
|
||||
'Admin fully deleted user',
|
||||
admin_id=admin_id_val,
|
||||
user_id=user_id,
|
||||
reason_text=reason_text,
|
||||
bot_deleted=delete_result.bot_deleted,
|
||||
panel_deleted=delete_result.panel_deleted,
|
||||
panel_error=delete_result.panel_error,
|
||||
)
|
||||
|
||||
return FullDeleteUserResponse(
|
||||
success=success,
|
||||
message='User fully deleted from bot and panel' if success else 'Failed to delete user',
|
||||
deleted_from_bot=success,
|
||||
deleted_from_panel=deleted_from_panel,
|
||||
panel_error=panel_error,
|
||||
success=delete_result.bot_deleted,
|
||||
message='User fully deleted from bot and panel' if delete_result.bot_deleted else 'Failed to delete user',
|
||||
deleted_from_bot=delete_result.bot_deleted,
|
||||
deleted_from_panel=delete_result.panel_deleted,
|
||||
panel_error=delete_result.panel_error,
|
||||
)
|
||||
|
||||
|
||||
@@ -1985,7 +1989,7 @@ async def reset_user_subscription(
|
||||
if panel_deactivated:
|
||||
logger.info('Disabled Remnawave user for subscription reset', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
panel_error = 'Ошибка обработки пользователя в Remnawave'
|
||||
logger.warning('Failed to disable Remnawave user during subscription reset', error=e)
|
||||
|
||||
# Delete subscription from database
|
||||
@@ -2055,7 +2059,7 @@ async def disable_user(
|
||||
if panel_deactivated:
|
||||
logger.info('Disabled Remnawave user', remnawave_uuid=user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
panel_error = str(e)
|
||||
panel_error = 'Ошибка обработки пользователя в Remnawave'
|
||||
logger.warning('Failed to disable Remnawave user', error=e)
|
||||
|
||||
# Deactivate subscription in bot database (skip if active paid subscription)
|
||||
|
||||
@@ -349,6 +349,9 @@ async def create_topup(
|
||||
amount_rubles = request.amount_kopeks / 100
|
||||
payment_url = None
|
||||
payment_id = None
|
||||
cabinet_return_url = f'{settings.CABINET_URL.rstrip("/")}/balance/top-up/result?method={request.payment_method}'
|
||||
cabinet_success_url = f'{cabinet_return_url}&status=success'
|
||||
cabinet_failed_url = f'{cabinet_return_url}&status=failed'
|
||||
|
||||
try:
|
||||
if request.payment_method == 'yookassa':
|
||||
@@ -373,6 +376,7 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
return_url=cabinet_return_url,
|
||||
)
|
||||
else:
|
||||
result = await payment_service.create_yookassa_payment(
|
||||
@@ -381,6 +385,7 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=description,
|
||||
metadata=yookassa_metadata,
|
||||
return_url=cabinet_return_url,
|
||||
)
|
||||
|
||||
if result:
|
||||
@@ -490,6 +495,8 @@ async def create_topup(
|
||||
),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
payment_method_code=method_code,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('redirect_url'):
|
||||
@@ -515,6 +522,8 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_return_url,
|
||||
success_url=cabinet_success_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -612,6 +621,8 @@ async def create_topup(
|
||||
amount_kopeks=request.amount_kopeks,
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -638,6 +649,8 @@ async def create_topup(
|
||||
description=settings.get_balance_payment_description(request.amount_kopeks),
|
||||
telegram_id=user.telegram_id,
|
||||
language=getattr(user, 'language', None) or settings.DEFAULT_LANGUAGE,
|
||||
return_url=cabinet_success_url,
|
||||
failed_url=cabinet_failed_url,
|
||||
)
|
||||
|
||||
if result and result.get('payment_url'):
|
||||
@@ -965,6 +978,91 @@ async def get_pending_payments(
|
||||
)
|
||||
|
||||
|
||||
@router.get('/pending-payments/{method}/latest', response_model=PendingPaymentResponse)
|
||||
async def get_latest_payment_by_method(
|
||||
method: str,
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Get user's most recent payment for a given method (any status, not just pending)."""
|
||||
try:
|
||||
payment_method = PaymentMethod(method)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Invalid payment method: {method}',
|
||||
)
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.models import (
|
||||
CloudPaymentsPayment,
|
||||
CryptoBotPayment,
|
||||
FreekassaPayment,
|
||||
HeleketPayment,
|
||||
KassaAiPayment,
|
||||
MulenPayPayment,
|
||||
Pal24Payment,
|
||||
PlategaPayment,
|
||||
WataPayment,
|
||||
YooKassaPayment,
|
||||
)
|
||||
|
||||
model_map: dict[PaymentMethod, type] = {
|
||||
PaymentMethod.YOOKASSA: YooKassaPayment,
|
||||
PaymentMethod.CRYPTOBOT: CryptoBotPayment,
|
||||
PaymentMethod.HELEKET: HeleketPayment,
|
||||
PaymentMethod.MULENPAY: MulenPayPayment,
|
||||
PaymentMethod.PAL24: Pal24Payment,
|
||||
PaymentMethod.WATA: WataPayment,
|
||||
PaymentMethod.PLATEGA: PlategaPayment,
|
||||
PaymentMethod.CLOUDPAYMENTS: CloudPaymentsPayment,
|
||||
PaymentMethod.FREEKASSA: FreekassaPayment,
|
||||
PaymentMethod.KASSA_AI: KassaAiPayment,
|
||||
}
|
||||
|
||||
model = model_map.get(payment_method)
|
||||
if not model:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Unsupported payment method: {method}',
|
||||
)
|
||||
|
||||
cutoff = datetime.now(UTC) - timedelta(hours=1)
|
||||
stmt = (
|
||||
select(model)
|
||||
.options(selectinload(model.user))
|
||||
.where(model.user_id == user.id, model.created_at >= cutoff)
|
||||
.order_by(desc(model.created_at))
|
||||
.limit(1)
|
||||
)
|
||||
result = await db.execute(stmt)
|
||||
payment = result.scalars().first()
|
||||
|
||||
if not payment:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='No recent payments found',
|
||||
)
|
||||
|
||||
record = PendingPayment(
|
||||
local_id=payment.id,
|
||||
method=payment_method,
|
||||
identifier=str(getattr(payment, 'correlation_id', None) or payment.id),
|
||||
amount_kopeks=payment.amount_kopeks,
|
||||
status=payment.status or '',
|
||||
is_paid=bool(payment.is_paid),
|
||||
created_at=payment.created_at,
|
||||
expires_at=getattr(payment, 'expires_at', None),
|
||||
user=payment.user,
|
||||
payment=payment,
|
||||
)
|
||||
|
||||
return _record_to_response(record)
|
||||
|
||||
|
||||
@router.get('/pending-payments/{method}/{payment_id}', response_model=PendingPaymentResponse)
|
||||
async def get_pending_payment_details(
|
||||
method: str,
|
||||
|
||||
@@ -1008,9 +1008,10 @@ async def purchase_devices_legacy(
|
||||
user: User = Depends(get_current_cabinet_user),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Purchase additional device slots (legacy endpoint without tariff support).
|
||||
"""Purchase additional device slots (legacy endpoint).
|
||||
|
||||
DEPRECATED: Use /devices/purchase instead for full tariff and discount support.
|
||||
Now uses tariff-aware pricing when subscription has a tariff_id.
|
||||
"""
|
||||
if getattr(user, 'restriction_subscription', False):
|
||||
raise HTTPException(
|
||||
@@ -1033,8 +1034,34 @@ async def purchase_devices_legacy(
|
||||
detail='No subscription found',
|
||||
)
|
||||
|
||||
price_per_device = settings.PRICE_PER_DEVICE
|
||||
base_total_price = price_per_device * request.devices
|
||||
if subscription.status not in ['active', 'trial']:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Ваша подписка неактивна',
|
||||
)
|
||||
|
||||
# Get tariff for device price (if exists)
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Determine device price and max limit from tariff or settings
|
||||
if tariff and tariff.device_price_kopeks is not None:
|
||||
device_price = tariff.device_price_kopeks
|
||||
max_device_limit = tariff.max_device_limit
|
||||
else:
|
||||
device_price = settings.PRICE_PER_DEVICE
|
||||
max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
|
||||
if not device_price or device_price <= 0:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Докупка устройств недоступна',
|
||||
)
|
||||
|
||||
base_total_price = device_price * request.devices
|
||||
|
||||
# Apply discount from promo group
|
||||
discount_result = _apply_addon_discount(user, 'devices', base_total_price, 30)
|
||||
@@ -1048,12 +1075,11 @@ async def purchase_devices_legacy(
|
||||
# Check max devices limit (under row lock — prevents concurrent purchases exceeding limit)
|
||||
current_devices = subscription.device_limit or 1
|
||||
new_devices = current_devices + request.devices
|
||||
max_devices = settings.MAX_DEVICES_LIMIT
|
||||
|
||||
if new_devices > max_devices:
|
||||
if max_device_limit and new_devices > max_device_limit:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f'Maximum device limit is {max_devices}',
|
||||
detail=f'Максимальное количество устройств: {max_device_limit}',
|
||||
)
|
||||
|
||||
# Check balance
|
||||
@@ -1127,7 +1153,7 @@ async def purchase_devices_legacy(
|
||||
|
||||
actual_current = subscription.device_limit or 1
|
||||
actual_new = actual_current + request.devices
|
||||
if max_devices > 0 and actual_new > max_devices:
|
||||
if max_device_limit and actual_new > max_device_limit:
|
||||
# Concurrent purchase already exceeded limit — refund balance
|
||||
user_refund = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
@@ -1137,14 +1163,25 @@ async def purchase_devices_legacy(
|
||||
await db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f'Maximum device limit is {max_devices}. Balance refunded.',
|
||||
detail=f'Максимальное количество устройств: {max_device_limit}. Баланс возвращён.',
|
||||
)
|
||||
|
||||
# Add devices (under lock)
|
||||
subscription.device_limit = actual_new
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
else:
|
||||
await service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
logger.error('Failed to sync devices with RemnaWave (legacy endpoint)', error=e)
|
||||
|
||||
# Отправляем уведомление админам
|
||||
try:
|
||||
from aiogram import Bot
|
||||
@@ -4454,19 +4491,27 @@ async def toggle_subscription_pause(
|
||||
detail='Pause is only available for daily tariffs',
|
||||
)
|
||||
|
||||
# Toggle pause state
|
||||
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
|
||||
new_paused_state = not is_currently_paused
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
# Сохраняем статус ДО изменения для проверки RemnaWave
|
||||
# Determine current state
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
was_disabled = user.subscription.status == SubscriptionStatus.DISABLED.value
|
||||
is_currently_paused = getattr(user.subscription, 'is_daily_paused', False)
|
||||
was_disabled = user.subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
)
|
||||
|
||||
# If resuming, check balance
|
||||
# System-DISABLED subs (insufficient balance) should always be treated as needing resume,
|
||||
# even if is_daily_paused is False (it's set by the system, not the user)
|
||||
if was_disabled and not is_currently_paused:
|
||||
new_paused_state = False # Force resume path
|
||||
else:
|
||||
new_paused_state = not is_currently_paused
|
||||
user.subscription.is_daily_paused = new_paused_state
|
||||
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# If resuming, check balance and charge
|
||||
if not new_paused_state:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -4478,8 +4523,44 @@ async def toggle_subscription_pause(
|
||||
},
|
||||
)
|
||||
|
||||
# Restore ACTIVE status if was DISABLED
|
||||
# Charge daily fee FIRST, then restore ACTIVE status
|
||||
if was_disabled:
|
||||
if daily_price > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Balance deduction failed',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction', error=exc)
|
||||
|
||||
# Balance deducted successfully — now activate
|
||||
user.subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
user.subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
user.subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
@@ -4489,14 +4570,17 @@ async def toggle_subscription_pause(
|
||||
await db.refresh(user)
|
||||
|
||||
# Sync with RemnaWave only when resuming from DISABLED state
|
||||
# При паузе НЕ отключаем - пользователь может пользоваться до конца оплаченного периода
|
||||
# При возобновлении включаем только если подписка была отключена (DISABLED)
|
||||
if not new_paused_state and user.remnawave_uuid and was_disabled:
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
user.subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Error enabling RemnaWave user on resume', error=e)
|
||||
logger.error('Error syncing RemnaWave user on resume', error=e)
|
||||
|
||||
if new_paused_state:
|
||||
message = 'Daily subscription paused'
|
||||
|
||||
@@ -2094,6 +2094,9 @@ async def get_disabled_daily_subscriptions_for_resume(
|
||||
Subscription.status == SubscriptionStatus.DISABLED.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.is_trial.is_(False),
|
||||
# Не возобновляем подписки, приостановленные пользователем вручную
|
||||
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
|
||||
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
|
||||
# Баланс пользователя >= суточной цены тарифа
|
||||
User.balance_kopeks >= Tariff.daily_price_kopeks,
|
||||
)
|
||||
@@ -2135,7 +2138,8 @@ async def get_expired_daily_subscriptions_for_recovery(db: AsyncSession) -> list
|
||||
Tariff.is_active.is_(True),
|
||||
Subscription.status == SubscriptionStatus.EXPIRED.value,
|
||||
User.status == UserStatus.ACTIVE.value,
|
||||
Subscription.is_daily_paused.is_(False),
|
||||
# is_(False) не ловит NULL, поэтому добавляем OR is_(None)
|
||||
(Subscription.is_daily_paused.is_(False) | Subscription.is_daily_paused.is_(None)),
|
||||
Subscription.is_trial.is_(False),
|
||||
# Только недавно экспайренные
|
||||
Subscription.updated_at >= recovery_threshold,
|
||||
|
||||
@@ -449,40 +449,10 @@ async def add_user_balance(
|
||||
amount_kopeks=amount_kopeks,
|
||||
)
|
||||
|
||||
# Автоматическое возобновление приостановленной суточной подписки
|
||||
try:
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, resume_daily_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
# Загружаем подписку явно, чтобы избежать lazy loading
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription and subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
# Проверяем что это суточный тариф
|
||||
is_daily = getattr(subscription, 'is_daily_tariff', False)
|
||||
if is_daily and subscription.tariff_id:
|
||||
# Загружаем тариф явно
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
# Если баланс достаточный для суточной оплаты - возобновляем
|
||||
if daily_price > 0 and user.balance_kopeks >= daily_price:
|
||||
await resume_daily_subscription(db, subscription)
|
||||
logger.info(
|
||||
'✅ Автоматически возобновлена суточная подписка после пополнения баланса (user_id=)',
|
||||
subscription_id=subscription.id,
|
||||
user_id=user.id,
|
||||
)
|
||||
# Синхронизируем с RemnaWave
|
||||
try:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
except Exception as sync_err:
|
||||
logger.warning('Не удалось синхронизировать с RemnaWave', sync_err=sync_err)
|
||||
except Exception as resume_err:
|
||||
logger.warning('Ошибка при попытке возобновить суточную подписку', resume_err=resume_err)
|
||||
# Авто-возобновление суточной подписки НЕ делаем здесь —
|
||||
# это обязанность try_resume_disabled_daily_after_topup (через send_cart_notification_after_topup)
|
||||
# и DailySubscriptionService.process_auto_resume (30-минутный цикл).
|
||||
# Они корректно списывают суточную плату при возобновлении.
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -2275,7 +2275,7 @@ async def start_edit_tariff_squads(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
squads, _ = await get_all_server_squads(db)
|
||||
squads, _ = await get_all_server_squads(db, limit=10000)
|
||||
|
||||
if not squads:
|
||||
await callback.answer('Нет доступных серверов', show_alert=True)
|
||||
@@ -2291,7 +2291,7 @@ async def start_edit_tariff_squads(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'{prefix} {squad.display_name}',
|
||||
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
|
||||
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -2344,7 +2344,7 @@ async def toggle_tariff_squad(
|
||||
tariff = await update_tariff(db, tariff, allowed_squads=list(current_squads))
|
||||
|
||||
# Перерисовываем меню
|
||||
squads, _ = await get_all_server_squads(db)
|
||||
squads, _ = await get_all_server_squads(db, limit=10000)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
buttons = []
|
||||
@@ -2355,7 +2355,7 @@ async def toggle_tariff_squad(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'{prefix} {squad.display_name}',
|
||||
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
|
||||
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -2382,6 +2382,15 @@ async def toggle_tariff_squad(
|
||||
|
||||
await callback.answer()
|
||||
|
||||
# Применяем изменения серверов к существующим подпискам
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, list(current_squads))
|
||||
if propagate_result.failed_ids:
|
||||
await callback.message.answer(
|
||||
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -2402,7 +2411,7 @@ async def clear_tariff_squads(
|
||||
await callback.answer('Все серверы очищены')
|
||||
|
||||
# Перерисовываем меню
|
||||
squads, _ = await get_all_server_squads(db)
|
||||
squads, _ = await get_all_server_squads(db, limit=10000)
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
buttons = []
|
||||
@@ -2411,7 +2420,7 @@ async def clear_tariff_squads(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'⬜ {squad.display_name}',
|
||||
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
|
||||
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -2436,6 +2445,15 @@ async def clear_tariff_squads(
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
|
||||
# Применяем изменения серверов к существующим подпискам (пустой список = все серверы)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, [])
|
||||
if propagate_result.failed_ids:
|
||||
await callback.message.answer(
|
||||
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
|
||||
)
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
@@ -2452,8 +2470,8 @@ async def select_all_tariff_squads(
|
||||
await callback.answer('Тариф не найден', show_alert=True)
|
||||
return
|
||||
|
||||
squads, _ = await get_all_server_squads(db)
|
||||
all_uuids = [s.squad_uuid for s in squads]
|
||||
squads, _ = await get_all_server_squads(db, limit=10000)
|
||||
all_uuids = [s.squad_uuid for s in squads if s.squad_uuid]
|
||||
|
||||
tariff = await update_tariff(db, tariff, allowed_squads=all_uuids)
|
||||
await callback.answer('Все серверы выбраны')
|
||||
@@ -2466,7 +2484,7 @@ async def select_all_tariff_squads(
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=f'✅ {squad.display_name}',
|
||||
callback_data=f'admin_tariff_toggle_squad:{tariff_id}:{squad.squad_uuid}',
|
||||
callback_data=f'trf_sq:{tariff_id}:{squad.squad_uuid}',
|
||||
)
|
||||
]
|
||||
)
|
||||
@@ -2491,6 +2509,15 @@ async def select_all_tariff_squads(
|
||||
except TelegramBadRequest:
|
||||
pass
|
||||
|
||||
# Применяем изменения серверов к существующим подпискам
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
propagate_result = await SubscriptionService().propagate_tariff_squads(db, tariff.id, all_uuids)
|
||||
if propagate_result.failed_ids:
|
||||
await callback.message.answer(
|
||||
f'⚠️ {len(propagate_result.failed_ids)} из {propagate_result.total} подписок не синхронизированы с RemnaWave',
|
||||
)
|
||||
|
||||
|
||||
# ============ РЕДАКТИРОВАНИЕ ПРОМОГРУПП ============
|
||||
|
||||
@@ -2799,7 +2826,13 @@ def register_handlers(dp: Dispatcher):
|
||||
# Просмотр и переключение
|
||||
dp.callback_query.register(view_tariff, F.data.startswith('admin_tariff_view:'))
|
||||
dp.callback_query.register(
|
||||
toggle_tariff, F.data.startswith('admin_tariff_toggle:') & ~F.data.startswith('admin_tariff_toggle_trial:')
|
||||
toggle_tariff,
|
||||
F.data.startswith('admin_tariff_toggle:')
|
||||
& ~F.data.startswith('admin_tariff_toggle_trial:')
|
||||
& ~F.data.startswith('trf_sq:')
|
||||
& ~F.data.startswith('admin_tariff_toggle_promo:')
|
||||
& ~F.data.startswith('admin_tariff_toggle_traffic_topup:')
|
||||
& ~F.data.startswith('admin_tariff_toggle_daily:'),
|
||||
)
|
||||
dp.callback_query.register(toggle_trial_tariff, F.data.startswith('admin_tariff_toggle_trial:'))
|
||||
|
||||
@@ -2821,7 +2854,8 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_tariff_description, F.data.startswith('admin_tariff_edit_desc:'))
|
||||
dp.message.register(process_edit_tariff_description, AdminStates.editing_tariff_description)
|
||||
|
||||
# Редактирование трафика
|
||||
# Редактирование трафика (traffic_topup BEFORE traffic to avoid prefix conflict)
|
||||
dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith('admin_tariff_edit_traffic_topup:'))
|
||||
dp.callback_query.register(start_edit_tariff_traffic, F.data.startswith('admin_tariff_edit_traffic:'))
|
||||
dp.message.register(process_edit_tariff_traffic, AdminStates.editing_tariff_traffic)
|
||||
|
||||
@@ -2849,8 +2883,7 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_tariff_trial_days, F.data.startswith('admin_tariff_edit_trial_days:'))
|
||||
dp.message.register(process_edit_tariff_trial_days, AdminStates.editing_tariff_trial_days)
|
||||
|
||||
# Редактирование докупки трафика
|
||||
dp.callback_query.register(start_edit_tariff_traffic_topup, F.data.startswith('admin_tariff_edit_traffic_topup:'))
|
||||
# Редактирование докупки трафика (start_edit_tariff_traffic_topup registered above with traffic)
|
||||
dp.callback_query.register(toggle_tariff_traffic_topup, F.data.startswith('admin_tariff_toggle_traffic_topup:'))
|
||||
dp.callback_query.register(
|
||||
start_edit_traffic_topup_packages, F.data.startswith('admin_tariff_edit_topup_packages:')
|
||||
@@ -2861,13 +2894,13 @@ def register_handlers(dp: Dispatcher):
|
||||
dp.callback_query.register(start_edit_max_topup_traffic, F.data.startswith('admin_tariff_edit_max_topup:'))
|
||||
dp.message.register(process_edit_max_topup_traffic, AdminStates.editing_tariff_max_topup_traffic)
|
||||
|
||||
# Удаление
|
||||
dp.callback_query.register(confirm_delete_tariff, F.data.startswith('admin_tariff_delete:'))
|
||||
# Удаление (delete_confirm BEFORE delete to avoid prefix conflict)
|
||||
dp.callback_query.register(delete_tariff_confirmed, F.data.startswith('admin_tariff_delete_confirm:'))
|
||||
dp.callback_query.register(confirm_delete_tariff, F.data.startswith('admin_tariff_delete:'))
|
||||
|
||||
# Редактирование серверов
|
||||
dp.callback_query.register(start_edit_tariff_squads, F.data.startswith('admin_tariff_edit_squads:'))
|
||||
dp.callback_query.register(toggle_tariff_squad, F.data.startswith('admin_tariff_toggle_squad:'))
|
||||
dp.callback_query.register(toggle_tariff_squad, F.data.startswith('trf_sq:'))
|
||||
dp.callback_query.register(clear_tariff_squads, F.data.startswith('admin_tariff_clear_squads:'))
|
||||
dp.callback_query.register(select_all_tariff_squads, F.data.startswith('admin_tariff_select_all_squads:'))
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ from app.database.crud.campaign import (
|
||||
from app.database.crud.promo_group import get_promo_groups_with_counts
|
||||
from app.database.crud.server_squad import (
|
||||
get_all_server_squads,
|
||||
get_server_ids_by_uuids,
|
||||
get_server_squad_by_id,
|
||||
get_server_squad_by_uuid,
|
||||
)
|
||||
@@ -1019,9 +1018,9 @@ async def delete_user_account(callback: types.CallbackQuery, db_user: User, db:
|
||||
user_id = int(callback.data.split('_')[-1])
|
||||
|
||||
user_service = UserService()
|
||||
success = await user_service.delete_user_account(db, user_id, db_user.id)
|
||||
delete_result = await user_service.delete_user_account(db, user_id, db_user.id)
|
||||
|
||||
if success:
|
||||
if delete_result.bot_deleted:
|
||||
await callback.message.edit_text(
|
||||
'✅ Пользователь успешно удален',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
@@ -4168,44 +4167,21 @@ async def _calculate_subscription_period_price(
|
||||
|
||||
service = subscription_service or SubscriptionService()
|
||||
|
||||
connected_squads = list(subscription.connected_squads or [])
|
||||
server_ids = []
|
||||
|
||||
if connected_squads:
|
||||
# Загружаем тариф для корректного расчёта в тарифном режиме
|
||||
if subscription.tariff_id:
|
||||
try:
|
||||
server_ids = await get_server_ids_by_uuids(db, connected_squads)
|
||||
if len(server_ids) != len(connected_squads):
|
||||
logger.warning(
|
||||
'Не удалось сопоставить все сервера подписки пользователя для расчёта цены',
|
||||
telegram_id=target_user.telegram_id,
|
||||
)
|
||||
await db.refresh(subscription, ['tariff'])
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'Не удалось получить идентификаторы серверов для расчёта цены подписки пользователя',
|
||||
telegram_id=target_user.telegram_id,
|
||||
e=e,
|
||||
)
|
||||
server_ids = []
|
||||
traffic_limit_gb = subscription.traffic_limit_gb
|
||||
if traffic_limit_gb is None:
|
||||
traffic_limit_gb = settings.DEFAULT_TRAFFIC_LIMIT_GB
|
||||
logger.warning('Не удалось загрузить тариф для расчёта цены', error=e)
|
||||
|
||||
device_limit = subscription.device_limit
|
||||
if not device_limit or device_limit < 0:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
total_price, _ = await service.calculate_subscription_price(
|
||||
return await service.calculate_renewal_price(
|
||||
subscription=subscription,
|
||||
period_days=period_days,
|
||||
traffic_gb=traffic_limit_gb,
|
||||
server_squad_ids=server_ids,
|
||||
devices=device_limit,
|
||||
db=db,
|
||||
user=target_user,
|
||||
promo_group=target_user.promo_group,
|
||||
promo_group=getattr(target_user, 'promo_group', None),
|
||||
)
|
||||
|
||||
return total_price
|
||||
|
||||
|
||||
@admin_required
|
||||
@error_handler
|
||||
|
||||
@@ -439,68 +439,100 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
# В режиме тарифов берём цену из тарифа пользователя
|
||||
tariff = None
|
||||
tariff_price_found = False
|
||||
base_price_original = 0
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
base_price_original = tariff.period_prices.get(str(duration_days), 0)
|
||||
if base_price_original > 0:
|
||||
tariff_price_found = True
|
||||
|
||||
# Если не нашли в тарифе - используем PERIOD_PRICES
|
||||
if base_price_original <= 0:
|
||||
base_price_original = PERIOD_PRICES.get(duration_days, 0)
|
||||
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
if tariff_price_found:
|
||||
# Тарифный режим: серверы и трафик включены в цену тарифа.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
# Рассчитываем стоимость серверов
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
original_price = base_price_original
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
(
|
||||
servers_price_per_month,
|
||||
per_server_monthly_prices,
|
||||
) = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_limit = (
|
||||
subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff and tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
devices_price = extra_devices * device_price_per_unit * months_in_period
|
||||
original_price += devices_price
|
||||
|
||||
# Рассчитываем стоимость трафика
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_price = original_price - discount_total
|
||||
|
||||
# Рассчитываем стоимость устройств
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
# Promo-offer скидка (временная)
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(db_user)
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_price * promo_offer_percent // 100
|
||||
total_price = total_price - promo_offer_discount
|
||||
else:
|
||||
# Классический режим: серверы + трафик + устройства считаются отдельно
|
||||
period_discount_percent = db_user.get_promo_discount('period', duration_days)
|
||||
base_price, base_discount_total = apply_percentage_discount(
|
||||
base_price_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
# Общая стоимость
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price
|
||||
+ total_servers_price * months_in_period
|
||||
+ traffic_discounted_per_month * months_in_period
|
||||
+ devices_discounted_per_month * months_in_period
|
||||
)
|
||||
subscription_service = SubscriptionService()
|
||||
(
|
||||
servers_price_per_month,
|
||||
per_server_monthly_prices,
|
||||
) = await subscription_service.get_countries_price_by_uuids(
|
||||
current_connected_squads,
|
||||
db,
|
||||
promo_group_id=db_user.promo_group_id,
|
||||
)
|
||||
servers_discount_percent = db_user.get_promo_discount('servers', duration_days)
|
||||
total_servers_price = 0
|
||||
for server_price in per_server_monthly_prices:
|
||||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||||
server_price,
|
||||
servers_discount_percent,
|
||||
)
|
||||
total_servers_price += discounted_per_month
|
||||
|
||||
traffic_price_per_month = settings.get_traffic_price(current_traffic)
|
||||
traffic_discount_percent = db_user.get_promo_discount('traffic', duration_days)
|
||||
traffic_discounted_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||||
traffic_price_per_month,
|
||||
traffic_discount_percent,
|
||||
)
|
||||
|
||||
additional_devices = max(0, (current_device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT)
|
||||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||||
devices_discount_percent = db_user.get_promo_discount('devices', duration_days)
|
||||
devices_discounted_per_month, devices_discount_per_month = apply_percentage_discount(
|
||||
devices_price_per_month,
|
||||
devices_discount_percent,
|
||||
)
|
||||
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
total_price = (
|
||||
base_price
|
||||
+ total_servers_price * months_in_period
|
||||
+ traffic_discounted_per_month * months_in_period
|
||||
+ devices_discounted_per_month * months_in_period
|
||||
)
|
||||
|
||||
traffic_value = current_traffic or 0
|
||||
if traffic_value <= 0:
|
||||
|
||||
+40
-21
@@ -1295,32 +1295,48 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
best_period = None
|
||||
best_price = 0
|
||||
|
||||
for period in available_periods:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
)
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
# Для продления используем тот же сервис, что и при реальном списании,
|
||||
# чтобы сумма проверки совпадала с суммой списания.
|
||||
renewal_service = SubscriptionRenewalService() if subscription else None
|
||||
|
||||
if not best_period:
|
||||
# Показать сколько не хватает для минимального периода
|
||||
min_period = min(available_periods) if available_periods else 30
|
||||
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
)
|
||||
missing = min_price - balance
|
||||
await callback.answer(
|
||||
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100} ₽'),
|
||||
show_alert=True,
|
||||
)
|
||||
try:
|
||||
for period in available_periods:
|
||||
if subscription and renewal_service:
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, period)
|
||||
price = pricing.final_total
|
||||
else:
|
||||
price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
)
|
||||
if price <= balance:
|
||||
best_period = period
|
||||
best_price = price
|
||||
break
|
||||
|
||||
if not best_period:
|
||||
# Показать сколько не хватает для минимального периода
|
||||
min_period = min(available_periods) if available_periods else 30
|
||||
if subscription and renewal_service:
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, min_period)
|
||||
min_price = pricing.final_total
|
||||
else:
|
||||
min_price, _ = await subscription_service.calculate_subscription_price_with_months(
|
||||
min_period, traffic_limit_gb, server_ids, device_limit, db, user=db_user
|
||||
)
|
||||
missing = min_price - balance
|
||||
await callback.answer(
|
||||
texts.t('INSUFFICIENT_FUNDS_DETAILED', f'❌ Недостаточно средств. Не хватает {missing // 100} ₽'),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error('Ошибка расчёта стоимости при активации', error=e)
|
||||
await callback.answer('❌ Ошибка расчёта стоимости', show_alert=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if subscription:
|
||||
# Продление существующей подписки
|
||||
renewal_service = SubscriptionRenewalService()
|
||||
pricing = await renewal_service.calculate_pricing(db, db_user, subscription, best_period)
|
||||
|
||||
await renewal_service.finalize(
|
||||
@@ -1333,7 +1349,10 @@ async def handle_activate_button(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
|
||||
await callback.answer(
|
||||
texts.t('ACTIVATION_SUCCESS', f'✅ Подписка продлена на {best_period} дней за {best_price // 100} ₽!'),
|
||||
texts.t(
|
||||
'ACTIVATION_SUCCESS',
|
||||
f'✅ Подписка продлена на {best_period} дней за {pricing.final_total // 100} ₽!',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -1958,7 +1958,7 @@ async def get_main_menu_text_simple(user_name, texts, db: AsyncSession):
|
||||
async def required_sub_channel_check(
|
||||
query: types.CallbackQuery, bot: Bot, state: FSMContext, db: AsyncSession, db_user=None
|
||||
):
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
from app.utils.message_patch import _cache_logo_file_id, caption_exceeds_telegram_limit, get_logo_media
|
||||
|
||||
language = DEFAULT_LANGUAGE
|
||||
texts = get_texts(language)
|
||||
@@ -2129,7 +2129,7 @@ async def required_sub_channel_check(
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(bot, db, user, pinned_message)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
|
||||
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=get_logo_media(),
|
||||
@@ -2255,7 +2255,7 @@ async def required_sub_channel_check(
|
||||
if pinned_message and pinned_message.send_before_menu:
|
||||
await _send_pinned_message(bot, db, user, pinned_message)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and len(menu_text) <= 900:
|
||||
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(menu_text):
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=get_logo_media(),
|
||||
@@ -2286,7 +2286,7 @@ async def required_sub_channel_check(
|
||||
else:
|
||||
rules_text = await get_rules(language)
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and len(rules_text) <= 900:
|
||||
if settings.ENABLE_LOGO_MODE and not caption_exceeds_telegram_limit(rules_text):
|
||||
_result = await bot.send_photo(
|
||||
chat_id=query.from_user.id,
|
||||
photo=get_logo_media(),
|
||||
|
||||
@@ -276,12 +276,19 @@ async def confirm_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
)
|
||||
return
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_devices_count > settings.MAX_DEVICES_LIMIT:
|
||||
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
|
||||
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
effective_max = (
|
||||
tariff_max_devices
|
||||
if tariff_max_devices
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
if effective_max and new_devices_count > effective_max:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'DEVICES_LIMIT_EXCEEDED',
|
||||
'⚠️ Превышен максимальный лимит устройств ({limit})',
|
||||
).format(limit=settings.MAX_DEVICES_LIMIT),
|
||||
).format(limit=effective_max),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
@@ -564,8 +571,13 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
# Re-validate: prevent double-charge and max-limit violation
|
||||
if new_devices_count > current_devices:
|
||||
max_devices = settings.MAX_DEVICES_LIMIT
|
||||
if max_devices > 0 and new_devices_count > max_devices:
|
||||
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
max_devices = (
|
||||
tariff_max_recheck
|
||||
if tariff_max_recheck
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
if max_devices and new_devices_count > max_devices:
|
||||
if price > 0:
|
||||
user_refund = await db.execute(
|
||||
select(User)
|
||||
@@ -1126,10 +1138,19 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
new_total_devices = subscription.device_limit + devices_count
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_total_devices > settings.MAX_DEVICES_LIMIT:
|
||||
# Используем max_device_limit из тарифа если есть, иначе глобальную настройку
|
||||
tariff_max_devices = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
effective_max = (
|
||||
tariff_max_devices
|
||||
if tariff_max_devices
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
if effective_max and new_total_devices > effective_max:
|
||||
await callback.answer(
|
||||
f'⚠️ Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT}). '
|
||||
f'У вас: {subscription.device_limit}, добавляете: {devices_count}',
|
||||
texts.t(
|
||||
'DEVICES_LIMIT_EXCEEDED_DETAIL',
|
||||
'⚠️ Превышен максимальный лимит устройств ({limit}). У вас: {current}, добавляете: {adding}',
|
||||
).format(limit=effective_max, current=subscription.device_limit, adding=devices_count),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
@@ -1257,8 +1278,13 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
# Re-validate max device limit after re-lock
|
||||
actual_current = subscription.device_limit or 1
|
||||
actual_new = actual_current + devices_count
|
||||
max_devices = settings.MAX_DEVICES_LIMIT
|
||||
if max_devices > 0 and actual_new > max_devices:
|
||||
tariff_max_recheck = getattr(tariff, 'max_device_limit', None) if tariff else None
|
||||
max_devices = (
|
||||
tariff_max_recheck
|
||||
if tariff_max_recheck
|
||||
else (settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None)
|
||||
)
|
||||
if max_devices and actual_new > max_devices:
|
||||
# Concurrent purchase exceeded limit — refund
|
||||
user_refund = await db.execute(
|
||||
select(User).where(User.id == db_user.id).with_for_update().execution_options(populate_existing=True)
|
||||
|
||||
@@ -309,11 +309,11 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
return 0
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
|
||||
base_cost_original = PERIOD_PRICES.get(30, 0)
|
||||
try:
|
||||
owner = subscription.user
|
||||
except AttributeError:
|
||||
@@ -321,65 +321,101 @@ async def get_subscription_cost(subscription, db: AsyncSession) -> int:
|
||||
|
||||
promo_group_id = getattr(owner, 'promo_group_id', None) if owner else None
|
||||
|
||||
period_discount_percent = 0
|
||||
if owner:
|
||||
# В тарифном режиме цена тарифа уже включает серверы и трафик
|
||||
tariff = None
|
||||
tariff_price_found = False
|
||||
if settings.is_tariffs_mode() and subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if tariff and tariff.period_prices:
|
||||
base_cost_original = tariff.period_prices.get('30', 0) or tariff.period_prices.get(30, 0)
|
||||
if base_cost_original > 0:
|
||||
tariff_price_found = True
|
||||
|
||||
if not tariff_price_found:
|
||||
base_cost_original = PERIOD_PRICES.get(30, 0)
|
||||
|
||||
if tariff_price_found:
|
||||
# Тарифный режим: серверы и трафик включены в цену.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
original_price = base_cost_original
|
||||
|
||||
tariff_device_limit = tariff.device_limit if tariff.device_limit is not None else 0
|
||||
device_limit = subscription.device_limit if subscription.device_limit is not None else tariff_device_limit
|
||||
extra_devices = max(0, device_limit - tariff_device_limit)
|
||||
device_price_per_unit = (
|
||||
tariff.device_price_kopeks
|
||||
if tariff and tariff.device_price_kopeks is not None
|
||||
else settings.PRICE_PER_DEVICE
|
||||
)
|
||||
devices_price = extra_devices * device_price_per_unit
|
||||
original_price += devices_price
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
period_discount_percent = 0
|
||||
if owner:
|
||||
try:
|
||||
period_discount_percent = owner.get_promo_discount('period', 30)
|
||||
except AttributeError:
|
||||
pass
|
||||
discount_total = original_price * period_discount_percent // 100
|
||||
total_cost = original_price - discount_total
|
||||
|
||||
# Promo-offer скидка (временная)
|
||||
promo_offer_percent = get_user_active_promo_discount_percent(owner)
|
||||
if promo_offer_percent > 0:
|
||||
promo_offer_discount = total_cost * promo_offer_percent // 100
|
||||
total_cost = total_cost - promo_offer_discount
|
||||
else:
|
||||
# Классический режим: серверы + трафик + устройства считаются отдельно
|
||||
period_discount_percent = 0
|
||||
if owner:
|
||||
try:
|
||||
period_discount_percent = owner.get_promo_discount('period', 30)
|
||||
except AttributeError:
|
||||
period_discount_percent = 0
|
||||
|
||||
base_cost, _ = apply_percentage_discount(
|
||||
base_cost_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
try:
|
||||
period_discount_percent = owner.get_promo_discount('period', 30)
|
||||
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
except AttributeError:
|
||||
period_discount_percent = 0
|
||||
servers_cost, _ = await get_countries_price_by_uuids_fallback(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
base_cost, _ = apply_percentage_discount(
|
||||
base_cost_original,
|
||||
period_discount_percent,
|
||||
)
|
||||
|
||||
try:
|
||||
servers_cost, _ = await subscription_service.get_countries_price_by_uuids(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
except AttributeError:
|
||||
servers_cost, _ = await get_countries_price_by_uuids_fallback(
|
||||
subscription.connected_squads,
|
||||
db,
|
||||
promo_group_id=promo_group_id,
|
||||
)
|
||||
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
traffic_cost = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||||
device_limit = subscription.device_limit
|
||||
if device_limit is None:
|
||||
if settings.is_devices_selection_enabled():
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
forced_limit = settings.get_disabled_mode_device_limit()
|
||||
if forced_limit is None:
|
||||
device_limit = settings.DEFAULT_DEVICE_LIMIT
|
||||
else:
|
||||
device_limit = forced_limit
|
||||
|
||||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
devices_cost = max(0, (device_limit or 0) - settings.DEFAULT_DEVICE_LIMIT) * settings.PRICE_PER_DEVICE
|
||||
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
total_cost = base_cost + servers_cost + traffic_cost + devices_cost
|
||||
|
||||
logger.info('📊 Месячная стоимость конфигурации подписки', subscription_id=subscription.id)
|
||||
base_log = f' 📅 Базовый тариф (30 дней): {base_cost_original / 100}₽'
|
||||
if period_discount_percent > 0:
|
||||
discount_value = base_cost_original * period_discount_percent // 100
|
||||
base_log += f' → {base_cost / 100}₽ (скидка {period_discount_percent}%: -{discount_value / 100}₽)'
|
||||
logger.info(base_log)
|
||||
if servers_cost > 0:
|
||||
logger.info('🌍 Серверы: ₽', servers_cost=servers_cost / 100)
|
||||
if traffic_cost > 0:
|
||||
logger.info('📊 Трафик: ₽', traffic_cost=traffic_cost / 100)
|
||||
if devices_cost > 0:
|
||||
logger.info('📱 Устройства: ₽', devices_cost=devices_cost / 100)
|
||||
logger.info('💎 ИТОГО: ₽', total_cost=total_cost / 100)
|
||||
logger.info('Месячная стоимость подписки', subscription_id=subscription.id, total_cost_kopeks=total_cost)
|
||||
|
||||
return total_cost
|
||||
|
||||
except Exception as e:
|
||||
logger.error('⚠️ Ошибка расчета стоимости подписки', error=e)
|
||||
logger.error('Ошибка расчета стоимости подписки', error=e)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -208,7 +208,11 @@ async def show_subscription_info(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
|
||||
if subscription.status == 'expired' or subscription.end_date <= current_time:
|
||||
if subscription.status == 'disabled':
|
||||
actual_status = 'disabled'
|
||||
status_display = texts.t('SUBSCRIPTION_STATUS_DISABLED', 'Приостановлена')
|
||||
status_emoji = '⏸️'
|
||||
elif subscription.status == 'expired' or subscription.end_date <= current_time:
|
||||
actual_status = 'expired'
|
||||
status_display = texts.t('SUBSCRIPTION_STATUS_EXPIRED', 'Истекла')
|
||||
status_emoji = '🔴'
|
||||
@@ -3222,6 +3226,42 @@ async def handle_toggle_daily_subscription_pause(callback: types.CallbackQuery,
|
||||
return
|
||||
|
||||
if needs_resume:
|
||||
# Списываем суточную оплату ДО активации (чтобы не было бесплатного дня)
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price > 0 and is_inactive:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
db_user,
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not deducted:
|
||||
await callback.answer(
|
||||
texts.t(
|
||||
'INSUFFICIENT_BALANCE_FOR_RESUME',
|
||||
f'❌ Недостаточно средств для возобновления. Требуется: {settings.format_price(daily_price)}',
|
||||
),
|
||||
show_alert=True,
|
||||
)
|
||||
return
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as tx_error:
|
||||
logger.warning('Не удалось создать транзакцию при возобновлении', error=tx_error)
|
||||
|
||||
# Принудительный resume: снимаем паузу + восстанавливаем статус ACTIVE
|
||||
from app.database.crud.subscription import resume_daily_subscription
|
||||
|
||||
|
||||
@@ -1704,6 +1704,7 @@
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Warning!</b> You have {days} days left.\nThey will be lost when switching to daily tariff!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Subscription paused",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Subscription resumed!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Subscription resumed!</b>\n\nYour daily plan «{tariff_name}» has been resumed after balance top-up.\n\n💳 Charged: {amount}\n💰 Remaining: {balance}",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Subscription expired</b>\n\nYour subscription has ended. Renew to restore VPN access.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Subscription disabled</b>\n\nYour subscription has been disabled by the administrator.",
|
||||
|
||||
@@ -1723,6 +1723,7 @@
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>توجه!</b> {days} روز اشتراک باقی مانده.\nبا تغییر به تعرفه روزانه از دست میروند!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ اشتراک متوقف شد",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ اشتراک از سر گرفته شد!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>اشتراک از سر گرفته شد!</b>\n\nتعرفه روزانه «{tariff_name}» پس از شارژ موجودی از سر گرفته شد.\n\n💳 کسر شده: {amount}\n💰 باقیمانده: {balance}",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>اشتراک منقضی شد</b>\n\nاشتراک شما به پایان رسیده است. برای بازیابی دسترسی VPN تمدید کنید.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>اشتراک غیرفعال شد</b>\n\nاشتراک شما توسط مدیر غیرفعال شده است.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>اشتراک فعال شد</b>\n\nاشتراک شما دوباره فعال است. از استفاده لذت ببرید!",
|
||||
|
||||
@@ -1725,6 +1725,7 @@
|
||||
"DAILY_SWITCH_WARNING": "⚠️ <b>Внимание!</b> У вас осталось {days} дн. подписки.\nПри смене на суточный тариф они будут утеряны!",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Подписка приостановлена",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Подписка возобновлена!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Подписка возобновлена!</b>\n\nВаш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n💳 Списано: {amount}\n💰 Остаток: {balance}",
|
||||
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Подписка истекла</b>\n\nВаша подписка завершена. Продлите подписку, чтобы восстановить доступ к VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Подписка отключена</b>\n\nВаша подписка была отключена администратором.",
|
||||
|
||||
@@ -1592,6 +1592,9 @@
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "Вартість: <s>{base_price}</s> <b>{final_price}</b> (за {months} міс)\n🎁 Знижка {discount}%: -{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "Вартість: {price} (за {months} міс)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>Підтвердження підключення модема</b>\n\n{price_text}\n\nПри підключенні модема:\n• До підписки додасться додатковий пристрій\n• Щомісячна плата збільшиться на {monthly_price}\n\nПідтвердити підключення?",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ Підписка призупинена",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ Підписка відновлена!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>Підписка відновлена!</b>\n\nВаш добовий тариф «{tariff_name}» відновлено після поповнення балансу.\n\n💳 Списано: {amount}\n💰 Залишок: {balance}",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>Підписка закінчилась</b>\n\nВаша підписка завершена. Продовжте підписку, щоб відновити доступ до VPN.",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>Підписку вимкнено</b>\n\nВашу підписку було вимкнено адміністратором.",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>Підписку активовано</b>\n\nВаша підписка знову активна. Приємного використання!",
|
||||
|
||||
@@ -1588,6 +1588,9 @@
|
||||
"MODEM_PRICE_WITH_DISCOUNT": "费用:<s>{base_price}</s> <b>{final_price}</b>({months}个月)\n🎁 折扣{discount}%:-{discount_amount}",
|
||||
"MODEM_PRICE_NO_DISCOUNT": "费用:{price}({months}个月)",
|
||||
"MODEM_CONFIRM_ENABLE_BASE": "📡 <b>确认连接调制解调器</b>\n\n{price_text}\n\n连接调制解调器时:\n• 将向您的订阅添加额外设备\n• 月费将增加{monthly_price}\n\n确认连接?",
|
||||
"DAILY_SUBSCRIPTION_PAUSED": "⏸️ 订阅已暂停",
|
||||
"DAILY_SUBSCRIPTION_RESUMED": "▶️ 订阅已恢复!",
|
||||
"DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP": "✅ <b>订阅已恢复!</b>\n\n您的日套餐「{tariff_name}」已在充值后恢复。\n\n💳 扣费:{amount}\n💰 余额:{balance}",
|
||||
"WEBHOOK_SUB_EXPIRED": "❌ <b>订阅已过期</b>\n\n您的订阅已结束。请续订以恢复VPN访问。",
|
||||
"WEBHOOK_SUB_DISABLED": "🚫 <b>订阅已禁用</b>\n\n您的订阅已被管理员禁用。",
|
||||
"WEBHOOK_SUB_ENABLED": "✅ <b>订阅已激活</b>\n\n您的订阅已重新激活。祝使用愉快!",
|
||||
|
||||
@@ -22,6 +22,7 @@ from app.database.models import (
|
||||
Transaction,
|
||||
User,
|
||||
)
|
||||
from app.utils.message_patch import caption_exceeds_telegram_limit
|
||||
from app.utils.timezone import format_local_datetime
|
||||
|
||||
|
||||
@@ -1915,7 +1916,7 @@ class AdminNotificationService:
|
||||
keyboard: types.InlineKeyboardMarkup | None = None,
|
||||
) -> bool:
|
||||
"""Отправить фото с текстом в тикет-топик.
|
||||
Если текст <= 1024 символов — отправляем фото с caption.
|
||||
Если текст помещается в caption (≤1024 символов после парсинга HTML) — фото с caption.
|
||||
Иначе — сначала текст, потом фото в тот же топик.
|
||||
"""
|
||||
if not self.chat_id:
|
||||
@@ -1924,7 +1925,7 @@ class AdminNotificationService:
|
||||
thread_id = self.ticket_topic_id or self.topic_id
|
||||
|
||||
try:
|
||||
if len(text) <= 1024:
|
||||
if not caption_exceeds_telegram_limit(text):
|
||||
# Фото с caption — всё в одном сообщении
|
||||
photo_kwargs: dict = {
|
||||
'chat_id': self.chat_id,
|
||||
|
||||
@@ -58,6 +58,7 @@ from app.services.notification_settings_service import NotificationSettingsServi
|
||||
from app.services.promo_offer_service import promo_offer_service
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.cache import cache
|
||||
from app.utils.message_patch import caption_exceeds_telegram_limit
|
||||
from app.utils.miniapp_buttons import build_miniapp_or_callback_button
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
from app.utils.subscription_utils import (
|
||||
@@ -110,7 +111,7 @@ class MonitoringService:
|
||||
logger.debug('Пропуск уведомления: пользователь недоступен', user_id=user.id, status=user.status)
|
||||
return None
|
||||
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and (text is None or len(text) <= 1000):
|
||||
if settings.ENABLE_LOGO_MODE and LOGO_PATH.exists() and not caption_exceeds_telegram_limit(text):
|
||||
try:
|
||||
from app.utils.message_patch import _cache_logo_file_id, get_logo_media
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ class CloudPaymentsPaymentMixin:
|
||||
language: str | None = None,
|
||||
email: str | None = None,
|
||||
return_url: str | None = None,
|
||||
failed_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
"""
|
||||
Create a CloudPayments payment and return payment link info.
|
||||
@@ -80,6 +81,7 @@ class CloudPaymentsPaymentMixin:
|
||||
description=description,
|
||||
email=email,
|
||||
success_redirect_url=return_url,
|
||||
fail_redirect_url=failed_url,
|
||||
)
|
||||
except CloudPaymentsAPIError as error:
|
||||
logger.error('Ошибка создания CloudPayments платежа', error=error)
|
||||
|
||||
@@ -306,7 +306,37 @@ async def send_cart_notification_after_topup(
|
||||
from aiogram import types
|
||||
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.services.subscription_auto_purchase_service import auto_purchase_saved_cart_after_topup
|
||||
from app.services.subscription_auto_purchase_service import (
|
||||
auto_purchase_saved_cart_after_topup,
|
||||
try_auto_extend_expired_after_topup,
|
||||
try_resume_disabled_daily_after_topup,
|
||||
)
|
||||
|
||||
# Try to resume DISABLED daily subscription immediately (highest priority)
|
||||
try:
|
||||
daily_resumed = await try_resume_disabled_daily_after_topup(db, user, bot=bot)
|
||||
if daily_resumed:
|
||||
return False
|
||||
except Exception as daily_error:
|
||||
logger.error(
|
||||
'Ошибка авто-возобновления суточной подписки после пополнения',
|
||||
user_id=user.id,
|
||||
error=daily_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Try to auto-extend expired subscription (works without cart)
|
||||
try:
|
||||
auto_extended = await try_auto_extend_expired_after_topup(db, user, bot=bot)
|
||||
if auto_extended:
|
||||
return False
|
||||
except Exception as extend_error:
|
||||
logger.error(
|
||||
'Ошибка автопродления истёкшей подписки после пополнения',
|
||||
user_id=user.id,
|
||||
error=extend_error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
cart_data = await user_cart_service.get_user_cart(user.id)
|
||||
if not cart_data:
|
||||
|
||||
@@ -28,6 +28,7 @@ class HeleketPaymentMixin:
|
||||
*,
|
||||
language: str | None = None,
|
||||
return_url: str | None = None,
|
||||
success_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not getattr(self, 'heleket_service', None):
|
||||
logger.error('Heleket сервис не инициализирован')
|
||||
@@ -72,7 +73,7 @@ class HeleketPaymentMixin:
|
||||
payload['url_callback'] = callback_url
|
||||
|
||||
effective_return = return_url or settings.HELEKET_RETURN_URL
|
||||
effective_success = return_url or settings.HELEKET_SUCCESS_URL
|
||||
effective_success = success_url or return_url or settings.HELEKET_SUCCESS_URL
|
||||
if effective_return:
|
||||
payload['url_return'] = effective_return
|
||||
if effective_success:
|
||||
|
||||
@@ -33,6 +33,7 @@ class PlategaPaymentMixin:
|
||||
language: str,
|
||||
payment_method_code: int,
|
||||
return_url: str | None = None,
|
||||
failed_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
service: PlategaService | None = getattr(self, 'platega_service', None)
|
||||
if not service or not service.is_configured:
|
||||
@@ -61,6 +62,7 @@ class PlategaPaymentMixin:
|
||||
amount_value = amount_kopeks / 100
|
||||
|
||||
effective_return_url = return_url or settings.get_platega_return_url()
|
||||
effective_failed_url = failed_url or settings.get_platega_failed_url()
|
||||
|
||||
try:
|
||||
response = await service.create_payment(
|
||||
@@ -69,7 +71,7 @@ class PlategaPaymentMixin:
|
||||
currency=settings.PLATEGA_CURRENCY,
|
||||
description=description,
|
||||
return_url=effective_return_url,
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
failed_url=effective_failed_url,
|
||||
payload=payload_token,
|
||||
)
|
||||
except Exception as error: # pragma: no cover - network errors
|
||||
@@ -105,7 +107,7 @@ class PlategaPaymentMixin:
|
||||
platega_transaction_id=transaction_id,
|
||||
redirect_url=redirect_url,
|
||||
return_url=effective_return_url,
|
||||
failed_url=settings.get_platega_failed_url(),
|
||||
failed_url=effective_failed_url,
|
||||
payload=payload_token,
|
||||
metadata=metadata,
|
||||
expires_at=expires_at,
|
||||
|
||||
@@ -74,6 +74,7 @@ class WataPaymentMixin:
|
||||
*,
|
||||
language: str | None = None,
|
||||
return_url: str | None = None,
|
||||
failed_url: str | None = None,
|
||||
) -> dict[str, Any] | None:
|
||||
if not getattr(self, 'wata_service', None):
|
||||
logger.error('WATA service is not initialised')
|
||||
@@ -120,6 +121,7 @@ class WataPaymentMixin:
|
||||
description=description,
|
||||
order_id=order_id,
|
||||
success_url=return_url,
|
||||
fail_url=failed_url,
|
||||
)
|
||||
except WataAPIError as error:
|
||||
logger.error('Ошибка создания WATA платежа', error=error)
|
||||
|
||||
@@ -15,7 +15,7 @@ from app.config import settings
|
||||
from app.database.crud.subscription import extend_subscription
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import get_user_by_id, subtract_user_balance
|
||||
from app.database.models import Subscription, TransactionType, User
|
||||
from app.database.models import Subscription, SubscriptionStatus, TransactionType, User
|
||||
from app.localization.texts import get_texts
|
||||
from app.services.admin_notification_service import AdminNotificationService
|
||||
from app.services.subscription_checkout_service import clear_subscription_checkout_draft
|
||||
@@ -1241,17 +1241,41 @@ async def _auto_add_devices(
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
# Load tariff for device price and max limit
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
if tariff and tariff.device_price_kopeks is not None:
|
||||
tariff_device_price = tariff.device_price_kopeks
|
||||
tariff_max_device_limit = tariff.max_device_limit
|
||||
else:
|
||||
tariff_device_price = settings.PRICE_PER_DEVICE
|
||||
tariff_max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
|
||||
# Block purchase if device price is 0 or negative (purchase unavailable for this tariff)
|
||||
if not tariff_device_price or tariff_device_price <= 0:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка устройств: докупка устройств недоступна для тарифа, корзина удалена',
|
||||
format_user_id=_format_user_id(user),
|
||||
tariff_id=subscription.tariff_id,
|
||||
tariff_device_price=tariff_device_price,
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
|
||||
# Check max device limit before charging
|
||||
old_device_limit = subscription.device_limit or 1
|
||||
new_device_limit = old_device_limit + devices_to_add
|
||||
max_devices = settings.MAX_DEVICES_LIMIT
|
||||
if max_devices > 0 and new_device_limit > max_devices:
|
||||
if tariff_max_device_limit and new_device_limit > tariff_max_device_limit:
|
||||
logger.warning(
|
||||
'🔁 Автопокупка устройств: превышен лимит устройств',
|
||||
format_user_id=_format_user_id(user),
|
||||
current=old_device_limit,
|
||||
requested=new_device_limit,
|
||||
max_devices=max_devices,
|
||||
tariff_max_device_limit=tariff_max_device_limit,
|
||||
)
|
||||
await user_cart_service.delete_user_cart(user.id)
|
||||
return False
|
||||
@@ -1293,7 +1317,7 @@ async def _auto_add_devices(
|
||||
old_device_limit = subscription.device_limit or 1
|
||||
new_device_limit = old_device_limit + devices_to_add
|
||||
|
||||
if max_devices > 0 and new_device_limit > max_devices:
|
||||
if tariff_max_device_limit and new_device_limit > tariff_max_device_limit:
|
||||
# Concurrent modification exceeded limit — refund
|
||||
user_refund = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
@@ -1641,6 +1665,577 @@ async def _auto_add_traffic(
|
||||
return True
|
||||
|
||||
|
||||
async def try_auto_extend_expired_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Try to auto-extend an expired subscription after balance top-up.
|
||||
|
||||
Unlike cart-based auto-purchase, this works without a saved cart.
|
||||
It finds the user's expired subscription and attempts to extend it
|
||||
with the shortest available period if the balance is sufficient.
|
||||
|
||||
Returns True if the subscription was successfully extended.
|
||||
"""
|
||||
from app.cabinet.routes.websocket import notify_user_subscription_renewed
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.database.crud.transaction import get_user_transactions
|
||||
|
||||
if not user or not getattr(user, 'id', None):
|
||||
return False
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription is None:
|
||||
logger.debug(
|
||||
'🔄 Автопродление expired: у пользователя нет подписки',
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return False
|
||||
|
||||
# Only process expired subscriptions (not trial, not disabled)
|
||||
if subscription.status != SubscriptionStatus.EXPIRED.value:
|
||||
return False
|
||||
if subscription.is_trial:
|
||||
return False
|
||||
|
||||
# Only process subscriptions expired within the last 30 days
|
||||
if subscription.end_date is None:
|
||||
return False
|
||||
expired_delta = datetime.now(UTC) - subscription.end_date
|
||||
if expired_delta.days > 30:
|
||||
logger.info(
|
||||
'🔄 Автопродление expired: подписка истекла более 30 дней назад',
|
||||
format_user_id=_format_user_id(user),
|
||||
expired_days=expired_delta.days,
|
||||
)
|
||||
return False
|
||||
|
||||
# Determine renewal period from tariff or default to 30 days
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if tariff:
|
||||
period_days = tariff.get_shortest_period() or 30
|
||||
else:
|
||||
period_days = 30
|
||||
|
||||
# Calculate renewal price
|
||||
subscription_service = SubscriptionService()
|
||||
try:
|
||||
renewal_cost = await subscription_service.calculate_renewal_price(
|
||||
subscription,
|
||||
period_days,
|
||||
db,
|
||||
user=user,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопродление expired: ошибка расчёта стоимости',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if renewal_cost <= 0:
|
||||
logger.warning(
|
||||
'❌ Автопродление expired: некорректная стоимость',
|
||||
format_user_id=_format_user_id(user),
|
||||
renewal_cost=renewal_cost,
|
||||
)
|
||||
return False
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < renewal_cost:
|
||||
logger.info(
|
||||
'🔄 Автопродление expired: недостаточно средств',
|
||||
format_user_id=_format_user_id(user),
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
renewal_cost=renewal_cost,
|
||||
)
|
||||
return False
|
||||
|
||||
# Race condition guard: skip if a subscription payment was made in the last 60 seconds
|
||||
try:
|
||||
recent_transactions = await get_user_transactions(db, user.id, limit=1)
|
||||
if recent_transactions:
|
||||
last_tx = recent_transactions[0]
|
||||
if (
|
||||
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
|
||||
and last_tx.created_at
|
||||
and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
|
||||
):
|
||||
logger.info(
|
||||
'🔄 Автопродление expired: пропуск — подписка оплачена секунд назад',
|
||||
format_user_id=_format_user_id(user),
|
||||
total_seconds=(datetime.now(UTC) - last_tx.created_at).total_seconds(),
|
||||
)
|
||||
return False
|
||||
except Exception as check_error:
|
||||
logger.warning(
|
||||
'🔄 Автопродление expired: ошибка проверки последней транзакции',
|
||||
format_user_id=_format_user_id(user),
|
||||
check_error=check_error,
|
||||
)
|
||||
|
||||
# Determine if promo offer discount was applied (for consume flag)
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
consume_promo_offer = get_user_active_promo_discount_percent(user) > 0
|
||||
|
||||
# Deduct balance
|
||||
description = f'Автопродление истёкшей подписки на {period_days} дней'
|
||||
try:
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
renewal_cost,
|
||||
description,
|
||||
consume_promo_offer=consume_promo_offer,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопродление expired: ошибка списания средств',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not deducted:
|
||||
logger.warning(
|
||||
'❌ Автопродление expired: списание средств не выполнено',
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return False
|
||||
|
||||
old_end_date = subscription.end_date
|
||||
was_trial = subscription.is_trial
|
||||
|
||||
# Extend subscription
|
||||
try:
|
||||
updated_subscription = await extend_subscription(db, subscription, period_days)
|
||||
|
||||
# Convert trial to paid if needed
|
||||
if was_trial and subscription.is_trial:
|
||||
subscription.is_trial = False
|
||||
subscription.status = 'active'
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'✅ Триал конвертирован в платную подписку (автопродление expired)',
|
||||
subscription_id=subscription.id,
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Автопродление expired: не удалось продлить подписку',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Create transaction record
|
||||
transaction = None
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=renewal_cost,
|
||||
description=description,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Автопродление expired: не удалось зафиксировать транзакцию',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
# Update RemnaWave
|
||||
try:
|
||||
await subscription_service.update_remnawave_user(
|
||||
db,
|
||||
updated_subscription,
|
||||
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
|
||||
reset_reason='автопродление истёкшей подписки',
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Автопродление expired: не удалось обновить RemnaWave',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
period_label = format_period_description(period_days, getattr(user, 'language', 'ru'))
|
||||
new_end_date = updated_subscription.end_date
|
||||
end_date_label = format_local_datetime(new_end_date, '%d.%m.%Y %H:%M')
|
||||
|
||||
# Admin notification
|
||||
try:
|
||||
from app.services.subscription_renewal_service import with_admin_notification_service
|
||||
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
updated_subscription,
|
||||
transaction,
|
||||
period_days,
|
||||
old_end_date,
|
||||
new_end_date=new_end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Автопродление expired: не удалось уведомить администраторов',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Send user notification (only for Telegram users)
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
auto_message = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED',
|
||||
'✅ Subscription automatically extended for {period}.',
|
||||
).format(period=period_label)
|
||||
details_message = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_EXTENDED_DETAILS',
|
||||
'New expiration date: {date}.',
|
||||
).format(date=end_date_label)
|
||||
hint_message = texts.t(
|
||||
'AUTO_PURCHASE_SUBSCRIPTION_HINT',
|
||||
"Open the 'My subscription' section to access your link.",
|
||||
)
|
||||
|
||||
full_message = '\n\n'.join(
|
||||
part.strip() for part in [auto_message, details_message, hint_message] if part and part.strip()
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=full_message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Автопродление expired: не удалось уведомить пользователя',
|
||||
telegram_id=user.telegram_id or user.id,
|
||||
error=error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Автопродление expired: подписка продлена для пользователя',
|
||||
period_days=period_days,
|
||||
renewal_cost=renewal_cost,
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
|
||||
# Send WebSocket notification
|
||||
try:
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=new_end_date.isoformat() if new_end_date else '',
|
||||
amount_kopeks=renewal_cost,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Автопродление expired: не удалось отправить WS уведомление',
|
||||
format_user_id=_format_user_id(user),
|
||||
ws_error=ws_error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def try_resume_disabled_daily_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
*,
|
||||
bot: Bot | None = None,
|
||||
) -> bool:
|
||||
"""Resume a DISABLED daily subscription immediately after balance top-up.
|
||||
|
||||
Daily subscriptions get DISABLED when balance is insufficient.
|
||||
The DailySubscriptionService loop picks them up every 30 minutes,
|
||||
but this function provides instant resumption right when the user tops up.
|
||||
|
||||
Returns True if the subscription was successfully resumed and charged.
|
||||
"""
|
||||
from app.cabinet.routes.websocket import notify_user_subscription_renewed
|
||||
from app.database.crud.subscription import get_subscription_by_user_id, update_daily_charge_time
|
||||
|
||||
if not user or not getattr(user, 'id', None):
|
||||
return False
|
||||
|
||||
subscription = await get_subscription_by_user_id(db, user.id)
|
||||
if subscription is None:
|
||||
return False
|
||||
|
||||
# Only handle DISABLED (or EXPIRED) daily tariff subscriptions
|
||||
if subscription.status not in (SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value):
|
||||
return False
|
||||
if not getattr(subscription, 'is_daily_tariff', False):
|
||||
return False
|
||||
if subscription.is_trial:
|
||||
return False
|
||||
# Skip user-paused subscriptions — they chose to pause, don't auto-resume
|
||||
if getattr(subscription, 'is_daily_paused', False):
|
||||
return False
|
||||
|
||||
tariff = getattr(subscription, 'tariff', None)
|
||||
if not tariff:
|
||||
return False
|
||||
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price <= 0:
|
||||
return False
|
||||
|
||||
# Check balance
|
||||
if user.balance_kopeks < daily_price:
|
||||
logger.info(
|
||||
'🔄 Авто-возобновление daily: недостаточно средств',
|
||||
format_user_id=_format_user_id(user),
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
daily_price=daily_price,
|
||||
)
|
||||
return False
|
||||
|
||||
# Race condition guard: skip if a subscription payment was made in the last 60 seconds
|
||||
from app.database.crud.transaction import get_user_transactions
|
||||
|
||||
try:
|
||||
recent_transactions = await get_user_transactions(db, user.id, limit=1)
|
||||
if recent_transactions:
|
||||
last_tx = recent_transactions[0]
|
||||
if (
|
||||
last_tx.type == TransactionType.SUBSCRIPTION_PAYMENT
|
||||
and last_tx.created_at
|
||||
and (datetime.now(UTC) - last_tx.created_at) < timedelta(seconds=60)
|
||||
):
|
||||
logger.info(
|
||||
'🔄 Авто-возобновление daily: пропуск — оплата секунд назад',
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return False
|
||||
except Exception as check_error:
|
||||
logger.warning(
|
||||
'🔄 Авто-возобновление daily: ошибка проверки последней транзакции',
|
||||
format_user_id=_format_user_id(user),
|
||||
check_error=check_error,
|
||||
)
|
||||
|
||||
# Deduct daily price FIRST (before changing status to avoid free-access window)
|
||||
previous_status = subscription.status
|
||||
description = f'Суточная оплата тарифа «{tariff.name}» (авто-возобновление)'
|
||||
try:
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
description,
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Авто-возобновление daily: ошибка списания средств',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
return False
|
||||
|
||||
if not deducted:
|
||||
logger.warning(
|
||||
'❌ Авто-возобновление daily: списание не выполнено',
|
||||
format_user_id=_format_user_id(user),
|
||||
)
|
||||
return False
|
||||
|
||||
# Activate the subscription (balance already deducted)
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
try:
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'❌ Авто-возобновление daily: ошибка активации подписки',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
exc_info=True,
|
||||
)
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
'✅ Авто-возобновление daily: подписка → ACTIVE после пополнения',
|
||||
format_user_id=_format_user_id(user),
|
||||
previous_status=previous_status,
|
||||
subscription_id=subscription.id,
|
||||
)
|
||||
|
||||
# Create transaction
|
||||
transaction = None
|
||||
try:
|
||||
transaction = await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=description,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Авто-возобновление daily: не удалось создать транзакцию',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Update charge time and end_date (+24h)
|
||||
old_end_date = subscription.end_date
|
||||
try:
|
||||
subscription = await update_daily_charge_time(db, subscription)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Авто-возобновление daily: не удалось обновить время списания',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Sync with RemnaWave
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Авто-возобновление daily: не удалось обновить RemnaWave',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Admin notification
|
||||
try:
|
||||
from app.services.subscription_renewal_service import with_admin_notification_service
|
||||
|
||||
await with_admin_notification_service(
|
||||
lambda svc: svc.send_subscription_extension_notification(
|
||||
db,
|
||||
user,
|
||||
subscription,
|
||||
transaction,
|
||||
1,
|
||||
old_end_date,
|
||||
new_end_date=subscription.end_date,
|
||||
balance_after=user.balance_kopeks,
|
||||
)
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Авто-возобновление daily: не удалось уведомить администраторов',
|
||||
format_user_id=_format_user_id(user),
|
||||
error=error,
|
||||
)
|
||||
|
||||
# User notification
|
||||
if bot and user.telegram_id:
|
||||
try:
|
||||
texts = get_texts(getattr(user, 'language', 'ru'))
|
||||
|
||||
message = texts.t(
|
||||
'DAILY_SUBSCRIPTION_RESUMED_AFTER_TOPUP',
|
||||
'✅ <b>Подписка возобновлена!</b>\n\n'
|
||||
'Ваш суточный тариф «{tariff_name}» возобновлён после пополнения баланса.\n\n'
|
||||
'💳 Списано: {amount}\n'
|
||||
'💰 Остаток: {balance}',
|
||||
).format(
|
||||
tariff_name=tariff.name,
|
||||
amount=settings.format_price(daily_price),
|
||||
balance=settings.format_price(user.balance_kopeks),
|
||||
)
|
||||
|
||||
keyboard = InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('MY_SUBSCRIPTION_BUTTON', '📱 My subscription'),
|
||||
callback_data='menu_subscription',
|
||||
)
|
||||
],
|
||||
[
|
||||
InlineKeyboardButton(
|
||||
text=texts.t('BACK_TO_MAIN_MENU_BUTTON', '🏠 Main menu'),
|
||||
callback_data='back_to_menu',
|
||||
)
|
||||
],
|
||||
]
|
||||
)
|
||||
|
||||
await bot.send_message(
|
||||
chat_id=user.telegram_id,
|
||||
text=message,
|
||||
reply_markup=keyboard,
|
||||
parse_mode='HTML',
|
||||
)
|
||||
except Exception as error:
|
||||
logger.error(
|
||||
'⚠️ Авто-возобновление daily: не удалось уведомить пользователя',
|
||||
telegram_id=user.telegram_id or user.id,
|
||||
error=error,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'✅ Авто-возобновление daily: подписка возобновлена для пользователя',
|
||||
format_user_id=_format_user_id(user),
|
||||
daily_price=daily_price,
|
||||
tariff_name=tariff.name,
|
||||
)
|
||||
|
||||
# WebSocket notification
|
||||
try:
|
||||
await notify_user_subscription_renewed(
|
||||
user_id=user.id,
|
||||
new_expires_at=subscription.end_date.isoformat() if subscription.end_date else '',
|
||||
amount_kopeks=daily_price,
|
||||
)
|
||||
except Exception as ws_error:
|
||||
logger.warning(
|
||||
'⚠️ Авто-возобновление daily: не удалось отправить WS уведомление',
|
||||
format_user_id=_format_user_id(user),
|
||||
ws_error=ws_error,
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def auto_purchase_saved_cart_after_topup(
|
||||
db: AsyncSession,
|
||||
user: User,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, User
|
||||
from app.external.remnawave_api import RemnaWaveAPI, RemnaWaveAPIError, RemnaWaveUser, TrafficLimitStrategy, UserStatus
|
||||
@@ -93,6 +97,15 @@ def get_traffic_reset_strategy(tariff=None):
|
||||
return getattr(TrafficLimitStrategy, mapped_strategy)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PropagateSquadsResult:
|
||||
"""Результат применения скводов тарифа к подпискам."""
|
||||
|
||||
total: int = 0
|
||||
synced: int = 0
|
||||
failed_ids: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
class SubscriptionService:
|
||||
def __init__(self):
|
||||
self._config_error: str | None = None
|
||||
@@ -1485,3 +1498,154 @@ class SubscriptionService:
|
||||
if bytes_value == 0:
|
||||
return 0.0
|
||||
return bytes_value / (1024 * 1024 * 1024)
|
||||
|
||||
async def propagate_tariff_squads(
|
||||
self, db: AsyncSession, tariff_id: int, new_squads: list[str], *, concurrency: int = 5
|
||||
) -> PropagateSquadsResult:
|
||||
"""Применяет изменение серверов тарифа к активным подпискам и синхронизирует с RemnaWave.
|
||||
|
||||
Если new_squads пустой — означает "все серверы", будут подставлены все доступные.
|
||||
Синхронизация с RemnaWave выполняется параллельно с ограничением concurrency.
|
||||
Паттерн: предзагрузка данных → параллельные API-вызовы → один commit.
|
||||
"""
|
||||
squads_to_set = list(new_squads)
|
||||
if not squads_to_set:
|
||||
all_servers, _ = await get_all_server_squads(db, available_only=True, limit=10000)
|
||||
squads_to_set = [s.squad_uuid for s in all_servers if s.squad_uuid]
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription).where(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
)
|
||||
)
|
||||
subscriptions = result.scalars().all()
|
||||
|
||||
if not subscriptions:
|
||||
return PropagateSquadsResult(total=0, synced=0)
|
||||
|
||||
for sub in subscriptions:
|
||||
sub.connected_squads = squads_to_set
|
||||
await db.commit()
|
||||
|
||||
# Предзагружаем пользователей и тарифы — никаких DB-операций внутри gather
|
||||
user_ids = [sub.user_id for sub in subscriptions]
|
||||
users_result = await db.execute(select(User).where(User.id.in_(user_ids)))
|
||||
users_map = {u.id: u for u in users_result.scalars().all()}
|
||||
|
||||
for sub in subscriptions:
|
||||
try:
|
||||
await db.refresh(sub, ['tariff'])
|
||||
except Exception as exc:
|
||||
logger.warning('Не удалось предзагрузить тариф подписки', subscription_id=sub.id, error=exc)
|
||||
|
||||
# Вычисляем стратегию сброса трафика один раз — все подписки одного тарифа
|
||||
sample_tariff = subscriptions[0].tariff if subscriptions[0].tariff else None
|
||||
traffic_strategy = get_traffic_reset_strategy(sample_tariff)
|
||||
|
||||
# Параллельная синхронизация: один API-клиент, только HTTP-вызовы внутри gather
|
||||
failed_ids: list[int] = []
|
||||
synced = 0
|
||||
|
||||
async with self.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> bool:
|
||||
async with semaphore:
|
||||
try:
|
||||
user = users_map.get(sub.user_id)
|
||||
if not user or not user.remnawave_uuid:
|
||||
return False
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
is_actually_active = (
|
||||
sub.status == SubscriptionStatus.ACTIVE.value and sub.end_date > current_time
|
||||
)
|
||||
|
||||
user_tag = self._resolve_user_tag(sub)
|
||||
ext_squad_uuid = sub.tariff.external_squad_uuid if sub.tariff else None
|
||||
hwid_limit = resolve_hwid_device_limit_for_payload(sub)
|
||||
|
||||
update_kwargs = dict(
|
||||
uuid=user.remnawave_uuid,
|
||||
status=UserStatus.ACTIVE if is_actually_active else UserStatus.EXPIRED,
|
||||
expire_at=sub.end_date,
|
||||
traffic_limit_bytes=self._gb_to_bytes(sub.traffic_limit_gb),
|
||||
traffic_limit_strategy=traffic_strategy,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
description=settings.format_remnawave_user_description(
|
||||
full_name=user.full_name,
|
||||
username=user.username,
|
||||
telegram_id=user.telegram_id,
|
||||
email=user.email,
|
||||
user_id=user.id,
|
||||
),
|
||||
)
|
||||
|
||||
if sub.connected_squads:
|
||||
update_kwargs['active_internal_squads'] = sub.connected_squads
|
||||
|
||||
if user_tag is not None:
|
||||
update_kwargs['tag'] = user_tag
|
||||
|
||||
if hwid_limit is not None:
|
||||
update_kwargs['hwid_device_limit'] = hwid_limit
|
||||
|
||||
if ext_squad_uuid is not None:
|
||||
update_kwargs['external_squad_uuid'] = ext_squad_uuid
|
||||
else:
|
||||
update_kwargs['external_squad_uuid'] = None
|
||||
|
||||
updated_user = await api.update_user(**update_kwargs)
|
||||
|
||||
# Сохраняем в памяти — commit будет после gather
|
||||
sub.subscription_url = updated_user.subscription_url
|
||||
sub.subscription_crypto_link = updated_user.happ_crypto_link
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Не удалось обновить сквады в RemnaWave',
|
||||
subscription_id=sub.id,
|
||||
user_id=sub.user_id,
|
||||
error=e,
|
||||
)
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
for i, success in enumerate(results):
|
||||
if success:
|
||||
synced += 1
|
||||
else:
|
||||
failed_ids.append(subscriptions[i].id)
|
||||
|
||||
# Один commit после всех API-вызовов
|
||||
try:
|
||||
await db.commit()
|
||||
except Exception as commit_error:
|
||||
logger.error('Ошибка фиксации транзакции при синхронизации скводов', error=commit_error)
|
||||
await db.rollback()
|
||||
failed_ids = [sub.id for sub in subscriptions]
|
||||
synced = 0
|
||||
|
||||
propagate_result = PropagateSquadsResult(total=len(subscriptions), synced=synced, failed_ids=failed_ids)
|
||||
|
||||
if failed_ids:
|
||||
logger.warning(
|
||||
'Частичная синхронизация скводов с RemnaWave',
|
||||
tariff_id=tariff_id,
|
||||
total=propagate_result.total,
|
||||
synced=synced,
|
||||
failed_ids=failed_ids,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
'Обновлены сквады подписок для тарифа',
|
||||
tariff_id=tariff_id,
|
||||
total=propagate_result.total,
|
||||
synced=synced,
|
||||
)
|
||||
|
||||
return propagate_result
|
||||
|
||||
@@ -275,27 +275,30 @@ class TributeService:
|
||||
|
||||
async for session in get_db():
|
||||
user = await get_user_by_telegram_id(session, user_id)
|
||||
if not user:
|
||||
logger.warning('Пользователь не найден для уведомления Tribute', user_id=user_id)
|
||||
break
|
||||
|
||||
# Сначала отправляем стандартное уведомление
|
||||
payment_service = PaymentService(self.bot)
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
text = (
|
||||
f'✅ **Платеж успешно получен!**\n\n'
|
||||
f'💰 Сумма: {int(amount_rubles)} ₽\n'
|
||||
f'💳 Способ оплаты: Tribute\n'
|
||||
f'🎉 Средства зачислены на баланс!\n\n'
|
||||
f'Спасибо за оплату! 🙏'
|
||||
)
|
||||
|
||||
await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown')
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, amount_kopeks, session, self.bot)
|
||||
break
|
||||
|
||||
# Сначала отправляем стандартное уведомление
|
||||
payment_service = PaymentService(self.bot)
|
||||
keyboard = await payment_service.build_topup_success_keyboard(user)
|
||||
|
||||
text = (
|
||||
f'✅ **Платеж успешно получен!**\n\n'
|
||||
f'💰 Сумма: {int(amount_rubles)} ₽\n'
|
||||
f'💳 Способ оплаты: Tribute\n'
|
||||
f'🎉 Средства зачислены на баланс!\n\n'
|
||||
f'Спасибо за оплату! 🙏'
|
||||
)
|
||||
|
||||
await self.bot.send_message(user_id, text, reply_markup=keyboard, parse_mode='Markdown')
|
||||
|
||||
# Проверяем наличие сохраненной корзины для возврата к оформлению подписки
|
||||
from app.services.payment.common import send_cart_notification_after_topup
|
||||
|
||||
await send_cart_notification_after_topup(user, amount_kopeks, session, self.bot)
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Ошибка отправки уведомления об успешном платеже', error=e)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -62,6 +63,15 @@ from app.services.notification_delivery_service import (
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeleteUserResult:
|
||||
"""Результат удаления пользователя."""
|
||||
|
||||
bot_deleted: bool = False
|
||||
panel_deleted: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
class UserService:
|
||||
async def send_topup_success_to_user(
|
||||
self,
|
||||
@@ -735,12 +745,21 @@ class UserService:
|
||||
logger.error('Ошибка разблокировки пользователя', error=e)
|
||||
return False
|
||||
|
||||
async def delete_user_account(self, db: AsyncSession, user_id: int, admin_id: int) -> bool:
|
||||
async def delete_user_account(
|
||||
self, db: AsyncSession, user_id: int, admin_id: int, *, force_panel_delete: bool = False
|
||||
) -> DeleteUserResult:
|
||||
"""Полное удаление пользователя из бота и (опционально) из панели RemnaWave.
|
||||
|
||||
force_panel_delete=True: пропускает проверку активной подписки и принудительно
|
||||
удаляет (не деактивирует) пользователя из панели RemnaWave. Используется
|
||||
при полном удалении через кабинет администратора.
|
||||
"""
|
||||
result = DeleteUserResult()
|
||||
try:
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if not user:
|
||||
logger.warning('Пользователь не найден для удаления', user_id=user_id)
|
||||
return False
|
||||
return result
|
||||
|
||||
user_id_display = user.telegram_id or user.email or f'#{user.id}'
|
||||
logger.info(
|
||||
@@ -751,14 +770,14 @@ class UserService:
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
if is_active_paid_subscription(user.subscription):
|
||||
if not force_panel_delete and is_active_paid_subscription(user.subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск отключения RemnaWave при удалении: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
else:
|
||||
delete_mode = settings.get_remnawave_user_delete_mode()
|
||||
delete_mode = 'delete' if force_panel_delete else settings.get_remnawave_user_delete_mode()
|
||||
|
||||
try:
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
@@ -770,11 +789,13 @@ class UserService:
|
||||
async with remnawave_service.get_api_client() as api:
|
||||
delete_success = await api.delete_user(user.remnawave_uuid)
|
||||
if delete_success:
|
||||
result.panel_deleted = True
|
||||
logger.info(
|
||||
'✅ RemnaWave пользователь удален из панели',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
else:
|
||||
result.panel_error = 'Remnawave API вернул ошибку удаления'
|
||||
logger.warning(
|
||||
'⚠️ Не удалось удалить пользователя из панели Remnawave',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
@@ -784,14 +805,24 @@ class UserService:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(
|
||||
'✅ RemnaWave пользователь деактивирован (режим: )',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
delete_mode=delete_mode,
|
||||
)
|
||||
disabled = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
result.panel_deleted = disabled
|
||||
if disabled:
|
||||
logger.info(
|
||||
'✅ RemnaWave пользователь деактивирован',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
delete_mode=delete_mode,
|
||||
)
|
||||
else:
|
||||
result.panel_error = 'disable_remnawave_user вернул False'
|
||||
logger.warning(
|
||||
'⚠️ Не удалось деактивировать пользователя в RemnaWave',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
delete_mode=delete_mode,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
result.panel_error = 'Ошибка обработки пользователя в Remnawave'
|
||||
logger.warning(
|
||||
'⚠️ Ошибка обработки пользователя в Remnawave (режим: )',
|
||||
delete_mode=delete_mode,
|
||||
@@ -803,11 +834,19 @@ class UserService:
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
logger.info(
|
||||
'✅ RemnaWave пользователь деактивирован как fallback',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
disabled = await subscription_service.disable_remnawave_user(user.remnawave_uuid)
|
||||
if disabled:
|
||||
result.panel_deleted = True
|
||||
result.panel_error = 'Удаление не удалось, пользователь деактивирован'
|
||||
logger.info(
|
||||
'✅ RemnaWave пользователь деактивирован как fallback',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
'⚠️ Fallback деактивация RemnaWave тоже не удалась',
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
except Exception as fallback_e:
|
||||
logger.error('❌ Ошибка деактивации RemnaWave как fallback', fallback_e=fallback_e)
|
||||
|
||||
@@ -1225,20 +1264,21 @@ class UserService:
|
||||
except Exception as e:
|
||||
logger.error('❌ Ошибка финального удаления пользователя', error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
return result
|
||||
|
||||
result.bot_deleted = True
|
||||
logger.info(
|
||||
'✅ Пользователь (ID: ) полностью удален администратором',
|
||||
user_id_display=user_id_display,
|
||||
user_id=user_id,
|
||||
admin_id=admin_id,
|
||||
)
|
||||
return True
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error('❌ Критическая ошибка удаления пользователя', user_id=user_id, error=e)
|
||||
await db.rollback()
|
||||
return False
|
||||
return result
|
||||
|
||||
async def get_user_statistics(self, db: AsyncSession) -> dict[str, Any]:
|
||||
try:
|
||||
@@ -1276,8 +1316,8 @@ class UserService:
|
||||
skipped_active_sub += 1
|
||||
continue
|
||||
|
||||
success = await self.delete_user_account(db, user.id, 0)
|
||||
if success:
|
||||
delete_result = await self.delete_user_account(db, user.id, 0)
|
||||
if delete_result.bot_deleted:
|
||||
deleted_count += 1
|
||||
|
||||
if skipped_active_sub > 0:
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import html as html_module
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -9,6 +11,20 @@ from app.localization.texts import get_texts
|
||||
|
||||
|
||||
LOGO_PATH = Path(settings.LOGO_FILE)
|
||||
|
||||
# Telegram API: caption limit is 1024 characters AFTER HTML entity parsing (tags stripped)
|
||||
TELEGRAM_CAPTION_LIMIT = 1024
|
||||
_HTML_TAG_RE = re.compile(r'<[^>]+>')
|
||||
|
||||
|
||||
def caption_exceeds_telegram_limit(text: str | None) -> bool:
|
||||
"""Check if text exceeds Telegram's caption limit (1024 parsed chars)."""
|
||||
if not text:
|
||||
return False
|
||||
stripped = html_module.unescape(_HTML_TAG_RE.sub('', text))
|
||||
return len(stripped) > TELEGRAM_CAPTION_LIMIT
|
||||
|
||||
|
||||
_PRIVACY_RESTRICTED_CODE = 'BUTTON_USER_PRIVACY_RESTRICTED'
|
||||
|
||||
# Кеш file_id логотипа: после первой загрузки Telegram возвращает file_id,
|
||||
@@ -139,7 +155,7 @@ async def _answer_with_photo(self: Message, text: str = None, **kwargs):
|
||||
return await _original_answer(self, text, **kwargs)
|
||||
# Если caption слишком длинный для фото — отправим как текст
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
if caption_exceeds_telegram_limit(text):
|
||||
return await _text_answer(self, text, **kwargs)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -207,7 +223,7 @@ async def _edit_with_photo(self: Message, text: str, **kwargs):
|
||||
language = _get_language(self)
|
||||
# Если caption потенциально слишком длинный — отправим как текст вместо caption
|
||||
try:
|
||||
if text is not None and len(text) > 900:
|
||||
if caption_exceeds_telegram_limit(text):
|
||||
try:
|
||||
await self.delete()
|
||||
except Exception:
|
||||
|
||||
@@ -11,6 +11,7 @@ from .message_patch import (
|
||||
LOGO_PATH,
|
||||
_cache_logo_file_id,
|
||||
append_privacy_hint,
|
||||
caption_exceeds_telegram_limit,
|
||||
get_logo_media,
|
||||
is_privacy_restricted_error,
|
||||
is_qr_message,
|
||||
@@ -137,7 +138,7 @@ async def edit_or_answer_photo(
|
||||
return
|
||||
|
||||
# Если текст слишком длинный для caption — отправим как текст
|
||||
if caption and len(caption) > 1000:
|
||||
if caption_exceeds_telegram_limit(caption):
|
||||
try:
|
||||
if callback.message.photo:
|
||||
await callback.message.delete()
|
||||
|
||||
+111
-26
@@ -5024,14 +5024,29 @@ async def _build_subscription_settings(
|
||||
default_device_limit = max(settings.DEFAULT_DEVICE_LIMIT, 1)
|
||||
current_device_limit = int(subscription.device_limit or default_device_limit)
|
||||
|
||||
max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
# Load tariff for device price and max limit
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
# Determine device price and max limit from tariff or global settings
|
||||
if tariff and tariff.device_price_kopeks is not None:
|
||||
base_device_price = tariff.device_price_kopeks
|
||||
max_devices_setting = tariff.max_device_limit
|
||||
else:
|
||||
base_device_price = settings.PRICE_PER_DEVICE
|
||||
max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
|
||||
# If device price is 0 or negative, device purchase is unavailable
|
||||
devices_can_update = bool(base_device_price and base_device_price > 0)
|
||||
|
||||
if max_devices_setting is not None:
|
||||
max_devices = max(max_devices_setting, current_device_limit, default_device_limit)
|
||||
else:
|
||||
max_devices = max(current_device_limit, default_device_limit) + 10
|
||||
|
||||
discounted_single_device, _ = apply_percentage_discount(
|
||||
settings.PRICE_PER_DEVICE,
|
||||
base_device_price,
|
||||
devices_discount,
|
||||
)
|
||||
|
||||
@@ -5039,7 +5054,7 @@ async def _build_subscription_settings(
|
||||
for value in range(1, max_devices + 1):
|
||||
chargeable = max(0, value - default_device_limit)
|
||||
discounted_per_month, _ = apply_percentage_discount(
|
||||
chargeable * settings.PRICE_PER_DEVICE,
|
||||
chargeable * base_device_price,
|
||||
devices_discount,
|
||||
)
|
||||
devices_options.append(
|
||||
@@ -5074,7 +5089,7 @@ async def _build_subscription_settings(
|
||||
),
|
||||
devices=MiniAppSubscriptionDevicesSettings(
|
||||
options=devices_options,
|
||||
can_update=True,
|
||||
can_update=devices_can_update,
|
||||
min=1,
|
||||
max=max_devices_setting or 0,
|
||||
step=1,
|
||||
@@ -6102,12 +6117,32 @@ async def update_subscription_devices_endpoint(
|
||||
detail={'code': 'validation_error', 'message': 'Device limit must be positive'},
|
||||
)
|
||||
|
||||
if settings.MAX_DEVICES_LIMIT > 0 and new_devices > settings.MAX_DEVICES_LIMIT:
|
||||
# Load tariff for device price and max limit
|
||||
tariff = None
|
||||
if subscription.tariff_id:
|
||||
tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
|
||||
if tariff and tariff.device_price_kopeks is not None:
|
||||
tariff_device_price = tariff.device_price_kopeks
|
||||
tariff_max_device_limit = tariff.max_device_limit
|
||||
else:
|
||||
tariff_device_price = settings.PRICE_PER_DEVICE
|
||||
tariff_max_device_limit = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
|
||||
# Block purchase if device price is 0 (purchase unavailable for this tariff)
|
||||
if not tariff_device_price or tariff_device_price <= 0:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={'code': 'devices_unavailable', 'message': 'Докупка устройств недоступна'},
|
||||
)
|
||||
|
||||
# Enforce tariff max device limit
|
||||
if tariff_max_device_limit and new_devices > tariff_max_device_limit:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
'code': 'devices_limit_exceeded',
|
||||
'message': (f'Превышен максимальный лимит устройств ({settings.MAX_DEVICES_LIMIT})'),
|
||||
'message': f'Превышен максимальный лимит устройств ({tariff_max_device_limit})',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -6140,7 +6175,7 @@ async def update_subscription_devices_endpoint(
|
||||
new_chargeable = max(0, new_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||||
chargeable_diff = new_chargeable - current_chargeable
|
||||
|
||||
price_per_month = chargeable_diff * settings.PRICE_PER_DEVICE
|
||||
price_per_month = chargeable_diff * tariff_device_price
|
||||
months_remaining = get_remaining_months(subscription.end_date)
|
||||
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
|
||||
devices_discount = _get_addon_discount_percent_for_user(
|
||||
@@ -6206,9 +6241,8 @@ async def update_subscription_devices_endpoint(
|
||||
|
||||
actual_current = subscription.device_limit or 1
|
||||
actual_delta = new_devices - actual_current
|
||||
max_devices_limit = settings.MAX_DEVICES_LIMIT
|
||||
|
||||
if actual_delta <= 0 or (max_devices_limit > 0 and new_devices > max_devices_limit):
|
||||
if actual_delta <= 0 or (tariff_max_device_limit and new_devices > tariff_max_device_limit):
|
||||
# Concurrent request already applied the change or pushed limit beyond max — refund
|
||||
user_refund = await db.execute(
|
||||
select(User).where(User.id == user.id).with_for_update().execution_options(populate_existing=True)
|
||||
@@ -6228,7 +6262,7 @@ async def update_subscription_devices_endpoint(
|
||||
status.HTTP_409_CONFLICT,
|
||||
detail={
|
||||
'code': 'devices_limit_exceeded',
|
||||
'message': f'Превышен максимальный лимит устройств ({max_devices_limit}). Баланс возвращён.',
|
||||
'message': f'Превышен максимальный лимит устройств ({tariff_max_device_limit}). Баланс возвращён.',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -7337,14 +7371,26 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
detail={'code': 'not_daily_tariff', 'message': 'Subscription is not on a daily tariff'},
|
||||
)
|
||||
|
||||
# Переключаем состояние паузы
|
||||
# Определяем состояние
|
||||
from app.database.models import SubscriptionStatus
|
||||
|
||||
is_currently_paused = getattr(subscription, 'is_daily_paused', False)
|
||||
new_paused_state = not is_currently_paused
|
||||
was_disabled = subscription.status in (
|
||||
SubscriptionStatus.DISABLED.value,
|
||||
SubscriptionStatus.EXPIRED.value,
|
||||
)
|
||||
|
||||
# System-DISABLED subs (is_daily_paused=False) должны идти по пути resume
|
||||
if was_disabled and not is_currently_paused:
|
||||
new_paused_state = False # Force resume path
|
||||
else:
|
||||
new_paused_state = not is_currently_paused
|
||||
subscription.is_daily_paused = new_paused_state
|
||||
|
||||
# Если снимаем с паузы, нужно проверить баланс для активации
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
|
||||
# Если снимаем с паузы, проверяем баланс и списываем оплату
|
||||
if not new_paused_state:
|
||||
daily_price = getattr(tariff, 'daily_price_kopeks', 0)
|
||||
if daily_price > 0 and user.balance_kopeks < daily_price:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
@@ -7356,29 +7402,68 @@ async def toggle_daily_subscription_pause_endpoint(
|
||||
},
|
||||
)
|
||||
|
||||
# Восстанавливаем статус ACTIVE если подписка была DISABLED (недостаток средств)
|
||||
from app.database.models import SubscriptionStatus
|
||||
# Списываем суточную оплату ПЕРЕД активацией
|
||||
if was_disabled:
|
||||
if daily_price > 0:
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
|
||||
if subscription.status == SubscriptionStatus.DISABLED.value:
|
||||
deducted = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
daily_price,
|
||||
f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
mark_as_paid_subscription=True,
|
||||
)
|
||||
if not deducted:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
||||
detail={
|
||||
'code': 'insufficient_balance',
|
||||
'message': 'Balance deduction failed',
|
||||
'required': daily_price,
|
||||
'balance': user.balance_kopeks,
|
||||
},
|
||||
)
|
||||
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.models import TransactionType
|
||||
|
||||
try:
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=daily_price,
|
||||
description=f'Суточная оплата тарифа «{tariff.name}» (возобновление)',
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning('Failed to create resume transaction in miniapp', error=exc)
|
||||
|
||||
# Баланс списан — теперь активируем
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
# Обновляем время последнего списания для корректного расчёта следующего
|
||||
subscription.last_daily_charge_at = datetime.now(UTC)
|
||||
subscription.end_date = datetime.now(UTC) + timedelta(days=1)
|
||||
logger.info('✅ Суточная подписка восстановлена из DISABLED в ACTIVE', subscription_id=subscription.id)
|
||||
|
||||
logger.info(
|
||||
'✅ Суточная подписка восстановлена в ACTIVE (miniapp)',
|
||||
subscription_id=subscription.id,
|
||||
previous_status='disabled/expired',
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
await db.refresh(user)
|
||||
|
||||
# Синхронизация с RemnaWave
|
||||
# При паузе VPN продолжает работать до конца оплаченного времени,
|
||||
# поэтому НЕ отключаем пользователя в RemnaWave
|
||||
# При возобновлении включаем если был отключен (например, из-за истечения срока)
|
||||
if not new_paused_state:
|
||||
# Синхронизация с RemnaWave только при возобновлении из DISABLED/EXPIRED
|
||||
if not new_paused_state and was_disabled:
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
if user.remnawave_uuid:
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
await service.create_remnawave_user(
|
||||
db,
|
||||
subscription,
|
||||
reset_traffic=False,
|
||||
reset_reason=None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с RemnaWave при возобновлении', error=e)
|
||||
|
||||
|
||||
@@ -15,11 +15,19 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
insp = sa.inspect(conn)
|
||||
return insp.has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=True)
|
||||
if _table_exists('yookassa_payments'):
|
||||
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# WARNING: Will fail if any rows have user_id=NULL (guest payments).
|
||||
# Backfill required: UPDATE yookassa_payments SET user_id = 0 WHERE user_id IS NULL;
|
||||
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=False)
|
||||
if _table_exists('yookassa_payments'):
|
||||
op.alter_column('yookassa_payments', 'user_id', existing_type=sa.Integer(), nullable=False)
|
||||
|
||||
@@ -30,13 +30,21 @@ _TABLES = [
|
||||
]
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
insp = sa.inspect(conn)
|
||||
return insp.has_table(table_name)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for table in _TABLES:
|
||||
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=True)
|
||||
if _table_exists(table):
|
||||
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# WARNING: Will fail if any rows have user_id=NULL (guest payments).
|
||||
# Backfill required before downgrading.
|
||||
for table in _TABLES:
|
||||
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=False)
|
||||
if _table_exists(table):
|
||||
op.alter_column(table, 'user_id', existing_type=sa.Integer(), nullable=False)
|
||||
|
||||
@@ -15,6 +15,12 @@ branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _table_exists(table_name: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
insp = sa.inspect(conn)
|
||||
return insp.has_table(table_name)
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
@@ -22,12 +28,12 @@ def _has_column(table: str, column: str) -> bool:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if _has_column('contest_templates', 'prize_days'):
|
||||
if _table_exists('contest_templates') and _has_column('contest_templates', 'prize_days'):
|
||||
op.drop_column('contest_templates', 'prize_days')
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
if not _has_column('contest_templates', 'prize_days'):
|
||||
if _table_exists('contest_templates') and not _has_column('contest_templates', 'prize_days'):
|
||||
op.add_column(
|
||||
'contest_templates',
|
||||
sa.Column('prize_days', sa.Integer(), nullable=True),
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.26.0"
|
||||
version = "3.27.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
Reference in New Issue
Block a user