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:
c0mrade
2026-03-23 23:11:18 +03:00
parent 78a7eafcb6
commit 344852b852
4 changed files with 140 additions and 7 deletions
@@ -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
+49 -1
View File
@@ -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).
+32 -1
View File
@@ -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