Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efa1b11db5 | |||
| d0ce193edb | |||
| 92d872236f | |||
| a11f492801 | |||
| c8162505ed | |||
| 076290e0c1 | |||
| bf72f241d8 | |||
| 12ae871653 | |||
| 8a362db783 | |||
| b1e2146254 | |||
| 68bc8eb57c | |||
| 9957259881 | |||
| b3f3eba575 | |||
| a798f1143e | |||
| cb5126aff8 | |||
| 8b35428055 | |||
| 5424d8c314 | |||
| df7411138e | |||
| 4545bef7ea | |||
| d7eb1e776a | |||
| f8fc382143 | |||
| e67b8e448e | |||
| bca8bab433 | |||
| 2fd0f6aa4e | |||
| 864a4ed700 | |||
| 2879996455 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.29.0"
|
||||
".": "3.31.0"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,46 @@
|
||||
# Changelog
|
||||
|
||||
## [3.31.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.30.0...v3.31.0) (2026-03-12)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add show_in_gift toggle for tariffs in admin panel ([cb5126a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/cb5126aff8c15938a59ea9c4f8e605b250b05dbc))
|
||||
* add sync-squads endpoint for bulk updating subscription squads in Remnawave ([b1e2146](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b1e2146254255586b5be9bd894ac4d113a0a8cf5))
|
||||
* auto-sync squads to Remnawave when admin updates tariff ([076290e](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/076290e0c1d81b610a7653d6b64ed218e0f124b4))
|
||||
* referral links now point to web cabinet instead of bot ([12ae871](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/12ae871653399bc4ccd23b6394878e814ce9cd75))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add post_update=True to User.referrals self-referential relationship ([9957259](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/995725988150f31d193631120a4692e88fa4dd57))
|
||||
* add Telegram Stars payment support for gift subscriptions ([5424d8c](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/5424d8c31484873b0adc0bc980abdc51ee81325b))
|
||||
* correct skipped_count in sync-squads circuit breaker and simplify ternary ([8a362db](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8a362db7833b5b7793b5b52345d227cb84cbc39e))
|
||||
* preserve purchased devices when admin changes user tariff ([bf72f24](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bf72f241d81e4432f50a61ec3bb829d18c92955d))
|
||||
* prevent account takeover via auto_login_token, ensure promo group on all purchase paths ([b3f3eba](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/b3f3eba5756404df9ed0f12d8048244ca536f7d3))
|
||||
* reactivate subscription after traffic top-up when status is EXPIRED ([8b35428](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/8b354280558a5f28d1b99eae55ccd21a4af6a07b))
|
||||
* update promo group via M2M table so admin changes persist ([68bc8eb](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/68bc8eb57c792059d2be8a8fff6bba3254d3773d))
|
||||
|
||||
|
||||
### Refactoring
|
||||
|
||||
* remove estimated price from balance, simplify server sync, fix HTML injection ([a798f11](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/a798f1143eebf52e18254bddd610f7f14a0c4056))
|
||||
|
||||
## [3.30.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.29.0...v3.30.0) (2026-03-11)
|
||||
|
||||
|
||||
### New Features
|
||||
|
||||
* add gifts section to admin user detail API ([bca8bab](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/bca8bab4336b2583da9be8c642985e6a0151e33d))
|
||||
* add promo group and promo offer discounts to gift subscriptions ([2fd0f6a](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/2fd0f6aa4eb62f704208c1e56a6542d3967e7867))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* record transactions for free tariff switches and admin tariff changes ([864a4ed](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/864a4ed7005195ff3be3a8bb2e7666bc5a7f3e4e))
|
||||
* reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet ([e67b8e4](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/e67b8e448e5396ee6daa8c6278bb5a0b313dda74))
|
||||
* use keyword args for Path.mkdir in asyncio.to_thread ([2879996](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/commit/287999645506a49b6693a184757598e1cdceb4d8))
|
||||
|
||||
## [3.29.0](https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot/compare/v3.28.1...v3.29.0) (2026-03-10)
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ RUN --mount=type=cache,target=/root/.cache/uv \
|
||||
|
||||
FROM python:3.13-slim
|
||||
|
||||
ARG VERSION="v3.29.0" # x-release-please-version
|
||||
ARG VERSION="v3.31.0" # x-release-please-version
|
||||
ARG BUILD_DATE
|
||||
ARG VCS_REF
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Admin routes for managing tariffs in cabinet."""
|
||||
|
||||
import asyncio
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import and_, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import joinedload
|
||||
|
||||
from app.database.crud.server_squad import get_all_server_squads
|
||||
from app.database.crud.tariff import (
|
||||
@@ -17,7 +20,7 @@ from app.database.crud.tariff import (
|
||||
set_tariff_promo_groups,
|
||||
update_tariff,
|
||||
)
|
||||
from app.database.models import PromoGroup, Subscription, Tariff, Transaction, TransactionType, User
|
||||
from app.database.models import PromoGroup, Subscription, SubscriptionStatus, Tariff, Transaction, TransactionType, User
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.tariffs import (
|
||||
@@ -26,6 +29,7 @@ from ..schemas.tariffs import (
|
||||
PromoGroupInfo,
|
||||
ServerInfo,
|
||||
ServerTrafficLimit,
|
||||
SyncSquadsResponse,
|
||||
TariffCreateRequest,
|
||||
TariffDetailResponse,
|
||||
TariffListItem,
|
||||
@@ -127,6 +131,7 @@ async def list_tariffs(
|
||||
is_daily=tariff.is_daily,
|
||||
daily_price_kopeks=tariff.daily_price_kopeks,
|
||||
allow_traffic_topup=tariff.allow_traffic_topup,
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
traffic_limit_gb=tariff.traffic_limit_gb,
|
||||
device_limit=tariff.device_limit,
|
||||
tier_level=tariff.tier_level,
|
||||
@@ -265,6 +270,8 @@ async def get_tariff(
|
||||
traffic_reset_mode=tariff.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=tariff.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=tariff.show_in_gift,
|
||||
created_at=tariff.created_at,
|
||||
updated_at=tariff.updated_at,
|
||||
)
|
||||
@@ -321,6 +328,8 @@ async def create_new_tariff(
|
||||
traffic_reset_mode=request.traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
external_squad_uuid=request.external_squad_uuid,
|
||||
# Показывать в подарках
|
||||
show_in_gift=request.show_in_gift,
|
||||
)
|
||||
|
||||
logger.info('Admin created tariff', admin_id=admin.id, tariff_id=tariff.id, tariff_name=tariff.name)
|
||||
@@ -347,6 +356,10 @@ async def update_existing_tariff(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Capture old values for change detection
|
||||
old_squads = list(tariff.allowed_squads) if tariff.allowed_squads else []
|
||||
old_external_squad = tariff.external_squad_uuid
|
||||
|
||||
# Build updates dict
|
||||
updates = {}
|
||||
if request.name is not None:
|
||||
@@ -413,6 +426,9 @@ async def update_existing_tariff(
|
||||
# Внешний сквад (None допускается для сброса)
|
||||
if 'external_squad_uuid' in request.model_fields_set:
|
||||
updates['external_squad_uuid'] = request.external_squad_uuid
|
||||
# Показывать в подарках
|
||||
if request.show_in_gift is not None:
|
||||
updates['show_in_gift'] = request.show_in_gift
|
||||
|
||||
if updates:
|
||||
await update_tariff(db, tariff, **updates)
|
||||
@@ -426,6 +442,18 @@ async def update_existing_tariff(
|
||||
# Перезагружаем периоды из БД для синхронизации с ботом
|
||||
await load_period_prices_from_db(db)
|
||||
|
||||
# Auto-sync squads to active subscriptions in Remnawave when squads changed
|
||||
new_squads = tariff.allowed_squads or []
|
||||
squads_changed = request.allowed_squads is not None and sorted(old_squads) != sorted(new_squads)
|
||||
ext_squad_changed = (
|
||||
'external_squad_uuid' in request.model_fields_set and tariff.external_squad_uuid != old_external_squad
|
||||
)
|
||||
if squads_changed or ext_squad_changed:
|
||||
asyncio.create_task(
|
||||
_background_sync_squads(tariff_id, admin.id),
|
||||
name=f'sync-squads-tariff-{tariff_id}',
|
||||
)
|
||||
|
||||
return await get_tariff(tariff_id, admin, db)
|
||||
|
||||
|
||||
@@ -586,3 +614,217 @@ async def get_tariff_stats(
|
||||
revenue_kopeks=revenue_kopeks,
|
||||
revenue_rubles=revenue_kopeks / 100,
|
||||
)
|
||||
|
||||
|
||||
async def _background_sync_squads(tariff_id: int, admin_id: int) -> None:
|
||||
"""Run squad sync in background with its own DB session (fire-and-forget)."""
|
||||
from app.database.database import AsyncSessionLocal
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
try:
|
||||
async with AsyncSessionLocal() as db:
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
return
|
||||
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated = 0
|
||||
failed = 0
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(5)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> None:
|
||||
nonlocal updated, failed
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
return
|
||||
async with semaphore:
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
sub.connected_squads = new_squads
|
||||
updated += 1
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
logger.warning(
|
||||
'Background sync: failed to sync squads for user',
|
||||
user_id=sub.user_id,
|
||||
error=str(e),
|
||||
)
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
await db.commit()
|
||||
logger.info(
|
||||
'Background squad sync completed after tariff update',
|
||||
admin_id=admin_id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated,
|
||||
failed=failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Background squad sync failed', tariff_id=tariff_id)
|
||||
|
||||
|
||||
_SYNC_SQUADS_CONCURRENCY = 5
|
||||
_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES = 10
|
||||
|
||||
|
||||
@router.post('/{tariff_id}/sync-squads', response_model=SyncSquadsResponse)
|
||||
async def sync_tariff_squads(
|
||||
tariff_id: int,
|
||||
admin: User = Depends(require_permission('tariffs:edit')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
):
|
||||
"""Sync squads from tariff to all active/trial subscriptions in Remnawave panel.
|
||||
|
||||
Updates connected_squads and external_squad_uuid for every active or trial
|
||||
subscription linked to this tariff. Only users that have a remnawave_uuid
|
||||
(i.e. already exist in the panel) are touched.
|
||||
"""
|
||||
tariff = await get_tariff_by_id(db, tariff_id)
|
||||
if not tariff:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Fetch active + trial subscriptions for this tariff whose users exist in Remnawave
|
||||
result = await db.execute(
|
||||
select(Subscription)
|
||||
.join(User, Subscription.user_id == User.id)
|
||||
.options(joinedload(Subscription.user))
|
||||
.where(
|
||||
and_(
|
||||
Subscription.tariff_id == tariff_id,
|
||||
Subscription.status.in_([SubscriptionStatus.ACTIVE.value, SubscriptionStatus.TRIAL.value]),
|
||||
User.remnawave_uuid.isnot(None),
|
||||
)
|
||||
)
|
||||
)
|
||||
subscriptions = list(result.unique().scalars().all())
|
||||
|
||||
if not subscriptions:
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=0,
|
||||
updated_count=0,
|
||||
failed_count=0,
|
||||
skipped_count=0,
|
||||
)
|
||||
|
||||
new_squads = tariff.allowed_squads or []
|
||||
# None means "clear external squad" — intentional when tariff has none
|
||||
ext_squad_uuid = tariff.external_squad_uuid
|
||||
|
||||
# Sync to Remnawave panel with concurrency limit and circuit breaker
|
||||
from app.services.remnawave_service import RemnaWaveService
|
||||
|
||||
service = RemnaWaveService()
|
||||
updated_count = 0
|
||||
failed_count = 0
|
||||
skipped_count = 0
|
||||
consecutive_failures = 0
|
||||
errors: list[str] = []
|
||||
aborted = False
|
||||
|
||||
async with service.get_api_client() as api:
|
||||
semaphore = asyncio.Semaphore(_SYNC_SQUADS_CONCURRENCY)
|
||||
|
||||
async def _sync_one(sub: Subscription) -> str:
|
||||
# Counter mutations are safe: no `await` between read-modify-write
|
||||
# and the check within each branch (single-threaded asyncio event loop).
|
||||
nonlocal updated_count, failed_count, skipped_count, consecutive_failures, aborted
|
||||
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
remnawave_uuid = sub.user.remnawave_uuid if sub.user else None
|
||||
if not remnawave_uuid:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
async with semaphore:
|
||||
if aborted:
|
||||
skipped_count += 1
|
||||
return 'skipped'
|
||||
|
||||
try:
|
||||
await api.update_user(
|
||||
uuid=remnawave_uuid,
|
||||
active_internal_squads=new_squads,
|
||||
external_squad_uuid=ext_squad_uuid,
|
||||
)
|
||||
# Update local DB only on successful API call
|
||||
sub.connected_squads = new_squads
|
||||
updated_count += 1
|
||||
consecutive_failures = 0
|
||||
return 'ok'
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
consecutive_failures += 1
|
||||
errors.append(f'user_id={sub.user_id}: sync failed')
|
||||
logger.warning(
|
||||
'Failed to sync squads for user in Remnawave',
|
||||
user_id=sub.user_id,
|
||||
remnawave_uuid=remnawave_uuid,
|
||||
error=str(e),
|
||||
)
|
||||
if consecutive_failures >= _SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES:
|
||||
aborted = True
|
||||
errors.append(f'Aborted after {_SYNC_SQUADS_MAX_CONSECUTIVE_FAILURES} consecutive failures')
|
||||
return 'error'
|
||||
|
||||
await asyncio.gather(*[_sync_one(sub) for sub in subscriptions])
|
||||
|
||||
# Commit local DB changes only for successfully synced subscriptions
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
'Admin synced squads for tariff',
|
||||
admin_id=admin.id,
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total=len(subscriptions),
|
||||
updated=updated_count,
|
||||
failed=failed_count,
|
||||
skipped=skipped_count,
|
||||
)
|
||||
|
||||
return SyncSquadsResponse(
|
||||
tariff_id=tariff_id,
|
||||
tariff_name=tariff.name,
|
||||
total_subscriptions=len(subscriptions),
|
||||
updated_count=updated_count,
|
||||
failed_count=failed_count,
|
||||
skipped_count=skipped_count,
|
||||
errors=errors[:20],
|
||||
)
|
||||
|
||||
@@ -4,8 +4,9 @@ from datetime import UTC, datetime, timedelta
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import Integer, and_, func, or_, select
|
||||
from sqlalchemy import Integer, and_, delete as sa_delete, func, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.database.crud.campaign import get_campaign_registration_by_user
|
||||
from app.database.crud.subscription import (
|
||||
@@ -24,7 +25,9 @@ from app.database.crud.user import (
|
||||
get_users_statistics,
|
||||
subtract_user_balance,
|
||||
)
|
||||
from app.database.crud.user_promo_group import sync_user_primary_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
PromoGroup,
|
||||
ReferralEarning,
|
||||
Subscription,
|
||||
@@ -34,12 +37,15 @@ from app.database.models import (
|
||||
Transaction,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
UserStatus,
|
||||
)
|
||||
from app.utils.timezone import panel_datetime_to_utc
|
||||
|
||||
from ..dependencies import get_cabinet_db, require_permission
|
||||
from ..schemas.users import (
|
||||
AdminUserGiftItem,
|
||||
AdminUserGiftsResponse,
|
||||
DeleteDeviceResponse,
|
||||
DeleteUserRequest,
|
||||
DeleteUserResponse,
|
||||
@@ -1115,13 +1121,38 @@ async def update_user_subscription(
|
||||
detail='Tariff not found',
|
||||
)
|
||||
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
extra_devices = 0
|
||||
if subscription.tariff_id:
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if old_tariff and old_tariff.device_limit:
|
||||
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
|
||||
|
||||
from app.config import settings
|
||||
|
||||
subscription.tariff_id = request.tariff_id
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.device_limit = tariff.device_limit
|
||||
|
||||
new_base = tariff.device_limit or 1
|
||||
new_total = new_base + extra_devices
|
||||
# Cap at new tariff's max_device_limit, falling back to global MAX_DEVICES_LIMIT
|
||||
effective_max = tariff.max_device_limit or (
|
||||
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
)
|
||||
if effective_max and new_total > effective_max:
|
||||
new_total = effective_max
|
||||
subscription.device_limit = new_total
|
||||
# Set squads from tariff
|
||||
if tariff.allowed_squads:
|
||||
subscription.connected_squads = tariff.allowed_squads
|
||||
|
||||
# Convert trial subscription to paid when switching to a non-trial tariff
|
||||
if subscription.is_trial and not tariff.is_trial_available:
|
||||
subscription.is_trial = False
|
||||
if subscription.end_date and subscription.end_date > datetime.now(UTC):
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
logger.info('Converted trial subscription to paid', user_id=user_id, tariff_name=tariff.name)
|
||||
|
||||
# Сбрасываем докупленный трафик при смене тарифа
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
@@ -1129,11 +1160,21 @@ async def update_user_subscription(
|
||||
subscription.purchased_traffic_gb = 0
|
||||
subscription.traffic_reset_at = None
|
||||
|
||||
from app.config import settings
|
||||
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
# Записываем транзакцию о смене тарифа
|
||||
from app.database.crud.transaction import create_transaction
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=f"Смена тарифа администратором на '{tariff.name}'",
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
@@ -1245,7 +1286,7 @@ async def update_user_subscription(
|
||||
|
||||
await add_subscription_traffic(db, subscription, request.traffic_gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
await db.refresh(subscription)
|
||||
@@ -1253,6 +1294,13 @@ async def update_user_subscription(
|
||||
# Sync to Remnawave panel
|
||||
await _sync_subscription_to_panel(db, user, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
logger.info('Admin added traffic for user', admin_id=admin.id, traffic_gb=request.traffic_gb, user_id=user_id)
|
||||
|
||||
return UpdateSubscriptionResponse(
|
||||
@@ -1606,8 +1654,22 @@ async def update_user_promo_group(
|
||||
)
|
||||
promo_group_name = promo_group.name
|
||||
|
||||
user.promo_group_id = new_promo_group_id
|
||||
user.updated_at = datetime.now(UTC)
|
||||
# Update M2M table (authoritative source) — not just the legacy FK column.
|
||||
# Without this, sync_user_primary_promo_group overwrites the admin change
|
||||
# on the next transaction.
|
||||
await db.execute(sa_delete(UserPromoGroup).where(UserPromoGroup.user_id == user_id))
|
||||
|
||||
if new_promo_group_id is not None:
|
||||
db.add(
|
||||
UserPromoGroup(
|
||||
user_id=user_id,
|
||||
promo_group_id=new_promo_group_id,
|
||||
assigned_by='admin',
|
||||
)
|
||||
)
|
||||
|
||||
await db.flush()
|
||||
await sync_user_primary_promo_group(db, user_id)
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
@@ -1998,21 +2060,6 @@ async def reset_user_subscription(
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
from app.database.crud.subscription import is_active_paid_subscription
|
||||
|
||||
if is_active_paid_subscription(user.subscription):
|
||||
logger.info(
|
||||
'⏭️ Пропуск сброса подписки: у пользователя активная оплаченная подписка',
|
||||
user_id=user_id,
|
||||
remnawave_uuid=user.remnawave_uuid,
|
||||
)
|
||||
return ResetSubscriptionResponse(
|
||||
success=False,
|
||||
message='Cannot reset active paid subscription. Subscription is still active and paid.',
|
||||
subscription_deleted=False,
|
||||
panel_deactivated=False,
|
||||
)
|
||||
|
||||
# Deactivate in Remnawave panel if requested
|
||||
if request.deactivate_in_panel and user.remnawave_uuid:
|
||||
try:
|
||||
@@ -2791,3 +2838,118 @@ async def sync_user_to_panel(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f'Sync error: {e!s}',
|
||||
)
|
||||
|
||||
|
||||
# === User Gifts ===
|
||||
|
||||
|
||||
@router.get('/{user_id}/gifts', response_model=AdminUserGiftsResponse)
|
||||
async def get_user_gifts(
|
||||
user_id: int,
|
||||
admin: User = Depends(require_permission('users:read')),
|
||||
db: AsyncSession = Depends(get_cabinet_db),
|
||||
) -> AdminUserGiftsResponse:
|
||||
"""Get all gift subscriptions sent and received by user."""
|
||||
from sqlalchemy.orm import noload
|
||||
|
||||
# Lightweight existence check (avoids eager-loading all User relationships)
|
||||
user_exists = await db.execute(select(User.id).where(User.id == user_id))
|
||||
if not user_exists.scalar_one_or_none():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail='User not found')
|
||||
|
||||
# True totals via COUNT queries
|
||||
sent_total = (
|
||||
await db.execute(
|
||||
select(func.count(GuestPurchase.id)).where(
|
||||
GuestPurchase.buyer_user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
received_total = (
|
||||
await db.execute(
|
||||
select(func.count(GuestPurchase.id)).where(
|
||||
GuestPurchase.user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
)
|
||||
).scalar() or 0
|
||||
|
||||
# Sent gifts (user is buyer) — suppress unneeded relationships
|
||||
sent_result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(
|
||||
selectinload(GuestPurchase.tariff),
|
||||
selectinload(GuestPurchase.user),
|
||||
noload(GuestPurchase.buyer),
|
||||
noload(GuestPurchase.landing),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.buyer_user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
sent_purchases = sent_result.scalars().all()
|
||||
|
||||
# Received gifts (user is recipient) — suppress unneeded relationships
|
||||
received_result = await db.execute(
|
||||
select(GuestPurchase)
|
||||
.options(
|
||||
selectinload(GuestPurchase.tariff),
|
||||
selectinload(GuestPurchase.buyer),
|
||||
noload(GuestPurchase.user),
|
||||
noload(GuestPurchase.landing),
|
||||
)
|
||||
.where(
|
||||
GuestPurchase.user_id == user_id,
|
||||
GuestPurchase.is_gift.is_(True),
|
||||
)
|
||||
.order_by(GuestPurchase.created_at.desc())
|
||||
.limit(200)
|
||||
)
|
||||
received_purchases = received_result.scalars().all()
|
||||
|
||||
sent_items = [_build_gift_item(p, receiver=p.user) for p in sent_purchases]
|
||||
received_items = [_build_gift_item(p, buyer=p.buyer) for p in received_purchases]
|
||||
|
||||
return AdminUserGiftsResponse(
|
||||
sent=sent_items,
|
||||
received=received_items,
|
||||
sent_total=sent_total,
|
||||
received_total=received_total,
|
||||
)
|
||||
|
||||
|
||||
def _build_gift_item(
|
||||
p: GuestPurchase,
|
||||
receiver: User | None = None,
|
||||
buyer: User | None = None,
|
||||
) -> AdminUserGiftItem:
|
||||
"""Build an admin gift item from a GuestPurchase."""
|
||||
tariff_name = p.tariff.name if p.tariff else None
|
||||
device_limit = p.tariff.device_limit if p.tariff else 1
|
||||
return AdminUserGiftItem(
|
||||
id=p.id,
|
||||
token=p.token[:12],
|
||||
status=p.status,
|
||||
tariff_name=tariff_name,
|
||||
period_days=p.period_days,
|
||||
device_limit=device_limit,
|
||||
amount_kopeks=p.amount_kopeks,
|
||||
payment_method=p.payment_method,
|
||||
gift_recipient_type=p.gift_recipient_type,
|
||||
gift_recipient_value=p.gift_recipient_value,
|
||||
gift_message=p.gift_message,
|
||||
buyer_user_id=p.buyer_user_id,
|
||||
buyer_username=buyer.username if buyer else None,
|
||||
buyer_full_name=buyer.full_name if buyer else None,
|
||||
receiver_user_id=p.user_id,
|
||||
receiver_username=receiver.username if receiver else None,
|
||||
receiver_full_name=receiver.full_name if receiver else None,
|
||||
created_at=p.created_at,
|
||||
paid_at=p.paid_at,
|
||||
delivered_at=p.delivered_at,
|
||||
)
|
||||
|
||||
+103
-8
@@ -15,7 +15,15 @@ from app.database.crud.system_setting import get_setting_value
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.crud.transaction import create_transaction, emit_transaction_side_effects
|
||||
from app.database.crud.user import subtract_user_balance
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, PaymentMethod, Tariff, TransactionType, User
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
GuestPurchaseStatus,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
UserPromoGroup,
|
||||
)
|
||||
from app.services.guest_purchase_service import (
|
||||
GuestPurchaseError,
|
||||
create_purchase,
|
||||
@@ -23,6 +31,7 @@ from app.services.guest_purchase_service import (
|
||||
)
|
||||
from app.services.payment_method_config_service import get_enabled_methods_for_user
|
||||
from app.utils.cache import RateLimitCache
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
from ..dependencies import get_cabinet_db, get_current_cabinet_user
|
||||
from ..schemas.gift import (
|
||||
@@ -73,25 +82,61 @@ async def get_gift_config(
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
)
|
||||
|
||||
# Load active tariffs
|
||||
# Load active tariffs visible in gift section
|
||||
result = await db.execute(
|
||||
select(Tariff).where(Tariff.is_active.is_(True)).order_by(Tariff.display_order, Tariff.id)
|
||||
select(Tariff)
|
||||
.where(Tariff.is_active.is_(True), Tariff.show_in_gift.is_(True))
|
||||
.order_by(Tariff.display_order, Tariff.id)
|
||||
)
|
||||
tariffs_db = result.scalars().all()
|
||||
|
||||
# Get user's promo group for discount calculation
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
promo_group_name = promo_group.name if promo_group else None
|
||||
|
||||
# Get active promo offer discount
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
|
||||
tariffs: list[GiftConfigTariff] = []
|
||||
for tariff in tariffs_db:
|
||||
period_days_list = tariff.get_available_periods()
|
||||
periods: list[GiftConfigTariffPeriod] = []
|
||||
for days in period_days_list:
|
||||
price = tariff.get_price_for_period(days)
|
||||
if price is None:
|
||||
base_price = tariff.get_price_for_period(days)
|
||||
if base_price is None:
|
||||
continue
|
||||
|
||||
original_price = base_price
|
||||
price = base_price
|
||||
|
||||
# Apply promo group discount
|
||||
promo_group_discount = 0
|
||||
if promo_group:
|
||||
promo_group_discount = promo_group.get_discount_percent('period', days)
|
||||
if promo_group_discount > 0:
|
||||
price = int(price * (100 - promo_group_discount) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks on top)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price = price - price * promo_offer_discount_percent // 100
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price = max(1, price)
|
||||
|
||||
# Calculate combined discount percent
|
||||
combined_discount = 0
|
||||
if original_price > 0 and original_price != price:
|
||||
combined_discount = int((original_price - price) * 100 / original_price)
|
||||
|
||||
periods.append(
|
||||
GiftConfigTariffPeriod(
|
||||
days=days,
|
||||
price_kopeks=price,
|
||||
price_label=settings.format_price(price),
|
||||
original_price_kopeks=original_price if combined_discount > 0 else None,
|
||||
discount_percent=combined_discount if combined_discount > 0 else None,
|
||||
)
|
||||
)
|
||||
if not periods:
|
||||
@@ -131,6 +176,11 @@ async def get_gift_config(
|
||||
payment_methods=payment_methods,
|
||||
balance_kopeks=user.balance_kopeks,
|
||||
currency_symbol=getattr(settings, 'CURRENCY_SYMBOL', '\u20bd'),
|
||||
promo_group_name=promo_group_name,
|
||||
active_discount_percent=promo_offer_discount_percent if promo_offer_discount_percent > 0 else None,
|
||||
active_discount_expires_at=(
|
||||
getattr(user, 'promo_offer_discount_expires_at', None) if promo_offer_discount_percent > 0 else None
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -193,7 +243,7 @@ async def create_gift_purchase(
|
||||
|
||||
# Find tariff and validate period
|
||||
tariff = await get_tariff_by_id(db, body.tariff_id)
|
||||
if tariff is None or not tariff.is_active:
|
||||
if tariff is None or not tariff.is_active or not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail='Tariff not found or inactive',
|
||||
@@ -206,6 +256,37 @@ async def create_gift_purchase(
|
||||
detail='Price is not configured for this period',
|
||||
)
|
||||
|
||||
# Lock user row to prevent concurrent promo offer double-spend
|
||||
locked_result = await db.execute(
|
||||
select(User)
|
||||
.options(
|
||||
selectinload(User.user_promo_groups).selectinload(UserPromoGroup.promo_group),
|
||||
selectinload(User.promo_group),
|
||||
)
|
||||
.where(User.id == user.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
)
|
||||
user = locked_result.scalar_one()
|
||||
|
||||
# Apply promo group discount
|
||||
promo_group = user.get_primary_promo_group() if hasattr(user, 'get_primary_promo_group') else None
|
||||
if promo_group is None:
|
||||
promo_group = getattr(user, 'promo_group', None)
|
||||
|
||||
if promo_group:
|
||||
discount_percent = promo_group.get_discount_percent('period', body.period_days)
|
||||
if discount_percent > 0:
|
||||
price_kopeks = int(price_kopeks * (100 - discount_percent) / 100)
|
||||
|
||||
# Apply active promo offer discount (stacks)
|
||||
promo_offer_discount_percent = get_user_active_promo_discount_percent(user)
|
||||
if promo_offer_discount_percent > 0:
|
||||
price_kopeks = price_kopeks - price_kopeks * promo_offer_discount_percent // 100
|
||||
|
||||
# Ensure minimum price of 1 kopek after all discounts
|
||||
price_kopeks = max(1, price_kopeks)
|
||||
|
||||
# Determine buyer contact info
|
||||
if user.email:
|
||||
buyer_contact_type = 'email'
|
||||
@@ -301,7 +382,14 @@ async def create_gift_purchase(
|
||||
|
||||
from app.services.payment_service import PaymentService
|
||||
|
||||
payment_service = PaymentService()
|
||||
# Stars payments need a Bot instance to create invoice links
|
||||
bot = None
|
||||
if body.payment_method == 'telegram_stars':
|
||||
from aiogram import Bot
|
||||
|
||||
bot = Bot(token=settings.BOT_TOKEN)
|
||||
|
||||
payment_service = PaymentService(bot=bot)
|
||||
payment_result = await payment_service.create_guest_payment(
|
||||
db=db,
|
||||
amount_kopeks=price_kopeks,
|
||||
@@ -331,6 +419,12 @@ async def create_gift_purchase(
|
||||
detail='Payment provider returned an invalid response',
|
||||
)
|
||||
|
||||
# Consume promo offer discount before committing gateway purchase
|
||||
if promo_offer_discount_percent > 0 and getattr(user, 'promo_offer_discount_percent', 0):
|
||||
user.promo_offer_discount_percent = 0
|
||||
user.promo_offer_discount_source = None
|
||||
user.promo_offer_discount_expires_at = None
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase)
|
||||
|
||||
@@ -384,13 +478,14 @@ async def create_gift_purchase(
|
||||
if recipient_warning:
|
||||
purchase.recipient_warning = recipient_warning
|
||||
|
||||
# Subtract balance
|
||||
# Subtract balance (consume promo offer if one was applied)
|
||||
balance_ok = await subtract_user_balance(
|
||||
db,
|
||||
user,
|
||||
price_kopeks,
|
||||
description=f'Gift: {tariff.name} ({body.period_days}d)',
|
||||
create_transaction=False,
|
||||
consume_promo_offer=promo_offer_discount_percent > 0,
|
||||
)
|
||||
if not balance_ok:
|
||||
await db.rollback()
|
||||
|
||||
@@ -607,6 +607,13 @@ async def create_landing_purchase(
|
||||
except GuestPurchaseError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=exc.message) from exc
|
||||
|
||||
# Gift purchases require the tariff to be visible in the gift section
|
||||
if body.is_gift and not tariff.show_in_gift:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='This tariff is not available for gift purchases',
|
||||
)
|
||||
|
||||
# Validate amount against per-method min/max limits (before creating purchase record)
|
||||
min_amount = method_config.get('min_amount_kopeks')
|
||||
max_amount = method_config.get('max_amount_kopeks')
|
||||
|
||||
@@ -92,8 +92,7 @@ async def get_referral_info(
|
||||
available_balance = min(user.balance_kopeks, referral_entitlement)
|
||||
|
||||
# Build referral link
|
||||
bot_username = settings.get_bot_username() or 'bot'
|
||||
referral_link = f'https://t.me/{bot_username}?start={user.referral_code}'
|
||||
referral_link = settings.get_referral_link(user.referral_code) if user.referral_code else ''
|
||||
|
||||
return ReferralInfoResponse(
|
||||
referral_code=user.referral_code or '',
|
||||
|
||||
@@ -934,7 +934,7 @@ async def purchase_traffic(
|
||||
# Добавляем трафик (add_subscription_traffic обновляет purchased_traffic_gb, traffic_reset_at и коммитит)
|
||||
await add_subscription_traffic(db, subscription, request.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -944,6 +944,9 @@ async def purchase_traffic(
|
||||
subscription_service = SubscriptionService()
|
||||
if getattr(user, 'remnawave_uuid', None):
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
else:
|
||||
await subscription_service.create_remnawave_user(db, subscription)
|
||||
except Exception as e:
|
||||
@@ -4350,6 +4353,16 @@ async def switch_tariff(
|
||||
description=description,
|
||||
payment_method=PaymentMethod.BALANCE,
|
||||
)
|
||||
else:
|
||||
# Free switch (downgrade) — record in history
|
||||
description = f"Переход на тариф '{new_tariff.name}'"
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Update subscription
|
||||
old_tariff_name = current_tariff.name if current_tariff else 'Unknown'
|
||||
|
||||
@@ -45,6 +45,9 @@ class GiftConfigResponse(BaseModel):
|
||||
payment_methods: list[GiftConfigPaymentMethod] = []
|
||||
balance_kopeks: int = 0
|
||||
currency_symbol: str = '\u20bd'
|
||||
promo_group_name: str | None = None
|
||||
active_discount_percent: int | None = None
|
||||
active_discount_expires_at: datetime | None = None
|
||||
|
||||
|
||||
class GiftPurchaseRequest(BaseModel):
|
||||
|
||||
@@ -54,6 +54,7 @@ class TariffListItem(BaseModel):
|
||||
is_daily: bool = False
|
||||
daily_price_kopeks: int = 0
|
||||
allow_traffic_topup: bool = True
|
||||
show_in_gift: bool = True
|
||||
traffic_limit_gb: int
|
||||
device_limit: int
|
||||
tier_level: int
|
||||
@@ -114,6 +115,8 @@ class TariffDetailResponse(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = None
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -170,6 +173,8 @@ class TariffCreateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool = True
|
||||
|
||||
|
||||
class TariffUpdateRequest(BaseModel):
|
||||
@@ -209,6 +214,8 @@ class TariffUpdateRequest(BaseModel):
|
||||
traffic_reset_mode: str | None = None # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
external_squad_uuid: str | None = Field(None, pattern=UUID_PATTERN)
|
||||
# Показывать в подарках
|
||||
show_in_gift: bool | None = None
|
||||
|
||||
|
||||
class TariffSortOrderRequest(BaseModel):
|
||||
@@ -243,3 +250,15 @@ class TariffStatsResponse(BaseModel):
|
||||
trial_subscriptions: int
|
||||
revenue_kopeks: int
|
||||
revenue_rubles: float
|
||||
|
||||
|
||||
class SyncSquadsResponse(BaseModel):
|
||||
"""Response after syncing squads for tariff subscriptions."""
|
||||
|
||||
tariff_id: int
|
||||
tariff_name: str
|
||||
total_subscriptions: int
|
||||
updated_count: int
|
||||
failed_count: int
|
||||
skipped_count: int
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
|
||||
@@ -696,3 +696,40 @@ class DisableUserResponse(BaseModel):
|
||||
panel_deactivated: bool = False
|
||||
user_blocked: bool = False
|
||||
panel_error: str | None = None
|
||||
|
||||
|
||||
# === Gifts ===
|
||||
|
||||
|
||||
class AdminUserGiftItem(BaseModel):
|
||||
"""Gift item for admin user detail view."""
|
||||
|
||||
id: int
|
||||
token: str
|
||||
status: str
|
||||
tariff_name: str | None = None
|
||||
period_days: int
|
||||
device_limit: int = 1
|
||||
amount_kopeks: int
|
||||
payment_method: str | None = None
|
||||
gift_recipient_type: str | None = None
|
||||
gift_recipient_value: str | None = None
|
||||
gift_message: str | None = None
|
||||
buyer_user_id: int | None = None
|
||||
buyer_username: str | None = None
|
||||
buyer_full_name: str | None = None
|
||||
receiver_user_id: int | None = None
|
||||
receiver_username: str | None = None
|
||||
receiver_full_name: str | None = None
|
||||
created_at: datetime | None = None
|
||||
paid_at: datetime | None = None
|
||||
delivered_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminUserGiftsResponse(BaseModel):
|
||||
"""Response with sent and received gifts for admin user detail."""
|
||||
|
||||
sent: list[AdminUserGiftItem] = []
|
||||
received: list[AdminUserGiftItem] = []
|
||||
sent_total: int = 0
|
||||
received_total: int = 0
|
||||
|
||||
@@ -1415,6 +1415,26 @@ class Settings(BaseSettings):
|
||||
return value
|
||||
return None
|
||||
|
||||
_CABINET_URL_DEFAULT = 'https://example.com/cabinet'
|
||||
|
||||
def get_referral_link(self, referral_code: str, bot_username: str | None = None) -> str:
|
||||
"""Build a referral link pointing to the web cabinet.
|
||||
|
||||
Falls back to a Telegram bot deep link when CABINET_URL is not configured.
|
||||
"""
|
||||
from urllib.parse import quote
|
||||
|
||||
if not referral_code:
|
||||
raise ValueError('referral_code must not be empty or None')
|
||||
|
||||
safe_code = quote(referral_code, safe='')
|
||||
cabinet_url = (self.CABINET_URL or '').strip().rstrip('/')
|
||||
if cabinet_url and cabinet_url != self._CABINET_URL_DEFAULT:
|
||||
sep = '&' if '?' in cabinet_url else '?'
|
||||
return f'{cabinet_url}{sep}ref={safe_code}'
|
||||
username = bot_username or self.get_bot_username() or 'bot'
|
||||
return f'https://t.me/{username}?start={safe_code}'
|
||||
|
||||
def is_deep_links_enabled(self) -> bool:
|
||||
return self.ENABLE_DEEP_LINKS
|
||||
|
||||
|
||||
@@ -306,9 +306,8 @@ async def sync_with_remnawave(db: AsyncSession, remnawave_squads: list[dict]) ->
|
||||
await create_server_squad(
|
||||
db=db,
|
||||
squad_uuid=squad_uuid,
|
||||
display_name=_generate_display_name(original_name),
|
||||
display_name=original_name,
|
||||
original_name=original_name,
|
||||
country_code=_extract_country_code(original_name),
|
||||
price_kopeks=1000,
|
||||
is_available=False,
|
||||
)
|
||||
@@ -482,219 +481,6 @@ async def get_random_trial_squad_uuid(
|
||||
return None
|
||||
|
||||
|
||||
def _generate_display_name(original_name: str) -> str:
|
||||
"""Генерирует отображаемое название сервера на основе оригинального имени."""
|
||||
|
||||
country_names = {
|
||||
# Европа
|
||||
'NL': '🇳🇱 Нидерланды',
|
||||
'DE': '🇩🇪 Германия',
|
||||
'FR': '🇫🇷 Франция',
|
||||
'GB': '🇬🇧 Великобритания',
|
||||
'UK': '🇬🇧 Великобритания',
|
||||
'IT': '🇮🇹 Италия',
|
||||
'ES': '🇪🇸 Испания',
|
||||
'PT': '🇵🇹 Португалия',
|
||||
'PL': '🇵🇱 Польша',
|
||||
'CZ': '🇨🇿 Чехия',
|
||||
'AT': '🇦🇹 Австрия',
|
||||
'CH': '🇨🇭 Швейцария',
|
||||
'SE': '🇸🇪 Швеция',
|
||||
'NO': '🇳🇴 Норвегия',
|
||||
'FI': '🇫🇮 Финляндия',
|
||||
'DK': '🇩🇰 Дания',
|
||||
'BE': '🇧🇪 Бельгия',
|
||||
'IE': '🇮🇪 Ирландия',
|
||||
'RO': '🇷🇴 Румыния',
|
||||
'BG': '🇧🇬 Болгария',
|
||||
'HU': '🇭🇺 Венгрия',
|
||||
'GR': '🇬🇷 Греция',
|
||||
'LV': '🇱🇻 Латвия',
|
||||
'LT': '🇱🇹 Литва',
|
||||
'EE': '🇪🇪 Эстония',
|
||||
'SK': '🇸🇰 Словакия',
|
||||
'SI': '🇸🇮 Словения',
|
||||
'HR': '🇭🇷 Хорватия',
|
||||
'RS': '🇷🇸 Сербия',
|
||||
'UA': '🇺🇦 Украина',
|
||||
'MD': '🇲🇩 Молдова',
|
||||
'BY': '🇧🇾 Беларусь',
|
||||
'LU': '🇱🇺 Люксембург',
|
||||
# СНГ и Азия
|
||||
'RU': '🇷🇺 Россия',
|
||||
'KZ': '🇰🇿 Казахстан',
|
||||
'UZ': '🇺🇿 Узбекистан',
|
||||
'GE': '🇬🇪 Грузия',
|
||||
'AM': '🇦🇲 Армения',
|
||||
'AZ': '🇦🇿 Азербайджан',
|
||||
# Америка
|
||||
'US': '🇺🇸 США',
|
||||
'CA': '🇨🇦 Канада',
|
||||
'MX': '🇲🇽 Мексика',
|
||||
'BR': '🇧🇷 Бразилия',
|
||||
'AR': '🇦🇷 Аргентина',
|
||||
'CL': '🇨🇱 Чили',
|
||||
'CO': '🇨🇴 Колумбия',
|
||||
# Азия
|
||||
'JP': '🇯🇵 Япония',
|
||||
'KR': '🇰🇷 Южная Корея',
|
||||
'CN': '🇨🇳 Китай',
|
||||
'HK': '🇭🇰 Гонконг',
|
||||
'TW': '🇹🇼 Тайвань',
|
||||
'SG': '🇸🇬 Сингапур',
|
||||
'TH': '🇹🇭 Таиланд',
|
||||
'VN': '🇻🇳 Вьетнам',
|
||||
'MY': '🇲🇾 Малайзия',
|
||||
'ID': '🇮🇩 Индонезия',
|
||||
'PH': '🇵🇭 Филиппины',
|
||||
'IN': '🇮🇳 Индия',
|
||||
'PK': '🇵🇰 Пакистан',
|
||||
# Ближний Восток
|
||||
'IL': '🇮🇱 Израиль',
|
||||
'TR': '🇹🇷 Турция',
|
||||
'AE': '🇦🇪 ОАЭ',
|
||||
'SA': '🇸🇦 Саудовская Аравия',
|
||||
'QA': '🇶🇦 Катар',
|
||||
'BH': '🇧🇭 Бахрейн',
|
||||
'KW': '🇰🇼 Кувейт',
|
||||
# Океания
|
||||
'AU': '🇦🇺 Австралия',
|
||||
'NZ': '🇳🇿 Новая Зеландия',
|
||||
# Африка
|
||||
'ZA': '🇿🇦 ЮАР',
|
||||
'EG': '🇪🇬 Египет',
|
||||
'NG': '🇳🇬 Нигерия',
|
||||
'KE': '🇰🇪 Кения',
|
||||
}
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент (через - или _)
|
||||
for code, display_name in country_names.items():
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return display_name
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return display_name
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return display_name
|
||||
if name_upper == code:
|
||||
return display_name
|
||||
|
||||
# Потом ищем просто вхождение кода
|
||||
for code, display_name in country_names.items():
|
||||
if code in name_upper:
|
||||
return display_name
|
||||
|
||||
return f'🌍 {original_name}'
|
||||
|
||||
|
||||
def _extract_country_code(original_name: str) -> str | None:
|
||||
"""Извлекает код страны из оригинального названия."""
|
||||
|
||||
# Полный список кодов стран
|
||||
codes = [
|
||||
# Европа
|
||||
'NL',
|
||||
'DE',
|
||||
'FR',
|
||||
'GB',
|
||||
'UK',
|
||||
'IT',
|
||||
'ES',
|
||||
'PT',
|
||||
'PL',
|
||||
'CZ',
|
||||
'AT',
|
||||
'CH',
|
||||
'SE',
|
||||
'NO',
|
||||
'FI',
|
||||
'DK',
|
||||
'BE',
|
||||
'IE',
|
||||
'RO',
|
||||
'BG',
|
||||
'HU',
|
||||
'GR',
|
||||
'LV',
|
||||
'LT',
|
||||
'EE',
|
||||
'SK',
|
||||
'SI',
|
||||
'HR',
|
||||
'RS',
|
||||
'UA',
|
||||
'MD',
|
||||
'BY',
|
||||
'LU',
|
||||
# СНГ
|
||||
'RU',
|
||||
'KZ',
|
||||
'UZ',
|
||||
'GE',
|
||||
'AM',
|
||||
'AZ',
|
||||
# Америка
|
||||
'US',
|
||||
'CA',
|
||||
'MX',
|
||||
'BR',
|
||||
'AR',
|
||||
'CL',
|
||||
'CO',
|
||||
# Азия
|
||||
'JP',
|
||||
'KR',
|
||||
'CN',
|
||||
'HK',
|
||||
'TW',
|
||||
'SG',
|
||||
'TH',
|
||||
'VN',
|
||||
'MY',
|
||||
'ID',
|
||||
'PH',
|
||||
'IN',
|
||||
'PK',
|
||||
# Ближний Восток
|
||||
'IL',
|
||||
'TR',
|
||||
'AE',
|
||||
'SA',
|
||||
'QA',
|
||||
'BH',
|
||||
'KW',
|
||||
# Океания
|
||||
'AU',
|
||||
'NZ',
|
||||
# Африка
|
||||
'ZA',
|
||||
'EG',
|
||||
'NG',
|
||||
'KE',
|
||||
]
|
||||
|
||||
name_upper = original_name.upper()
|
||||
|
||||
# Сначала ищем код как отдельный элемент
|
||||
for code in codes:
|
||||
if f'-{code}' in name_upper or f'_{code}' in name_upper:
|
||||
return code
|
||||
if name_upper.startswith(code + '-') or name_upper.startswith(code + '_'):
|
||||
return code
|
||||
if name_upper.endswith('-' + code) or name_upper.endswith('_' + code):
|
||||
return code
|
||||
if name_upper == code:
|
||||
return code
|
||||
|
||||
# Потом просто ищем вхождение
|
||||
for code in codes:
|
||||
if code in name_upper:
|
||||
return code
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def get_server_statistics(db: AsyncSession) -> dict:
|
||||
total_result = await db.execute(select(func.count(ServerSquad.id)))
|
||||
total_servers = total_result.scalar()
|
||||
|
||||
@@ -772,26 +772,35 @@ async def deactivate_subscription(db: AsyncSession, subscription: Subscription)
|
||||
|
||||
|
||||
async def reactivate_subscription(db: AsyncSession, subscription: Subscription) -> Subscription:
|
||||
"""Реактивация подписки (например, после повторной подписки на канал).
|
||||
"""Реактивация подписки (например, после повторной подписки на канал или докупки трафика).
|
||||
|
||||
Активирует только если подписка была DISABLED и ещё не истекла.
|
||||
Активирует если подписка была DISABLED или EXPIRED и ещё не истекла по времени.
|
||||
Не логирует если реактивация не требуется.
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Тихо выходим если реактивация не нужна
|
||||
if subscription.status != SubscriptionStatus.DISABLED.value:
|
||||
# Тихо выходим если реактивация не нужна (уже активна или другой статус)
|
||||
reactivatable_statuses = {SubscriptionStatus.DISABLED.value, SubscriptionStatus.EXPIRED.value}
|
||||
if subscription.status not in reactivatable_statuses:
|
||||
return subscription
|
||||
|
||||
if subscription.end_date and subscription.end_date <= now:
|
||||
if not subscription.end_date or subscription.end_date <= now:
|
||||
return subscription
|
||||
|
||||
old_status = subscription.status
|
||||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||||
subscription.updated_at = now
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(subscription)
|
||||
|
||||
logger.info(
|
||||
'✅ Подписка реактивирована',
|
||||
subscription_id=subscription.id,
|
||||
user_id=subscription.user_id,
|
||||
old_status=old_status,
|
||||
)
|
||||
|
||||
return subscription
|
||||
|
||||
|
||||
|
||||
@@ -185,6 +185,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks: int = 0,
|
||||
min_traffic_gb: int = 1,
|
||||
max_traffic_gb: int = 1000,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool = True,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = None, # DAY, WEEK, MONTH, NO_RESET, None = глобальная настройка
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -223,6 +225,8 @@ async def create_tariff(
|
||||
traffic_price_per_gb_kopeks=max(0, traffic_price_per_gb_kopeks),
|
||||
min_traffic_gb=max(1, min_traffic_gb),
|
||||
max_traffic_gb=max(1, max_traffic_gb),
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift=show_in_gift,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode=traffic_reset_mode,
|
||||
# Внешний сквад
|
||||
@@ -290,6 +294,8 @@ async def update_tariff(
|
||||
traffic_price_per_gb_kopeks: int | None = None,
|
||||
min_traffic_gb: int | None = None,
|
||||
max_traffic_gb: int | None = None,
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift: bool | None = None,
|
||||
# Режим сброса трафика
|
||||
traffic_reset_mode: str | None = ..., # ... = не передан, None = сбросить к глобальной настройке
|
||||
# Внешний сквад RemnaWave
|
||||
@@ -354,6 +360,9 @@ async def update_tariff(
|
||||
tariff.min_traffic_gb = max(1, min_traffic_gb)
|
||||
if max_traffic_gb is not None:
|
||||
tariff.max_traffic_gb = max(1, max_traffic_gb)
|
||||
# Видимость в разделе подарков
|
||||
if show_in_gift is not None:
|
||||
tariff.show_in_gift = show_in_gift
|
||||
# Режим сброса трафика
|
||||
if traffic_reset_mode is not ...:
|
||||
tariff.traffic_reset_mode = traffic_reset_mode
|
||||
|
||||
@@ -40,6 +40,7 @@ async def _sync_user_primary_promo_group(
|
||||
|
||||
except Exception as error:
|
||||
logger.error('Ошибка синхронизации primary промогруппы пользователя', user_id=user_id, error=error)
|
||||
raise
|
||||
|
||||
|
||||
async def sync_user_primary_promo_group(
|
||||
|
||||
+10
-5
@@ -974,6 +974,9 @@ class Tariff(Base):
|
||||
min_traffic_gb = Column(Integer, default=1, nullable=False) # Минимальный трафик в ГБ
|
||||
max_traffic_gb = Column(Integer, default=1000, nullable=False) # Максимальный трафик в ГБ
|
||||
|
||||
# Видимость в разделе подарков
|
||||
show_in_gift = Column(Boolean, default=True, server_default='true', nullable=False)
|
||||
|
||||
# Режим сброса трафика: DAY, WEEK, MONTH, NO_RESET (по умолчанию берётся из конфига)
|
||||
traffic_reset_mode = Column(String(20), nullable=True, default=None) # None = использовать глобальную настройку
|
||||
|
||||
@@ -1144,7 +1147,9 @@ class User(Base):
|
||||
discord_id = Column(String(255), unique=True, nullable=True, index=True)
|
||||
vk_id = Column(BigInteger, unique=True, nullable=True, index=True)
|
||||
broadcasts = relationship('BroadcastHistory', back_populates='admin')
|
||||
referrals = relationship('User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id')
|
||||
referrals = relationship(
|
||||
'User', backref='referrer', remote_side=[id], foreign_keys='User.referred_by_id', post_update=True
|
||||
)
|
||||
subscription = relationship('Subscription', back_populates='user', uselist=False)
|
||||
transactions = relationship('Transaction', back_populates='user')
|
||||
referral_earnings = relationship('ReferralEarning', foreign_keys='ReferralEarning.user_id', back_populates='user')
|
||||
@@ -1219,10 +1224,10 @@ class User(Base):
|
||||
|
||||
def get_primary_promo_group(self):
|
||||
"""Возвращает промогруппу с максимальным приоритетом."""
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, 'promo_group', None)
|
||||
|
||||
try:
|
||||
if not self.user_promo_groups:
|
||||
return getattr(self, 'promo_group', None)
|
||||
|
||||
# Сортируем по приоритету группы (убывание), затем по ID группы
|
||||
# Используем getattr для защиты от ленивой загрузки
|
||||
sorted_groups = sorted(
|
||||
@@ -1234,7 +1239,7 @@ class User(Base):
|
||||
if sorted_groups and sorted_groups[0].promo_group:
|
||||
return sorted_groups[0].promo_group
|
||||
except Exception:
|
||||
# Если возникла ошибка (например, ленивая загрузка), fallback на старую связь
|
||||
# Если возникла ошибка (например, ленивая загрузка в async), fallback на старую связь
|
||||
pass
|
||||
|
||||
# Fallback на старую связь если новая пустая или возникла ошибка
|
||||
|
||||
@@ -977,7 +977,7 @@ async def _render_squad_selection(
|
||||
if not selected_server:
|
||||
selected_server = await get_server_squad_by_uuid(db, selected_uuid)
|
||||
if selected_server:
|
||||
selected_server_name = selected_server.display_name
|
||||
selected_server_name = html.escape(selected_server.display_name)
|
||||
|
||||
header = texts.t('ADMIN_PROMO_OFFER_SELECT_SQUAD_TITLE', '🌍 <b>Выберите сквад</b>')
|
||||
if selected_server_name:
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import math
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -175,7 +176,7 @@ def _format_migration_server_label(texts, server) -> str:
|
||||
return texts.t(
|
||||
'ADMIN_SQUAD_MIGRATION_SERVER_LABEL',
|
||||
'{name} — 👥 {users} ({status})',
|
||||
).format(name=server.display_name, users=server.current_users, status=status)
|
||||
).format(name=html.escape(server.display_name), users=server.current_users, status=status)
|
||||
|
||||
|
||||
def _build_migration_keyboard(
|
||||
|
||||
@@ -44,8 +44,8 @@ def _build_server_edit_view(server):
|
||||
<b>Информация:</b>
|
||||
• ID: {server.id}
|
||||
• UUID: <code>{server.squad_uuid}</code>
|
||||
• Название: {server.display_name}
|
||||
• Оригинальное: {server.original_name or 'Не указано'}
|
||||
• Название: {html.escape(server.display_name)}
|
||||
• Оригинальное: {html.escape(server.original_name) if server.original_name else 'Не указано'}
|
||||
• Статус: {status_emoji}
|
||||
|
||||
<b>Настройки:</b>
|
||||
@@ -172,7 +172,7 @@ async def show_servers_list(callback: types.CallbackQuery, db_user: User, db: As
|
||||
status_emoji = '✅' if server.is_available else '❌'
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
|
||||
text += f'{i}. {status_emoji} {server.display_name}\n'
|
||||
text += f'{i}. {status_emoji} {html.escape(server.display_name)}\n'
|
||||
text += f' 💰 Цена: {price_text}'
|
||||
|
||||
if server.max_users:
|
||||
@@ -559,7 +559,7 @@ async def start_server_edit_name(callback: types.CallbackQuery, state: FSMContex
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✏️ <b>Редактирование названия</b>\n\n'
|
||||
f'Текущее название: <b>{server.display_name}</b>\n\n'
|
||||
f'Текущее название: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
f'Отправьте новое название для сервера:',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
@@ -621,7 +621,7 @@ async def delete_server_confirm(callback: types.CallbackQuery, db_user: User, db
|
||||
🗑️ <b>Удаление сервера</b>
|
||||
|
||||
Вы действительно хотите удалить сервер:
|
||||
<b>{server.display_name}</b>
|
||||
<b>{html.escape(server.display_name)}</b>
|
||||
|
||||
⚠️ <b>Внимание!</b>
|
||||
Сервер можно удалить только если к нему нет активных подключений.
|
||||
@@ -658,7 +658,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
await cache.delete_pattern('available_countries*')
|
||||
|
||||
await callback.message.edit_text(
|
||||
f'✅ Сервер <b>{server.display_name}</b> успешно удален!',
|
||||
f'✅ Сервер <b>{html.escape(server.display_name)}</b> успешно удален!',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='📋 К списку серверов', callback_data='admin_servers_list')]
|
||||
@@ -668,7 +668,7 @@ async def delete_server_execute(callback: types.CallbackQuery, db_user: User, db
|
||||
)
|
||||
else:
|
||||
await callback.message.edit_text(
|
||||
f'❌ Не удалось удалить сервер <b>{server.display_name}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
f'❌ Не удалось удалить сервер <b>{html.escape(server.display_name)}</b>\n\nВозможно, к нему есть активные подключения.',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
inline_keyboard=[
|
||||
[types.InlineKeyboardButton(text='🔙 К серверу', callback_data=f'admin_server_edit_{server_id}')]
|
||||
@@ -706,7 +706,7 @@ async def show_server_detailed_stats(callback: types.CallbackQuery, db_user: Use
|
||||
|
||||
for i, server in enumerate(sorted_servers[:5], 1):
|
||||
price_text = f'{int(server.price_rubles)} ₽' if server.price_kopeks > 0 else 'Бесплатно'
|
||||
text += f'{i}. {server.display_name} - {price_text}\n'
|
||||
text += f'{i}. {html.escape(server.display_name)} - {price_text}\n'
|
||||
|
||||
if not sorted_servers:
|
||||
text += 'Нет доступных серверов\n'
|
||||
@@ -968,7 +968,7 @@ async def start_server_edit_promo_groups(
|
||||
|
||||
text = (
|
||||
'🎯 <b>Настройка промогрупп</b>\n\n'
|
||||
f'Сервер: <b>{server.display_name}</b>\n\n'
|
||||
f'Сервер: <b>{html.escape(server.display_name)}</b>\n\n'
|
||||
'Выберите промогруппы, которым будет доступен этот сервер.\n'
|
||||
'Должна быть выбрана минимум одна промогруппа.'
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
@@ -868,7 +869,7 @@ async def _render_user_subscription_overview(callback: types.CallbackQuery, db:
|
||||
try:
|
||||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||||
if server:
|
||||
text += f'• {server.display_name}\n'
|
||||
text += f'• {html.escape(server.display_name)}\n'
|
||||
else:
|
||||
text += f'• {squad_uuid[:8]}... (неизвестный)\n'
|
||||
except Exception as e:
|
||||
@@ -4002,12 +4003,20 @@ async def _add_subscription_traffic(db: AsyncSession, user_id: int, gb: int, adm
|
||||
else:
|
||||
await add_subscription_traffic(db, subscription, gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if subscription.status == 'active':
|
||||
from app.database.crud.user import get_user_by_id
|
||||
|
||||
user = await get_user_by_id(db, user_id)
|
||||
if user and user.remnawave_uuid:
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
traffic_text = 'безлимитный' if gb == 0 else f'{gb} ГБ'
|
||||
logger.info('Админ добавил трафик пользователю', admin_id=admin_id, traffic_text=traffic_text, user_id=user_id)
|
||||
return True
|
||||
@@ -5309,9 +5318,24 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
try:
|
||||
old_tariff_id = subscription.tariff_id
|
||||
|
||||
# Обновляем параметры подписки в соответствии с тарифом
|
||||
# Preserve extra purchased devices above the old tariff's base limit
|
||||
extra_devices = 0
|
||||
if subscription.tariff_id:
|
||||
old_tariff = await get_tariff_by_id(db, subscription.tariff_id)
|
||||
if old_tariff and old_tariff.device_limit:
|
||||
extra_devices = max(0, (subscription.device_limit or old_tariff.device_limit) - old_tariff.device_limit)
|
||||
|
||||
subscription.tariff_id = tariff.id
|
||||
subscription.device_limit = tariff.device_limit
|
||||
|
||||
new_base = tariff.device_limit or 1
|
||||
new_total = new_base + extra_devices
|
||||
effective_max = tariff.max_device_limit or (
|
||||
settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||||
)
|
||||
if effective_max and new_total > effective_max:
|
||||
new_total = effective_max
|
||||
subscription.device_limit = new_total
|
||||
|
||||
subscription.traffic_limit_gb = tariff.traffic_limit_gb
|
||||
subscription.connected_squads = tariff.allowed_squads or []
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
@@ -5329,6 +5353,18 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
if settings.RESET_TRAFFIC_ON_TARIFF_SWITCH:
|
||||
subscription.traffic_used_gb = 0.0
|
||||
|
||||
# Записываем транзакцию о смене тарифа
|
||||
from app.database.crud.transaction import create_transaction
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=f"Смена тарифа администратором на '{tariff.name}'",
|
||||
commit=False,
|
||||
)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Синхронизируем с RemnaWave (сброс трафика по админ-настройке)
|
||||
@@ -5352,7 +5388,7 @@ async def confirm_admin_tariff_change(callback: types.CallbackQuery, db_user: Us
|
||||
await callback.message.edit_text(
|
||||
f'✅ <b>Тариф успешно изменен</b>\n\n'
|
||||
f'Новый тариф: <b>{tariff.name}</b>\n'
|
||||
f'• Устройства: {tariff.device_limit}\n'
|
||||
f'• Устройства: {subscription.device_limit}\n'
|
||||
f'• Трафик: {"♾️" if tariff.traffic_limit_gb == 0 else f"{tariff.traffic_limit_gb} ГБ"}\n'
|
||||
f'• Серверы: {len(tariff.allowed_squads) if tariff.allowed_squads else 0}',
|
||||
reply_markup=types.InlineKeyboardMarkup(
|
||||
|
||||
@@ -404,10 +404,7 @@ async def handle_balance_history_pagination(callback: types.CallbackQuery, db_us
|
||||
@error_handler
|
||||
async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db: AsyncSession, state: FSMContext):
|
||||
from app.config import settings
|
||||
from app.database.crud.subscription import get_subscription_by_user_id
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
from app.utils.payment_utils import get_payment_methods_text
|
||||
from app.utils.pricing_utils import apply_percentage_discount, calculate_months_from_days
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
@@ -430,139 +427,7 @@ async def show_payment_methods(callback: types.CallbackQuery, db_user: User, db:
|
||||
|
||||
payment_text = get_payment_methods_text(db_user.language)
|
||||
|
||||
# Добавляем информацию о текущем тарифе пользователя
|
||||
subscription = await get_subscription_by_user_id(db, db_user.id)
|
||||
tariff_info = ''
|
||||
if subscription and not subscription.is_trial:
|
||||
# Рассчитываем приблизительную стоимость продления на 30 дней
|
||||
duration_days = 30 # Берем для примера 30 дней
|
||||
current_traffic = subscription.traffic_limit_gb
|
||||
current_connected_squads = subscription.connected_squads or []
|
||||
current_device_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT
|
||||
|
||||
try:
|
||||
# Получаем цены для текущих параметров
|
||||
from app.config import PERIOD_PRICES
|
||||
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)
|
||||
|
||||
if tariff_price_found:
|
||||
# Тарифный режим: серверы и трафик включены в цену тарифа.
|
||||
# Порядок: база + устройства → скидка на полную сумму (как в calculate_renewal_price).
|
||||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||||
|
||||
original_price = base_price_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
|
||||
)
|
||||
months_in_period = calculate_months_from_days(duration_days)
|
||||
devices_price = extra_devices * device_price_per_unit * months_in_period
|
||||
original_price += devices_price
|
||||
|
||||
# Скидка промогруппы на полную сумму (база + устройства)
|
||||
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
|
||||
|
||||
# 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
|
||||
|
||||
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:
|
||||
traffic_display = texts.t('TRAFFIC_UNLIMITED_SHORT', 'Безлимит')
|
||||
else:
|
||||
traffic_display = texts.format_traffic(traffic_value)
|
||||
|
||||
current_tariff_desc = (
|
||||
f'📱 Подписка: {len(current_connected_squads)} серверов, '
|
||||
f'{traffic_display}, {current_device_limit} устр.'
|
||||
)
|
||||
estimated_price_info = (
|
||||
f'💰 Стоимость продления (примерно): {texts.format_price(total_price)} за {duration_days} дней'
|
||||
)
|
||||
|
||||
tariff_info = f'\n\n📋 <b>Ваш текущий тариф:</b>\n{current_tariff_desc}\n{estimated_price_info}'
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
'Не удалось рассчитать стоимость текущей подписки для пользователя', db_user_id=db_user.id, error=e
|
||||
)
|
||||
tariff_info = ''
|
||||
|
||||
full_text = payment_text + tariff_info
|
||||
full_text = payment_text
|
||||
|
||||
keyboard = get_payment_methods_keyboard(0, db_user.language)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
@@ -37,10 +38,14 @@ async def show_referral_info(callback: types.CallbackQuery, db_user: User, db: A
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
summary = await get_user_referral_summary(db, db_user.id)
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
referral_text = (
|
||||
texts.t('REFERRAL_PROGRAM_TITLE', '👥 <b>Реферальная программа</b>')
|
||||
@@ -229,17 +234,22 @@ async def show_referral_qr(
|
||||
callback: types.CallbackQuery,
|
||||
db_user: User,
|
||||
):
|
||||
await callback.answer()
|
||||
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
await callback.answer()
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
qr_dir = Path('data') / 'referral_qr'
|
||||
qr_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
file_path = qr_dir / f'{db_user.id}.png'
|
||||
link_hash = hashlib.md5(referral_link.encode()).hexdigest()[:8]
|
||||
file_path = qr_dir / f'{db_user.id}_{link_hash}.png'
|
||||
if not file_path.exists():
|
||||
img = qrcode.make(referral_link)
|
||||
img.save(file_path)
|
||||
@@ -470,8 +480,12 @@ async def show_referral_analytics(callback: types.CallbackQuery, db_user: User,
|
||||
async def create_invite_message(callback: types.CallbackQuery, db_user: User):
|
||||
texts = get_texts(db_user.language)
|
||||
|
||||
if not db_user.referral_code:
|
||||
await callback.answer(texts.t('REFERRAL_CODE_NOT_ASSIGNED', 'Реферальный код не назначен'), show_alert=True)
|
||||
return
|
||||
|
||||
bot_username = (await callback.bot.get_me()).username
|
||||
referral_link = f'https://t.me/{bot_username}?start={db_user.referral_code}'
|
||||
referral_link = settings.get_referral_link(db_user.referral_code, bot_username)
|
||||
|
||||
invite_text = texts.t('REFERRAL_INVITE_TITLE', '🎉 Присоединяйся к VPN сервису!')
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import structlog
|
||||
@@ -189,7 +190,7 @@ def _format_server_lines(
|
||||
else:
|
||||
latency_text = texts.t('SERVER_STATUS_OFFLINE', 'нет ответа')
|
||||
|
||||
name = server.display_name or server.name
|
||||
name = html.escape(server.display_name or server.name)
|
||||
flag_prefix = f'{server.flag} ' if server.flag else ''
|
||||
server_line = f'{flag_prefix}{name} — {latency_text}'
|
||||
lines.append(f'<blockquote>{server_line}</blockquote>')
|
||||
|
||||
@@ -285,6 +285,90 @@ async def _handle_trial_payment(
|
||||
return False
|
||||
|
||||
|
||||
_PURCHASE_TOKEN_RE = __import__('re').compile(r'^[A-Za-z0-9_\-]{10,100}$')
|
||||
|
||||
|
||||
async def _handle_guest_purchase_payment(
|
||||
message: types.Message,
|
||||
db: AsyncSession,
|
||||
user,
|
||||
stars_amount: int,
|
||||
payload: str,
|
||||
telegram_payment_charge_id: str,
|
||||
):
|
||||
"""Обработка Stars платежа для гостевой покупки (подарочная подписка из кабинета)."""
|
||||
from app.database.crud.landing import get_purchase_by_token
|
||||
from app.services.payment.common import try_fulfill_guest_purchase
|
||||
|
||||
try:
|
||||
purchase_token = payload[len('guest_purchase_') :]
|
||||
if not purchase_token or not _PURCHASE_TOKEN_RE.match(purchase_token):
|
||||
logger.error('Invalid purchase_token format in guest_purchase payload', payload=payload)
|
||||
await message.answer('❌ Ошибка: неверный формат платежа.')
|
||||
return
|
||||
|
||||
# Verify Stars amount matches expected price (±5% tolerance for conversion rounding)
|
||||
existing = await get_purchase_by_token(db, purchase_token)
|
||||
if existing and existing.amount_kopeks:
|
||||
expected_stars = max(1, settings.rubles_to_stars(existing.amount_kopeks / 100))
|
||||
tolerance = max(1, round(expected_stars * 0.05))
|
||||
if abs(stars_amount - expected_stars) > tolerance:
|
||||
logger.error(
|
||||
'Stars amount mismatch for guest purchase',
|
||||
paid_stars=stars_amount,
|
||||
expected_stars=expected_stars,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
await message.answer('❌ Сумма оплаты не совпадает с ожидаемой.')
|
||||
return
|
||||
|
||||
# Calculate kopeks from stars
|
||||
rubles_amount = TelegramStarsService.calculate_rubles_from_stars(stars_amount)
|
||||
amount_kopeks = int((rubles_amount * Decimal(100)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
|
||||
# Build metadata matching what other providers use
|
||||
metadata = {
|
||||
'purpose': 'guest_purchase',
|
||||
'purchase_token': purchase_token,
|
||||
}
|
||||
|
||||
result = await try_fulfill_guest_purchase(
|
||||
db,
|
||||
metadata=metadata,
|
||||
payment_amount_kopeks=amount_kopeks,
|
||||
provider_payment_id=telegram_payment_charge_id,
|
||||
provider_name='telegram_stars',
|
||||
skip_amount_check=True,
|
||||
)
|
||||
|
||||
if result is True:
|
||||
await message.answer(
|
||||
'🎁 <b>Подарочная подписка успешно оплачена!</b>\n\n'
|
||||
f'⭐ Потрачено: {stars_amount} Stars\n\n'
|
||||
'Подарок будет доставлен получателю.',
|
||||
parse_mode='HTML',
|
||||
)
|
||||
logger.info(
|
||||
'✅ Guest purchase fulfilled via Stars',
|
||||
user_id=user.id,
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
elif result is False:
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
else:
|
||||
logger.error('try_fulfill_guest_purchase returned None for Stars gift', payload=payload)
|
||||
await message.answer('❌ Ошибка обработки платежа. Обратитесь в поддержку.')
|
||||
|
||||
except Exception as e:
|
||||
logger.error('Error handling guest purchase Stars payment', error=e, exc_info=True)
|
||||
await message.answer(
|
||||
'❌ Произошла ошибка при обработке подарочной подписки. Обратитесь в поддержку.',
|
||||
)
|
||||
|
||||
|
||||
async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
texts = get_texts(DEFAULT_LANGUAGE)
|
||||
|
||||
@@ -296,7 +380,7 @@ async def handle_pre_checkout_query(query: types.PreCheckoutQuery):
|
||||
invoice_payload=query.invoice_payload,
|
||||
)
|
||||
|
||||
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_')
|
||||
allowed_prefixes = ('balance_', 'admin_stars_test_', 'simple_sub_', 'wheel_spin_', 'trial_', 'guest_purchase_')
|
||||
|
||||
if not query.invoice_payload or not query.invoice_payload.startswith(allowed_prefixes):
|
||||
logger.warning('Невалидный payload', invoice_payload=query.invoice_payload)
|
||||
@@ -402,6 +486,18 @@ async def handle_successful_payment(message: types.Message, db: AsyncSession, st
|
||||
)
|
||||
return
|
||||
|
||||
# Обработка оплаты гостевой покупки (подарочная подписка из кабинета)
|
||||
if payment.invoice_payload and payment.invoice_payload.startswith('guest_purchase_'):
|
||||
await _handle_guest_purchase_payment(
|
||||
message=message,
|
||||
db=db,
|
||||
user=user,
|
||||
stars_amount=payment.total_amount,
|
||||
payload=payment.invoice_payload,
|
||||
telegram_payment_charge_id=payment.telegram_payment_charge_id,
|
||||
)
|
||||
return
|
||||
|
||||
payment_service = PaymentService(message.bot)
|
||||
|
||||
state_data = await state.get_data()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from aiogram import types
|
||||
@@ -66,7 +67,7 @@ async def handle_add_countries(callback: types.CallbackQuery, db_user: User, db:
|
||||
current_countries_names = []
|
||||
for country in countries:
|
||||
if country['uuid'] in current_countries:
|
||||
current_countries_names.append(country['name'])
|
||||
current_countries_names.append(html.escape(country['name']))
|
||||
|
||||
current_list = (
|
||||
'\n'.join(f'• {name}' for name in current_countries_names)
|
||||
@@ -659,8 +660,8 @@ def _build_countries_selection_text(countries: list[dict], base_text: str) -> st
|
||||
continue
|
||||
desc = country.get('description', '').strip()
|
||||
if desc:
|
||||
name = country.get('name', '')
|
||||
descriptions.append(f'<b>{name}</b>\n{desc}')
|
||||
name = html.escape(country.get('name', ''))
|
||||
descriptions.append(f'<b>{name}</b>\n{html.escape(desc)}')
|
||||
|
||||
if not descriptions:
|
||||
return base_text
|
||||
@@ -841,9 +842,9 @@ async def confirm_add_countries_to_subscription(
|
||||
|
||||
total_price += charged_price
|
||||
total_discount_value += int(discount_per_month * charged_days / 30)
|
||||
new_countries_names.append(country['name'])
|
||||
new_countries_names.append(html.escape(country['name']))
|
||||
if country['uuid'] in removed_countries:
|
||||
removed_countries_names.append(country['name'])
|
||||
removed_countries_names.append(html.escape(country['name']))
|
||||
|
||||
if new_countries and db_user.balance_kopeks < total_price:
|
||||
missing_kopeks = total_price - db_user.balance_kopeks
|
||||
|
||||
@@ -82,7 +82,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
|
||||
for uuid in squad_uuids:
|
||||
server = await get_server_squad_by_uuid(db, uuid)
|
||||
if server:
|
||||
server_names.append(server.display_name)
|
||||
server_names.append(html_mod.escape(server.display_name))
|
||||
logger.debug('Найден сервер в БД', uuid=uuid, display_name=server.display_name)
|
||||
else:
|
||||
logger.warning('Сервер с UUID не найден в БД', uuid=uuid)
|
||||
@@ -92,7 +92,7 @@ async def get_servers_display_names(squad_uuids: list[str]) -> str:
|
||||
for uuid in squad_uuids:
|
||||
for country in countries:
|
||||
if country['uuid'] == uuid:
|
||||
server_names.append(country['name'])
|
||||
server_names.append(html_mod.escape(country['name']))
|
||||
logger.debug('Найден сервер в кэше', uuid=uuid, country=country['name'])
|
||||
break
|
||||
|
||||
@@ -606,7 +606,7 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -614,6 +614,10 @@ async def execute_change_devices(callback: types.CallbackQuery, db_user: User, d
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
# При уменьшении лимита - удалить лишние устройства (последние подключённые)
|
||||
devices_reset_count = 0
|
||||
if new_devices_count < current_devices and db_user.remnawave_uuid:
|
||||
@@ -1285,7 +1289,7 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
subscription.updated_at = datetime.now(UTC)
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1293,6 +1297,10 @@ async def confirm_add_devices(callback: types.CallbackQuery, db_user: User, db:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
@@ -74,7 +75,7 @@ async def _prepare_subscription_summary(
|
||||
if country['uuid'] in selected_country_ids:
|
||||
server_price_per_month = country['price_kopeks']
|
||||
countries_price_per_month += server_price_per_month
|
||||
selected_countries_names.append(country['name'])
|
||||
selected_countries_names.append(html.escape(country['name']))
|
||||
server_monthly_prices.append(server_price_per_month)
|
||||
|
||||
servers_discount_percent = db_user.get_promo_discount(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import html
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
@@ -623,7 +624,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
tariff_squads = await get_server_squads_by_uuids(db, trial_tariff.allowed_squads)
|
||||
if tariff_squads:
|
||||
if len(tariff_squads) == 1:
|
||||
trial_server_name = tariff_squads[0].display_name
|
||||
trial_server_name = html.escape(tariff_squads[0].display_name)
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
'TRIAL_SERVER_RANDOM_POOL',
|
||||
@@ -633,7 +634,7 @@ async def show_trial_offer(callback: types.CallbackQuery, db_user: User, db: Asy
|
||||
trial_squads = await get_trial_eligible_server_squads(db, include_unavailable=True)
|
||||
if trial_squads:
|
||||
if len(trial_squads) == 1:
|
||||
trial_server_name = trial_squads[0].display_name
|
||||
trial_server_name = html.escape(trial_squads[0].display_name)
|
||||
else:
|
||||
trial_server_name = texts.t(
|
||||
'TRIAL_SERVER_RANDOM_POOL',
|
||||
|
||||
@@ -577,12 +577,16 @@ async def add_traffic(callback: types.CallbackQuery, db_user: User, db: AsyncSes
|
||||
# add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля
|
||||
await add_subscription_traffic(db, subscription, traffic_gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=db_user.id,
|
||||
@@ -834,12 +838,16 @@ async def execute_switch_traffic(callback: types.CallbackQuery, db_user: User, d
|
||||
|
||||
await db.commit()
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if db_user.remnawave_uuid and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(db_user.remnawave_uuid)
|
||||
|
||||
await db.refresh(db_user)
|
||||
await db.refresh(subscription)
|
||||
|
||||
|
||||
@@ -347,7 +347,7 @@ class BackupService:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
staging_dir = temp_path / 'backup'
|
||||
await asyncio.to_thread(staging_dir.mkdir, True, True)
|
||||
await asyncio.to_thread(lambda: staging_dir.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
database_info = await self._dump_database(staging_dir, include_logs=include_logs)
|
||||
database_info.setdefault('tables_count', overview.get('tables_count', 0))
|
||||
@@ -520,7 +520,7 @@ class BackupService:
|
||||
]
|
||||
|
||||
logger.info('📦 Экспорт PostgreSQL через pg_dump ...', pg_dump_path=pg_dump_path)
|
||||
await asyncio.to_thread(dump_path.parent.mkdir, True, True)
|
||||
await asyncio.to_thread(lambda: dump_path.parent.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
with open(dump_path, 'wb') as dump_file:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
@@ -582,7 +582,7 @@ class BackupService:
|
||||
if not await asyncio.to_thread(sqlite_path.exists):
|
||||
raise FileNotFoundError(f'SQLite база данных не найдена по пути {sqlite_path}')
|
||||
|
||||
await asyncio.to_thread(dump_path.parent.mkdir, True, True)
|
||||
await asyncio.to_thread(lambda: dump_path.parent.mkdir(parents=True, exist_ok=True))
|
||||
await asyncio.to_thread(shutil.copy2, sqlite_path, dump_path)
|
||||
logger.info('✅ SQLite база данных скопирована', dump_path=dump_path)
|
||||
|
||||
@@ -666,7 +666,7 @@ class BackupService:
|
||||
async def _collect_files(self, staging_dir: Path, include_logs: bool) -> list[dict[str, Any]]:
|
||||
files_info: list[dict[str, Any]] = []
|
||||
files_dir = staging_dir / 'files'
|
||||
await asyncio.to_thread(files_dir.mkdir, True, True)
|
||||
await asyncio.to_thread(lambda: files_dir.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
if include_logs and settings.LOG_FILE:
|
||||
log_path = Path(settings.LOG_FILE)
|
||||
@@ -861,7 +861,7 @@ class BackupService:
|
||||
raise FileNotFoundError(f'SQLite файл не найден: {dump_path}')
|
||||
|
||||
target_path = Path(settings.SQLITE_PATH)
|
||||
await asyncio.to_thread(target_path.parent.mkdir, True, True)
|
||||
await asyncio.to_thread(lambda: target_path.parent.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
if clear_existing and await asyncio.to_thread(target_path.exists):
|
||||
await asyncio.to_thread(target_path.unlink)
|
||||
@@ -918,7 +918,7 @@ class BackupService:
|
||||
logger.warning('Файл отсутствует в архиве', relative_path=relative_path)
|
||||
continue
|
||||
|
||||
await asyncio.to_thread(target_resolved.parent.mkdir, True, True)
|
||||
await asyncio.to_thread(lambda: target_resolved.parent.mkdir(parents=True, exist_ok=True))
|
||||
await asyncio.to_thread(shutil.copy2, source_file, target_resolved)
|
||||
logger.info('📁 Файл восстановлен', target_resolved=target_resolved)
|
||||
|
||||
|
||||
@@ -17,7 +17,17 @@ from app.config import settings
|
||||
from app.database.crud.landing import create_guest_purchase
|
||||
from app.database.crud.subscription import create_paid_subscription, get_subscription_by_user_id, replace_subscription
|
||||
from app.database.crud.tariff import get_tariff_by_id
|
||||
from app.database.models import GuestPurchase, GuestPurchaseStatus, LandingPage, Tariff, User
|
||||
from app.database.crud.transaction import create_transaction
|
||||
from app.database.crud.user import _get_or_create_default_promo_group
|
||||
from app.database.models import (
|
||||
GuestPurchase,
|
||||
GuestPurchaseStatus,
|
||||
LandingPage,
|
||||
PaymentMethod,
|
||||
Tariff,
|
||||
TransactionType,
|
||||
User,
|
||||
)
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
|
||||
@@ -242,7 +252,7 @@ async def fulfill_purchase(
|
||||
# Active subscription or gift with any existing subscription — hold for manual activation
|
||||
purchase.status = GuestPurchaseStatus.PENDING_ACTIVATION.value
|
||||
purchase.user_id = user.id
|
||||
if recipient_type == 'email' and not purchase.is_gift:
|
||||
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
@@ -310,12 +320,28 @@ async def fulfill_purchase(
|
||||
purchase.status = GuestPurchaseStatus.DELIVERED.value
|
||||
purchase.user_id = user.id
|
||||
purchase.delivered_at = datetime.now(UTC)
|
||||
if recipient_type == 'email' and not purchase.is_gift:
|
||||
if recipient_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for guest purchase', purchase_id=purchase.id)
|
||||
|
||||
try:
|
||||
await send_guest_notification(
|
||||
purchase,
|
||||
@@ -357,6 +383,26 @@ async def fulfill_purchase(
|
||||
return purchase
|
||||
|
||||
|
||||
def _resolve_payment_method(method_str: str | None) -> PaymentMethod | None:
|
||||
"""Convert payment method string from GuestPurchase to PaymentMethod enum."""
|
||||
if not method_str:
|
||||
return None
|
||||
# Try exact match first (handles 'telegram_stars', 'kassa_ai', 'yookassa', etc.)
|
||||
try:
|
||||
return PaymentMethod(method_str)
|
||||
except ValueError:
|
||||
pass
|
||||
# Strip sub-option suffix ('yookassa_sbp' → 'yookassa', 'platega_2' → 'platega')
|
||||
if '_' in method_str:
|
||||
base_method = method_str.split('_')[0]
|
||||
try:
|
||||
return PaymentMethod(base_method)
|
||||
except ValueError:
|
||||
pass
|
||||
logger.debug('Unknown payment method for transaction', method=method_str)
|
||||
return None
|
||||
|
||||
|
||||
def _mask_email(email: str) -> str:
|
||||
"""Mask email for logging: 'user@example.com' -> 'u***@e***.com'."""
|
||||
if not email:
|
||||
@@ -399,8 +445,8 @@ async def _find_or_create_user(
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
is_new_account = False
|
||||
# Existing user WITHOUT password — generate one and set up cabinet access
|
||||
if not user.password_hash:
|
||||
# User without cabinet access — generate credentials
|
||||
plain_password = secrets.token_urlsafe(12)
|
||||
user.password_hash = hash_password(plain_password)
|
||||
if purchase:
|
||||
@@ -410,16 +456,22 @@ async def _find_or_create_user(
|
||||
if not user.email_verified:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
# Ensure default promo group
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, is_new_account
|
||||
|
||||
# Create new email user with verified cabinet account
|
||||
plain_password = secrets.token_urlsafe(12)
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user = User(
|
||||
auth_type='email',
|
||||
email=contact_value,
|
||||
email_verified=True,
|
||||
email_verified_at=datetime.now(UTC),
|
||||
password_hash=hash_password(plain_password),
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
if purchase:
|
||||
purchase.cabinet_password = plain_password
|
||||
@@ -431,7 +483,7 @@ async def _find_or_create_user(
|
||||
result = await db.execute(select(User).where(User.email == contact_value))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
# Clear stale password from failed insert, then check if re-fetched user needs one
|
||||
# Race condition — user was created concurrently
|
||||
if purchase:
|
||||
purchase.cabinet_password = None
|
||||
is_new_account = False
|
||||
@@ -444,6 +496,9 @@ async def _find_or_create_user(
|
||||
if not user.email_verified:
|
||||
user.email_verified = True
|
||||
user.email_verified_at = datetime.now(UTC)
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, is_new_account
|
||||
raise
|
||||
logger.info(
|
||||
@@ -508,13 +563,19 @@ async def _find_or_create_user(
|
||||
resolved_telegram_id=resolved_telegram_id,
|
||||
)
|
||||
await db.refresh(user)
|
||||
# Ensure default promo group
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
|
||||
# Create new telegram user
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user = User(
|
||||
auth_type='telegram',
|
||||
username=username,
|
||||
telegram_id=resolved_telegram_id,
|
||||
promo_group_id=default_group.id,
|
||||
)
|
||||
try:
|
||||
async with db.begin_nested():
|
||||
@@ -525,10 +586,16 @@ async def _find_or_create_user(
|
||||
result = await db.execute(select(User).where(User.telegram_id == resolved_telegram_id))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
result = await db.execute(select(User).where(func.lower(User.username) == normalized))
|
||||
user = result.scalars().first()
|
||||
if user:
|
||||
if not user.promo_group_id:
|
||||
default_group = await _get_or_create_default_promo_group(db)
|
||||
user.promo_group_id = default_group.id
|
||||
return user, False
|
||||
raise
|
||||
logger.info(
|
||||
@@ -854,13 +921,29 @@ async def activate_purchase(db: AsyncSession, purchase_token: str, *, skip_notif
|
||||
purchase.subscription_crypto_link = subscription.subscription_crypto_link
|
||||
purchase.status = GuestPurchaseStatus.DELIVERED.value
|
||||
purchase.delivered_at = datetime.now(UTC)
|
||||
if user.auth_type == 'email' and not purchase.is_gift:
|
||||
if user.auth_type == 'email' and not purchase.is_gift and is_new_account:
|
||||
purchase.auto_login_token = create_auto_login_token(user.id)
|
||||
|
||||
# Single atomic commit: subscription + purchase status + user changes
|
||||
await db.commit()
|
||||
await db.refresh(purchase, attribute_names=['landing', 'user'])
|
||||
|
||||
# Create transaction so promo group auto-assignment and contest tracking work
|
||||
try:
|
||||
payment_method_enum = _resolve_payment_method(purchase.payment_method)
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=purchase.amount_kopeks,
|
||||
description=f'Покупка подписки через лендинг ({notification_tariff_name}, {purchase.period_days} дн.)',
|
||||
payment_method=payment_method_enum,
|
||||
external_id=purchase.payment_id,
|
||||
is_completed=True,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception('Failed to create transaction for activated purchase', purchase_id=purchase.id)
|
||||
|
||||
if not skip_notification:
|
||||
try:
|
||||
await send_guest_notification(
|
||||
|
||||
@@ -78,8 +78,8 @@ class LogRotationService:
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Создать необходимые директории."""
|
||||
await asyncio.to_thread(self.current_dir.mkdir, True, True)
|
||||
await asyncio.to_thread(self.archive_dir.mkdir, True, True)
|
||||
await asyncio.to_thread(lambda: self.current_dir.mkdir(parents=True, exist_ok=True))
|
||||
await asyncio.to_thread(lambda: self.archive_dir.mkdir(parents=True, exist_ok=True))
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Запустить сервис ротации."""
|
||||
|
||||
@@ -686,6 +686,53 @@ class PaymentService(
|
||||
}
|
||||
return None
|
||||
|
||||
# --- Telegram Stars ---------------------------------------------------
|
||||
if payment_method == 'telegram_stars':
|
||||
if not settings.TELEGRAM_STARS_ENABLED:
|
||||
logger.warning('Telegram Stars is not enabled, cannot create guest payment')
|
||||
return None
|
||||
|
||||
if self.bot is None:
|
||||
logger.warning('Bot instance required for Stars guest payment')
|
||||
return None
|
||||
|
||||
from aiogram.types import LabeledPrice
|
||||
|
||||
rate = settings.get_stars_rate()
|
||||
if rate <= 0:
|
||||
logger.error('TELEGRAM_STARS_RATE_RUB is not positive, cannot create Stars invoice')
|
||||
return None
|
||||
|
||||
amount_rubles = amount_kopeks / 100
|
||||
stars_amount = max(1, round(amount_rubles / rate))
|
||||
|
||||
payload = f'guest_purchase_{purchase_token}'
|
||||
|
||||
try:
|
||||
invoice_url = await self.bot.create_invoice_link(
|
||||
title='Подарочная подписка VPN',
|
||||
description=f'{description} ({stars_amount} ⭐)',
|
||||
payload=payload,
|
||||
provider_token='',
|
||||
currency='XTR',
|
||||
prices=[LabeledPrice(label='Подарочная подписка', amount=stars_amount)],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
'Created Stars invoice for guest purchase',
|
||||
stars_amount=stars_amount,
|
||||
purchase_token_prefix=purchase_token[:5],
|
||||
)
|
||||
return {
|
||||
'payment_url': invoice_url,
|
||||
'payment_id': f'stars_{purchase_token[:12]}',
|
||||
'provider': 'telegram_stars',
|
||||
}
|
||||
|
||||
except Exception as stars_error:
|
||||
logger.error('Error creating Stars invoice for guest payment', error=stars_error)
|
||||
return None
|
||||
|
||||
# --- Unsupported provider ---------------------------------------------
|
||||
logger.warning(
|
||||
'Guest payment requested for unsupported provider',
|
||||
|
||||
@@ -1361,7 +1361,7 @@ async def _auto_add_devices(
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1370,6 +1370,9 @@ async def _auto_add_devices(
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка устройств: не удалось обновить Remnawave для пользователя',
|
||||
@@ -1573,7 +1576,7 @@ async def _auto_add_traffic(
|
||||
await db.rollback()
|
||||
return False
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -1582,6 +1585,9 @@ async def _auto_add_traffic(
|
||||
try:
|
||||
subscription_service = SubscriptionService()
|
||||
await subscription_service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await subscription_service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as error:
|
||||
logger.warning(
|
||||
'⚠️ Автопокупка трафика: не удалось обновить Remnawave для пользователя',
|
||||
|
||||
@@ -2867,10 +2867,9 @@ async def _build_referral_info(
|
||||
referral_code = getattr(user, 'referral_code', None)
|
||||
referral_settings = settings.get_referral_settings() or {}
|
||||
|
||||
bot_username = settings.get_bot_username()
|
||||
referral_link = None
|
||||
if referral_code and bot_username:
|
||||
referral_link = f'https://t.me/{bot_username}?start={referral_code}'
|
||||
if referral_code:
|
||||
referral_link = settings.get_referral_link(referral_code)
|
||||
|
||||
minimum_topup_kopeks = int(referral_settings.get('minimum_topup_kopeks') or 0)
|
||||
first_topup_bonus_kopeks = int(referral_settings.get('first_topup_bonus_kopeks') or 0)
|
||||
@@ -7061,6 +7060,16 @@ async def switch_tariff_endpoint(
|
||||
amount_kopeks=upgrade_cost,
|
||||
description=description,
|
||||
)
|
||||
else:
|
||||
# Бесплатный переход (downgrade) — записываем в историю
|
||||
description = f"Переход на тариф '{new_tariff.name}'"
|
||||
await create_transaction(
|
||||
db=db,
|
||||
user_id=user.id,
|
||||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||||
amount_kopeks=0,
|
||||
description=description,
|
||||
)
|
||||
|
||||
# Получаем список серверов из тарифа
|
||||
squads = new_tariff.allowed_squads or []
|
||||
@@ -7304,7 +7313,7 @@ async def purchase_traffic_topup_endpoint(
|
||||
# Добавляем трафик (add_subscription_traffic уже создаёт TrafficPurchase и обновляет все необходимые поля)
|
||||
await add_subscription_traffic(db, subscription, payload.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
from app.database.crud.subscription import reactivate_subscription
|
||||
|
||||
await reactivate_subscription(db, subscription)
|
||||
@@ -7313,6 +7322,9 @@ async def purchase_traffic_topup_endpoint(
|
||||
try:
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
# Явно включаем пользователя на панели (PATCH может не снять LIMITED-статус)
|
||||
if getattr(user, 'remnawave_uuid', None) and subscription.status == 'active':
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
except Exception as e:
|
||||
logger.error('Ошибка синхронизации с RemnaWave при докупке трафика', error=e)
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ from app.database.crud.subscription import (
|
||||
remove_subscription_squad,
|
||||
replace_subscription,
|
||||
)
|
||||
from app.database.crud.user import get_user_by_id
|
||||
from app.database.models import Subscription, SubscriptionStatus
|
||||
from app.services.subscription_service import SubscriptionService
|
||||
|
||||
@@ -255,13 +256,17 @@ async def add_subscription_traffic_endpoint(
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_traffic(db, subscription, payload.gb)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid and subscription.status == 'active':
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
@@ -276,13 +281,17 @@ async def add_subscription_devices_endpoint(
|
||||
subscription = await _get_subscription(db, subscription_id)
|
||||
subscription = await add_subscription_devices(db, subscription, payload.devices)
|
||||
|
||||
# Реактивируем подписку если она была DISABLED (например, после LIMITED в RemnaWave)
|
||||
# Реактивируем подписку если она была DISABLED/EXPIRED (например, после LIMITED/EXPIRED в RemnaWave)
|
||||
await reactivate_subscription(db, subscription)
|
||||
|
||||
# Синхронизируем с RemnaWave
|
||||
# Синхронизируем с RemnaWave и явно включаем пользователя на панели
|
||||
service = SubscriptionService()
|
||||
await service.update_remnawave_user(db, subscription)
|
||||
|
||||
user = await get_user_by_id(db, subscription.user_id)
|
||||
if user and user.remnawave_uuid and subscription.status == 'active':
|
||||
await service.enable_remnawave_user(user.remnawave_uuid)
|
||||
|
||||
subscription = await _get_subscription(db, subscription.id)
|
||||
return _serialize_subscription(subscription)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add show_in_gift to tariffs
|
||||
|
||||
Boolean flag to control tariff visibility in the gift section.
|
||||
Defaults to True so all existing tariffs remain visible.
|
||||
|
||||
Revision ID: 0038
|
||||
Revises: 0037
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = '0038'
|
||||
down_revision: Union[str, None] = '0037'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def _has_column(table: str, column: str) -> bool:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
return column in [c['name'] for c in inspector.get_columns(table)]
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
if not _has_column('tariffs', 'show_in_gift'):
|
||||
op.add_column(
|
||||
'tariffs',
|
||||
sa.Column('show_in_gift', sa.Boolean(), nullable=False, server_default=sa.text('true')),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('tariffs', 'show_in_gift')
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = 'remnawave-bedolaga-telegram-bot'
|
||||
version = "3.29.0"
|
||||
version = "3.31.0"
|
||||
description = 'Telegram bot for RemnaWave VPN service'
|
||||
readme = 'README.md'
|
||||
license = { text = 'MIT' }
|
||||
|
||||
Reference in New Issue
Block a user