- FastAPI REST API with JWT auth - aiogram 3 Telegram bot with admin middleware - APScheduler daily tasks (expiry, reminders, revoke, sync) - SQLAlchemy 2 async ORM with Alembic migrations - Jinja2 admin panel (Dashboard, Users, Payments, Servers, Tariffs) - VPN provider abstraction with MockProvider - Stats service with revenue/subscription analytics - Docker Compose (PostgreSQL + Redis + app) - Healthcheck endpoint
150 lines
4.6 KiB
Python
150 lines
4.6 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
|
|
from loguru import logger
|
|
|
|
from app.models.payment import PaymentStatus
|
|
from app.models.tariff import Tariff
|
|
from app.models.user import User
|
|
from app.repositories.payment import PaymentRepository
|
|
from app.repositories.tariff import TariffRepository
|
|
from app.repositories.user import UserRepository
|
|
|
|
|
|
class SubscriptionInfo:
|
|
def __init__(
|
|
self,
|
|
is_active: bool,
|
|
tariff: Tariff | None = None,
|
|
start_date: datetime | None = None,
|
|
end_date: datetime | None = None,
|
|
remaining_days: int = 0,
|
|
auto_renew: bool = False,
|
|
):
|
|
self.is_active = is_active
|
|
self.tariff = tariff
|
|
self.start_date = start_date
|
|
self.end_date = end_date
|
|
self.remaining_days = remaining_days
|
|
self.auto_renew = auto_renew
|
|
|
|
|
|
class BillingService:
|
|
def __init__(
|
|
self,
|
|
payment_repo: PaymentRepository,
|
|
tariff_repo: TariffRepository,
|
|
user_repo: UserRepository,
|
|
):
|
|
self.payment_repo = payment_repo
|
|
self.tariff_repo = tariff_repo
|
|
self.user_repo = user_repo
|
|
|
|
async def activate_subscription(
|
|
self, user_id: int, tariff_id: int, payment_id: int
|
|
) -> SubscriptionInfo:
|
|
user = await self.user_repo.get(user_id)
|
|
if user is None:
|
|
raise ValueError(f"User not found: {user_id}")
|
|
|
|
tariff = await self.tariff_repo.get(tariff_id)
|
|
if tariff is None:
|
|
raise ValueError(f"Tariff not found: {tariff_id}")
|
|
|
|
payment = await self.payment_repo.get(payment_id)
|
|
if payment is None:
|
|
raise ValueError(f"Payment not found: {payment_id}")
|
|
|
|
if payment.status != PaymentStatus.CONFIRMED:
|
|
raise ValueError(f"Payment {payment_id} is not confirmed")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
end_date = now + timedelta(days=tariff.duration_days)
|
|
|
|
logger.info(
|
|
"Subscription activated: user={} tariff={} until={}",
|
|
user_id,
|
|
tariff.name,
|
|
end_date.date(),
|
|
)
|
|
|
|
return self._build_subscription_info(tariff, now, end_date)
|
|
|
|
async def get_active_subscription(self, user_id: int) -> SubscriptionInfo | None:
|
|
user = await self.user_repo.get(user_id)
|
|
if user is None:
|
|
return None
|
|
|
|
confirmed = await self.payment_repo.get_by_user_and_status(
|
|
user_id, PaymentStatus.CONFIRMED
|
|
)
|
|
if not confirmed:
|
|
return SubscriptionInfo(is_active=False)
|
|
|
|
latest = confirmed[0]
|
|
tariff = await self.tariff_repo.get(latest.tariff_id)
|
|
if tariff is None:
|
|
return SubscriptionInfo(is_active=False)
|
|
|
|
now = datetime.now(timezone.utc)
|
|
start = latest.paid_at or latest.created_at
|
|
end = start + timedelta(days=tariff.duration_days)
|
|
|
|
if now > end:
|
|
return SubscriptionInfo(
|
|
is_active=False,
|
|
tariff=tariff,
|
|
start_date=start,
|
|
end_date=end,
|
|
remaining_days=0,
|
|
)
|
|
|
|
remaining = (end - now).days
|
|
return SubscriptionInfo(
|
|
is_active=True,
|
|
tariff=tariff,
|
|
start_date=start,
|
|
end_date=end,
|
|
remaining_days=remaining,
|
|
)
|
|
|
|
async def has_active_subscription(self, user_id: int) -> bool:
|
|
info = await self.get_active_subscription(user_id)
|
|
return info is not None and info.is_active
|
|
|
|
async def expire_subscriptions(self) -> list[int]:
|
|
expired_user_ids: list[int] = []
|
|
|
|
confirmed = await self.payment_repo.get_by_status(PaymentStatus.CONFIRMED)
|
|
now = datetime.now(timezone.utc)
|
|
|
|
for payment in confirmed:
|
|
tariff = await self.tariff_repo.get(payment.tariff_id)
|
|
if tariff is None:
|
|
continue
|
|
|
|
start = payment.paid_at or payment.created_at
|
|
end = start + timedelta(days=tariff.duration_days)
|
|
|
|
if now > end:
|
|
expired_user_ids.append(payment.user_id)
|
|
logger.info(
|
|
"Subscription expired: user={} payment={}",
|
|
payment.user_id,
|
|
payment.id,
|
|
)
|
|
|
|
return expired_user_ids
|
|
|
|
@staticmethod
|
|
def _build_subscription_info(
|
|
tariff: Tariff, start: datetime, end: datetime
|
|
) -> SubscriptionInfo:
|
|
remaining = max(0, (end - datetime.now(timezone.utc)).days)
|
|
return SubscriptionInfo(
|
|
is_active=True,
|
|
tariff=tariff,
|
|
start_date=start,
|
|
end_date=end,
|
|
remaining_days=remaining,
|
|
)
|