From 344852b85223b49fc52f1401d63b198e1a8412c5 Mon Sep 17 00:00:00 2001 From: c0mrade Date: Mon, 23 Mar 2026 23:11:18 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20trial=20subscription=20lifecycle=20?= =?UTF-8?q?=E2=80=94=20autopay,=20cleanup=20on=20purchase,=20bonus=20days?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - create_trial_subscription: always set autopay_enabled=False (trial is a probe, autopay makes no sense regardless of operator default setting) - autopay endpoint: block enabling autopay on trial subscriptions via API - purchase-tariff (cabinet): before creating/extending paid subscription, find and deactivate ALL user's trial subscriptions, collect remaining time for TRIAL_ADD_REMAINING_DAYS_TO_PAID, disable trials on RemnaWave panel, decrement server counts — works for both tariff-based and squad-based trials uniformly - subscription_purchase_service (miniapp): same trial cleanup logic - New CRUD: deactivate_user_trial_subscriptions() — finds all active trials for user, marks them disabled with is_trial=False --- .../routes/subscription_modules/autopay.py | 11 +++- .../routes/subscription_modules/purchase.py | 53 +++++++++++++++++-- app/database/crud/subscription.py | 50 ++++++++++++++++- app/services/subscription_purchase_service.py | 33 +++++++++++- 4 files changed, 140 insertions(+), 7 deletions(-) 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