Files
vpn-control-panel/app/services/billing.py
smolkik-code 7c7c88621d Initial commit: VPN Control Panel
- 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
2026-07-05 17:50:11 +07:00

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,
)