diff --git a/app/cabinet/routes/subscription_modules/autopay.py b/app/cabinet/routes/subscription_modules/autopay.py index df19bce4..f93de8f5 100644 --- a/app/cabinet/routes/subscription_modules/autopay.py +++ b/app/cabinet/routes/subscription_modules/autopay.py @@ -45,9 +45,16 @@ async def update_autopay( detail='No subscription found', ) - # Суточные подписки имеют свой механизм продления (DailySubscriptionService), - # глобальный autopay для них запрещён if request.enabled: + # Триальные подписки — пробник, автопродление не имеет смысла + if subscription.is_trial: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail='Autopay is not available for trial subscriptions', + ) + + # Суточные подписки имеют свой механизм продления (DailySubscriptionService), + # глобальный autopay для них запрещён await db.refresh(subscription, ['tariff']) if subscription.tariff and getattr(subscription.tariff, 'is_daily', False): raise HTTPException( diff --git a/app/cabinet/routes/subscription_modules/purchase.py b/app/cabinet/routes/subscription_modules/purchase.py index 2ad2bf86..3f1376eb 100644 --- a/app/cabinet/routes/subscription_modules/purchase.py +++ b/app/cabinet/routes/subscription_modules/purchase.py @@ -10,7 +10,7 @@ POST /subscription/trial from __future__ import annotations -from datetime import UTC, datetime +from datetime import UTC, datetime, timedelta from typing import Any import structlog @@ -22,6 +22,7 @@ from app.database.crud.server_squad import get_server_squad_by_uuid from app.database.crud.subscription import ( create_paid_subscription, create_trial_subscription, + decrement_subscription_server_counts, extend_subscription, get_subscription_by_user_id, ) @@ -771,6 +772,30 @@ async def purchase_tariff( payment_method=PaymentMethod.BALANCE, ) + # --- Trial cleanup: find and kill all trials BEFORE creating/extending --- + from app.database.crud.subscription import deactivate_user_trial_subscriptions + + # Collect remaining trial seconds for TRIAL_ADD_REMAINING_DAYS_TO_PAID + _bonus_seconds = 0 + _now_trial = datetime.now(UTC) + killed_trials = await deactivate_user_trial_subscriptions( + db, + user.id, + exclude_subscription_id=subscription.id if subscription else None, + ) + if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID: + for _kt in killed_trials: + if _kt.end_date and _kt.end_date > _now_trial: + _bonus_seconds += max(0, (_kt.end_date - _now_trial).total_seconds()) + + # If existing subscription IS the trial being extended — it's already deactivated + # as trial by deactivate_user_trial_subscriptions (is_trial=False, status=DISABLED). + # We need to re-activate it for extend to work correctly. + if subscription and subscription.id in {kt.id for kt in killed_trials}: + subscription.status = 'active' + subscription.is_trial = False + await db.flush() + if subscription: # Extend/change tariff — сохраняем докупленные устройства при продлении того же тарифа subscription = await extend_subscription( @@ -794,6 +819,17 @@ async def purchase_tariff( tariff_id=tariff.id, ) + # Add remaining trial time to paid subscription + if _bonus_seconds > 0 and subscription: + subscription.end_date = subscription.end_date + timedelta(seconds=_bonus_seconds) + await db.commit() + await db.refresh(subscription) + logger.info( + 'Added remaining trial time to paid subscription', + bonus_seconds=int(_bonus_seconds), + subscription_id=subscription.id, + ) + # For daily tariffs, set last_daily_charge_at if is_daily_tariff: subscription.last_daily_charge_at = datetime.now(UTC) @@ -801,9 +837,20 @@ async def purchase_tariff( await db.commit() await db.refresh(subscription) - # Sync with RemnaWave - # При покупке тарифа ВСЕГДА сбрасываем трафик в панели + # --- Disable killed trials on RemnaWave panel --- service = SubscriptionService() + for trial_sub in killed_trials: + if trial_sub.id == (subscription.id if subscription else None): + continue # This trial became the paid subscription, don't disable + try: + _trial_uuid = trial_sub.remnawave_uuid or ( + getattr(user, 'remnawave_uuid', None) if not settings.is_multi_tariff_enabled() else None + ) + if _trial_uuid: + await service.disable_remnawave_user(_trial_uuid) + await decrement_subscription_server_counts(db, trial_sub) + except Exception as trial_err: + logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id) try: if subscription.remnawave_uuid: # Existing subscription with Remnawave user — update it diff --git a/app/database/crud/subscription.py b/app/database/crud/subscription.py index 467f299b..5ad4d77b 100644 --- a/app/database/crud/subscription.py +++ b/app/database/crud/subscription.py @@ -203,7 +203,7 @@ async def create_trial_subscription( traffic_limit_gb=traffic_limit_gb, device_limit=device_limit, connected_squads=final_squads, - autopay_enabled=settings.is_autopay_enabled_by_default(), + autopay_enabled=False, autopay_days_before=settings.DEFAULT_AUTOPAY_DAYS_BEFORE, tariff_id=tariff_id, remnawave_short_id=short_id, @@ -2091,6 +2091,54 @@ async def get_subscription_by_user_and_tariff(db: AsyncSession, user_id: int, ta return result.scalar_one_or_none() +async def deactivate_user_trial_subscriptions( + db: AsyncSession, + user_id: int, + *, + exclude_subscription_id: int | None = None, +) -> list[Subscription]: + """Deactivate all trial subscriptions for a user. + + Called when user purchases a paid tariff — trial is a probe that must die on purchase. + Returns remaining trial time in seconds (for TRIAL_ADD_REMAINING_DAYS_TO_PAID). + Handles both tariff-based and squad-based trials uniformly. + """ + result = await db.execute( + select(Subscription).where( + Subscription.user_id == user_id, + Subscription.is_trial.is_(True), + Subscription.status.in_( + [ + SubscriptionStatus.ACTIVE.value, + SubscriptionStatus.TRIAL.value, + ] + ), + ) + ) + trial_subs = list(result.scalars().all()) + + deactivated = [] + for sub in trial_subs: + if exclude_subscription_id and sub.id == exclude_subscription_id: + continue + sub.status = SubscriptionStatus.DISABLED.value + sub.is_trial = False + sub.autopay_enabled = False + sub.updated_at = datetime.now(UTC) + deactivated.append(sub) + logger.info( + 'Trial subscription deactivated on paid purchase', + subscription_id=sub.id, + user_id=user_id, + tariff_id=sub.tariff_id, + ) + + if deactivated: + await db.flush() + + return deactivated + + async def get_all_subscriptions_by_user_id(db: AsyncSession, user_id: int) -> list[Subscription]: """Get all subscriptions for a user (any status). diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index 232f52fc..63c8d6e9 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -1109,8 +1109,39 @@ class MiniAppSubscriptionPurchaseService: except Exception as error: # pragma: no cover - defensive logging logger.error('Failed to register subscription servers', error=error) + # Kill remaining trial subscriptions (trial = probe, dies on any paid purchase) + from app.database.crud.subscription import ( + deactivate_user_trial_subscriptions, + decrement_subscription_server_counts, + ) + + killed_trials = await deactivate_user_trial_subscriptions(db, user.id, exclude_subscription_id=subscription.id) + + # Add remaining trial time from OTHER killed trials (current trial already handled above) + if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and killed_trials: + extra_seconds = 0 + for _kt in killed_trials: + if _kt.end_date and _kt.end_date > now: + extra_seconds += max(0, (_kt.end_date - now).total_seconds()) + if extra_seconds > 0: + subscription.end_date = subscription.end_date + timedelta(seconds=extra_seconds) + await db.commit() + await db.refresh(subscription) + subscription_service = SubscriptionService() - # При покупке подписки ВСЕГДА сбрасываем трафик в панели + + # Disable killed trials on RemnaWave panel + for trial_sub in killed_trials: + try: + _trial_uuid = trial_sub.remnawave_uuid or ( + getattr(user, 'remnawave_uuid', None) if not settings.is_multi_tariff_enabled() else None + ) + if _trial_uuid: + await subscription_service.disable_remnawave_user(_trial_uuid) + await decrement_subscription_server_counts(db, trial_sub) + except Exception as trial_err: + logger.warning('Failed to disable trial on RemnaWave', error=trial_err, trial_id=trial_sub.id) + try: _purch_uuid = ( subscription.remnawave_uuid