fix: trial subscription lifecycle — autopay, cleanup on purchase, bonus days
- 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
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user