Compare commits

...

26 Commits

Author SHA1 Message Date
Egor efa1b11db5 Merge pull request #2724 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.31.0
2026-03-12 08:30:35 +03:00
github-actions[bot] d0ce193edb chore(main): release 3.31.0 2026-03-12 05:30:07 +00:00
Egor 92d872236f Merge pull request #2723 from BEDOLAGA-DEV/dev
Dev
2026-03-12 08:29:34 +03:00
Egor a11f492801 Merge pull request #2722 from BEDOLAGA-DEV/main
ц
2026-03-12 08:25:26 +03:00
Fringg c8162505ed chore: apply ruff formatting to 4 files 2026-03-12 08:24:47 +03:00
Fringg 076290e0c1 feat: auto-sync squads to Remnawave when admin updates tariff
When admin changes allowed_squads or external_squad_uuid on a tariff,
automatically sync the new squad config to all active/trial subscriptions
in Remnawave panel via a background task (fire-and-forget).
2026-03-12 08:20:25 +03:00
Fringg bf72f241d8 fix: preserve purchased devices when admin changes user tariff
Previously subscription.device_limit was blindly overwritten with the new
tariff's base limit, losing any extra devices the user had purchased.
Now extra devices are calculated from the old tariff base and carried over,
capped at tariff.max_device_limit or global MAX_DEVICES_LIMIT.
2026-03-12 06:55:56 +03:00
Fringg 12ae871653 feat: referral links now point to web cabinet instead of bot
Centralized referral link generation into settings.get_referral_link().
When CABINET_URL is configured, links use {CABINET_URL}?ref={code}.
Falls back to Telegram bot deep link when CABINET_URL is not set.

- URL-encodes referral_code for safety
- Handles CABINET_URL with existing query params (uses & vs ?)
- Guards against None referral_code in all call sites
- QR code caching uses link hash for auto-invalidation
2026-03-12 06:45:17 +03:00
Fringg 8a362db783 fix: correct skipped_count in sync-squads circuit breaker and simplify ternary
- Add missing skipped_count increment when early-aborting due to circuit breaker
- Remove redundant `new_squads if new_squads else []` (already [] when empty)
- Add comment explaining asyncio safety of shared counter mutations
2026-03-12 06:28:13 +03:00
Fringg b1e2146254 feat: add sync-squads endpoint for bulk updating subscription squads in Remnawave
POST /admin/tariffs/{tariff_id}/sync-squads updates active_internal_squads
and external_squad_uuid for all active/trial subscriptions on a tariff.

- Concurrent API calls (semaphore=5) with circuit breaker (10 consecutive failures)
- Local DB updated only on successful API response to avoid split-brain
- Error messages sanitized (details in server logs only)
- Uses joinedload to avoid N+1 query for User.remnawave_uuid
2026-03-12 06:17:13 +03:00
Fringg 68bc8eb57c fix: update promo group via M2M table so admin changes persist
The admin endpoint wrote only to the legacy users.promo_group_id FK,
which got overwritten by sync_user_primary_promo_group on the next
transaction. Now writes to user_promo_groups M2M table (authoritative
source) and re-derives the FK via sync.

Also re-raise exceptions in _sync_user_primary_promo_group to prevent
committing inconsistent state between M2M and FK columns.
2026-03-12 06:03:26 +03:00
Fringg 9957259881 fix: add post_update=True to User.referrals self-referential relationship
Without post_update, SQLAlchemy's flush ordering cannot resolve the
circular dependency when both a user and their referrer are in the same
session. This caused referred_by_id to be silently NULLed during flush,
breaking Telegram login (which eagerly loads User.referrer) and causing
apparent admin rights loss in the cabinet.

OAuth login was unaffected because it uses a bare select(User) without
eager loading the referrer relationship.

Root cause confirmed by 6 parallel investigation agents tracing the
exact code paths through get_user_by_telegram_id → selectinload →
flush → circular dependency.
2026-03-12 05:39:29 +03:00
Fringg b3f3eba575 fix: prevent account takeover via auto_login_token, ensure promo group on all purchase paths
- Gate auto_login_token generation behind is_new_account flag in all 3 locations
  (fulfill_purchase PENDING/DELIVERED paths + activate_purchase) — prevents attacker
  from buying cheapest plan with victim's email to get their session token
- Assign default promo group in all _find_or_create_user paths including telegram
  IntegrityError fallbacks (7 return paths total)
- Create transaction records for landing purchases so promo group auto-assignment
  and contest tracking work correctly
- Add _resolve_payment_method helper for enum conversion with sub-option suffix stripping
2026-03-12 05:26:16 +03:00
Fringg a798f1143e refactor: remove estimated price from balance, simplify server sync, fix HTML injection
- Remove estimated renewal price display from balance top-up screen
- Remove country name generation during server/squad sync, use original RemnaWave name as display_name
- Add html.escape() for all display_name/country name values rendered in HTML-parsed Telegram messages
2026-03-12 04:58:49 +03:00
Fringg cb5126aff8 feat: add show_in_gift toggle for tariffs in admin panel
Add a per-tariff visibility flag (show_in_gift) that controls whether
a tariff appears in the /gift section. Enforced server-side in gift
config query, gift purchase endpoint, and landing page gift purchases.

Includes Alembic migration with idempotency guard and server_default.
2026-03-12 04:15:40 +03:00
Fringg 8b35428055 fix: reactivate subscription after traffic top-up when status is EXPIRED
When traffic is exhausted, RemnaWave may send user.expired webhook setting
local status to EXPIRED (not just DISABLED). reactivate_subscription() only
handled DISABLED→ACTIVE, silently ignoring EXPIRED subscriptions. After
purchasing additional GB, the subscription stayed expired and VPN remained
blocked despite payment.

Changes:
- reactivate_subscription() now handles both DISABLED and EXPIRED→ACTIVE
  when end_date is still in the future
- Inverted null end_date guard to block reactivation (defense-in-depth)
- Added enable_remnawave_user() call after update in all traffic/device
  top-up paths to ensure panel exits LIMITED state
- Gated enable call on subscription.status == 'active' to prevent
  enabling when reactivation was a no-op
- Fixed all 12 call sites across bot handlers, cabinet routes,
  miniapp, webapi, and auto-purchase service
2026-03-12 03:57:35 +03:00
Fringg 5424d8c314 fix: add Telegram Stars payment support for gift subscriptions
- Add telegram_stars handler in create_guest_payment() using
  bot.create_invoice_link() with guest_purchase_{token} payload
- Add guest_purchase_ prefix handling in Stars pre-checkout and
  successful_payment handlers with amount tolerance check (±5%)
- Pass Bot instance to PaymentService when payment method is Stars
- Add purchase_token format validation via regex guard
2026-03-12 03:32:08 +03:00
Egor df7411138e Merge pull request #2718 from BEDOLAGA-DEV/release-please--branches--main
chore(main): release 3.30.0
2026-03-11 03:56:12 +03:00
github-actions[bot] 4545bef7ea chore(main): release 3.30.0 2026-03-11 00:55:56 +00:00
Egor d7eb1e776a Merge pull request #2716 from BEDOLAGA-DEV/dev
Dev
2026-03-11 03:55:35 +03:00
Egor f8fc382143 Merge pull request #2717 from BEDOLAGA-DEV/main
w
2026-03-11 03:55:10 +03:00
Fringg e67b8e448e fix: reset subscription for paid users, trial-to-paid tariff conversion, gift purchase MissingGreenlet
- Remove is_active_paid_subscription guard from reset-subscription endpoint
- Add is_trial=False and status=ACTIVE on admin tariff change (guarded by
  tariff.is_trial_available and end_date check)
- Eagerly load user_promo_groups and promo_group in gift purchase locked query
- Move user_promo_groups access inside try/except in get_primary_promo_group()
2026-03-11 03:46:39 +03:00
Fringg bca8bab433 feat: add gifts section to admin user detail API
Add GET /{user_id}/gifts endpoint returning sent and received gift
subscriptions with COUNT queries for true totals, token truncation
for security, and noload() optimization for unused relationships.
2026-03-11 03:30:12 +03:00
Fringg 2fd0f6aa4e feat: add promo group and promo offer discounts to gift subscriptions
Apply promo group and active promo offer discounts to gift purchase flow.
Discounts stack multiplicatively with max(1, price) floor. FOR UPDATE
row lock prevents concurrent promo offer double-spend. Promo offers
consumed after purchase in both balance and gateway modes.
2026-03-11 02:58:34 +03:00
Fringg 864a4ed700 fix: record transactions for free tariff switches and admin tariff changes
- Add transaction records for free tariff switches (downgrade, upgrade_cost=0) in miniapp and cabinet
- Add atomic transaction records for admin tariff changes in bot handler and cabinet API
- Use commit=False for admin flows to ensure subscription change and transaction are committed together
2026-03-11 02:17:35 +03:00
Fringg 2879996455 fix: use keyword args for Path.mkdir in asyncio.to_thread
Positional args caused mode=True(1) and exist_ok=False(default),
raising FileExistsError when directories already existed.

Fixed 8 instances: 2 in log_rotation_service, 6 in backup_service.
2026-03-11 01:22:02 +03:00
41 changed files with 1135 additions and 462 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.29.0"
".": "3.31.0"
}
+41
View File
@@ -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
View File
@@ -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
+244 -2
View File
@@ -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],
)
+184 -22
View File
@@ -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
View File
@@ -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()
+7
View File
@@ -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')
+1 -2
View File
@@ -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 '',
+14 -1
View File
@@ -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'
+3
View File
@@ -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):
+19
View File
@@ -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)
+37
View File
@@ -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
+20
View File
@@ -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
+1 -215
View File
@@ -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()
+14 -5
View File
@@ -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
+9
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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 на старую связь если новая пустая или возникла ошибка
+1 -1
View File
@@ -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:
+2 -1
View File
@@ -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(
+9 -9
View File
@@ -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'
'Должна быть выбрана минимум одна промогруппа.'
)
+41 -5
View File
@@ -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(
+1 -136
View File
@@ -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)
+20 -6
View File
@@ -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 сервису!')
+2 -1
View File
@@ -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>')
+97 -1
View File
@@ -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()
+6 -5
View File
@@ -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
+12 -4
View File
@@ -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,
+2 -1
View File
@@ -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(
+3 -2
View File
@@ -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',
+10 -2
View File
@@ -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)
+6 -6
View File
@@ -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)
+89 -6
View File
@@ -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(
+2 -2
View File
@@ -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:
"""Запустить сервис ротации."""
+47
View File
@@ -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 для пользователя',
+16 -4
View File
@@ -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)
+13 -4
View File
@@ -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
View File
@@ -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' }
Generated
+1 -1
View File
@@ -1115,7 +1115,7 @@ wheels = [
[[package]]
name = "remnawave-bedolaga-telegram-bot"
version = "3.28.0"
version = "3.29.0"
source = { virtual = "." }
dependencies = [
{ name = "aiogram" },