453 lines
19 KiB
Python
453 lines
19 KiB
Python
from datetime import datetime, timedelta
|
|
from math import ceil
|
|
from urllib.parse import urlsplit
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
|
from pytz import timezone as tz_moscow
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from api.depends import get_session, validate_redirect_url, verify_identity_token
|
|
from api.v2.base_crud import generate_crud_router
|
|
from api.v2.routes.coupon_pricing import resolve_percent_coupon_pricing
|
|
from api.v2.schemas import TariffBase, TariffResponse, TariffUpdate
|
|
from api.v2.schemas.tariffs import TariffGroup, TariffPublic
|
|
from api.v2.schemas.web_public import (
|
|
TariffConfigPriceResponse,
|
|
TariffPurchaseRequest,
|
|
TariffPurchaseResponse,
|
|
)
|
|
from core.bootstrap import PAYMENTS_CONFIG
|
|
from core.redis_cache import cache_get, cache_key, cache_set
|
|
from database import (
|
|
get_balance,
|
|
identities as idb,
|
|
)
|
|
from database.coupons import mark_coupon_used
|
|
from database.models import Tariff
|
|
from database.tariffs import get_tariff_by_id
|
|
from database.temporary_data import create_temporary_data
|
|
from logger import logger
|
|
from services.keys import create_vpn_key_headless
|
|
from services.payments.payment_links import PaymentLinkRequest, create_payment_link
|
|
from services.payments.providers import WEB_LINK_PROVIDER_IDS
|
|
from services.tariffs import calculate_config_price
|
|
|
|
|
|
def _tariff_to_public(t: Tariff) -> TariffPublic:
|
|
dev_opts = getattr(t, "device_options", None)
|
|
tr_opts = getattr(t, "traffic_options_gb", None)
|
|
device_options: list[int] | None = None
|
|
traffic_options_gb: list[int] | None = None
|
|
if isinstance(dev_opts, list):
|
|
device_options = []
|
|
for x in dev_opts:
|
|
try:
|
|
device_options.append(int(x))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not device_options:
|
|
device_options = None
|
|
if isinstance(tr_opts, list):
|
|
traffic_options_gb = []
|
|
for x in tr_opts:
|
|
try:
|
|
traffic_options_gb.append(int(x))
|
|
except (TypeError, ValueError):
|
|
continue
|
|
if not traffic_options_gb:
|
|
traffic_options_gb = None
|
|
return TariffPublic(
|
|
id=t.id,
|
|
name=t.name or "",
|
|
group_code=t.group_code or "",
|
|
duration_days=t.duration_days or 0,
|
|
price_rub=t.price_rub or 0,
|
|
traffic_limit=t.traffic_limit,
|
|
device_limit=t.device_limit,
|
|
subgroup_title=t.subgroup_title,
|
|
sort_order=t.sort_order,
|
|
vless=bool(getattr(t, "vless", False)),
|
|
configurable=bool(getattr(t, "configurable", False)),
|
|
device_options=device_options,
|
|
traffic_options_gb=traffic_options_gb,
|
|
)
|
|
|
|
|
|
public_router = APIRouter()
|
|
|
|
|
|
def _resolve_public_base_url(request: Request) -> str:
|
|
origin = str(request.headers.get("origin") or "").strip()
|
|
if origin.startswith(("http://", "https://")):
|
|
return origin.rstrip("/")
|
|
referer = str(request.headers.get("referer") or request.headers.get("referrer") or "").strip()
|
|
if referer.startswith(("http://", "https://")):
|
|
parsed = urlsplit(referer)
|
|
if parsed.scheme and parsed.netloc:
|
|
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
|
forwarded_host = str(request.headers.get("x-forwarded-host") or "").strip()
|
|
host = forwarded_host or str(request.headers.get("host") or "").strip()
|
|
forwarded_proto = str(request.headers.get("x-forwarded-proto") or "").split(",", 1)[0].strip().lower()
|
|
scheme = forwarded_proto if forwarded_proto in {"http", "https"} else request.url.scheme
|
|
if host:
|
|
return f"{scheme}://{host}".rstrip("/")
|
|
return str(request.base_url).rstrip("/")
|
|
|
|
|
|
def _resolve_default_web_payment_provider() -> str | None:
|
|
for provider_id in WEB_LINK_PROVIDER_IDS:
|
|
if bool(PAYMENTS_CONFIG.get(provider_id)):
|
|
return provider_id
|
|
return WEB_LINK_PROVIDER_IDS[0] if WEB_LINK_PROVIDER_IDS else None
|
|
|
|
|
|
def _public_tariffs_cache_key(
|
|
group_code: str | None,
|
|
tariff_ids: str | None,
|
|
filter_vless: str | None,
|
|
) -> str:
|
|
normalized_group = (group_code or "").strip().lower()
|
|
normalized_ids = ",".join(part.strip() for part in (tariff_ids or "").split(",") if part.strip())
|
|
normalized_vless = (filter_vless or "").strip().lower()
|
|
return cache_key("tariffs_public", normalized_group or "-", normalized_ids or "-", normalized_vless or "-")
|
|
|
|
|
|
@public_router.get("/groups", response_model=list[TariffGroup])
|
|
async def get_tariff_groups(session: AsyncSession = Depends(get_session)):
|
|
"""Публичный список групп тарифов — уникальные значения колонки group_code."""
|
|
q = (
|
|
select(Tariff.group_code)
|
|
.where(Tariff.is_active.is_(True), Tariff.group_code.isnot(None), Tariff.group_code != "")
|
|
.distinct()
|
|
.order_by(Tariff.group_code)
|
|
)
|
|
result = await session.execute(q)
|
|
values = result.scalars().all()
|
|
return [TariffGroup(group_code=v or "") for v in values]
|
|
|
|
|
|
@public_router.get("/public", response_model=list[TariffPublic])
|
|
async def get_tariffs_public(
|
|
group_code: str | None = Query(None, description="Фильтр по группе тарифов"),
|
|
tariff_ids: str | None = Query(None, description="ID тарифов через запятую (приоритет над группой)"),
|
|
filter_vless: str | None = Query(
|
|
None,
|
|
description="vless: только для роутера (vless=True), app: только для приложения (vless=False), иначе все",
|
|
),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Публичный список активных тарифов (без авторизации)."""
|
|
cache_token = _public_tariffs_cache_key(group_code, tariff_ids, filter_vless)
|
|
cached = await cache_get(cache_token)
|
|
if isinstance(cached, list):
|
|
return cached
|
|
|
|
q = (
|
|
select(Tariff)
|
|
.where(Tariff.is_active.is_(True))
|
|
.order_by(Tariff.sort_order.asc().nulls_last(), Tariff.price_rub.asc())
|
|
)
|
|
if tariff_ids:
|
|
try:
|
|
ids = [int(x.strip()) for x in tariff_ids.split(",") if x.strip()]
|
|
if not ids:
|
|
return []
|
|
q = q.where(Tariff.id.in_(ids))
|
|
except ValueError:
|
|
raise HTTPException(status_code=422, detail="Некорректный параметр tariff_ids")
|
|
elif group_code:
|
|
q = q.where(Tariff.group_code == group_code)
|
|
if filter_vless == "router":
|
|
q = q.where(Tariff.vless.is_(True))
|
|
elif filter_vless == "app":
|
|
q = q.where(Tariff.vless.is_(False))
|
|
result = await session.execute(q)
|
|
rows = result.scalars().all()
|
|
payload = [_tariff_to_public(t).model_dump() for t in rows]
|
|
await cache_set(cache_token, payload, 30)
|
|
return payload
|
|
|
|
|
|
@public_router.get("/config-price", response_model=TariffConfigPriceResponse)
|
|
async def get_tariff_config_price(
|
|
tariff_id: int = Query(..., ge=1),
|
|
selected_device_limit: int | None = Query(None),
|
|
selected_traffic_gb: int | None = Query(None),
|
|
session: AsyncSession = Depends(get_session),
|
|
):
|
|
tariff = await get_tariff_by_id(session, tariff_id)
|
|
if not tariff or not tariff.get("is_active", True):
|
|
raise HTTPException(status_code=404, detail="Тариф не найден")
|
|
price = int(calculate_config_price(tariff, selected_device_limit, selected_traffic_gb))
|
|
return TariffConfigPriceResponse(price_rub=price)
|
|
|
|
|
|
user_tariff_router = APIRouter()
|
|
|
|
|
|
@user_tariff_router.post("/purchase", response_model=TariffPurchaseResponse)
|
|
async def purchase_tariff_with_balance(
|
|
body: TariffPurchaseRequest,
|
|
request: Request,
|
|
preview: bool = Query(False),
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
|
tariff = await get_tariff_by_id(session, body.tariff_id)
|
|
if not tariff or not tariff.get("is_active", True):
|
|
raise HTTPException(status_code=404, detail="Тариф не найден")
|
|
price = int(calculate_config_price(tariff, body.selected_device_limit, body.selected_traffic_gb))
|
|
if price <= 0:
|
|
raise HTTPException(status_code=400, detail="Некорректная цена тарифа")
|
|
final_price, discount_rub, coupon_id, applied_coupon_code = await resolve_percent_coupon_pricing(
|
|
session=session,
|
|
billing_user_id=int(tg_id),
|
|
base_price_rub=int(price),
|
|
coupon_code=body.coupon_code,
|
|
)
|
|
balance = float(await get_balance(session, tg_id))
|
|
duration = int(tariff.get("duration_days") or 0)
|
|
if duration <= 0:
|
|
raise HTTPException(status_code=400, detail="Некорректная длительность тарифа")
|
|
required_amount = int(max(0, ceil(float(final_price) - balance)))
|
|
if preview:
|
|
return TariffPurchaseResponse(
|
|
ok=True,
|
|
message="Расчет обновлен",
|
|
key_email=None,
|
|
charged_rub=0,
|
|
base_price_rub=int(price),
|
|
discount_rub=int(discount_rub),
|
|
final_price_rub=int(final_price),
|
|
applied_coupon_code=applied_coupon_code,
|
|
payment_required=required_amount > 0,
|
|
required_amount_rub=int(required_amount),
|
|
payment_id=None,
|
|
payment_url=None,
|
|
)
|
|
if required_amount > 0:
|
|
provider_id = str(body.provider_id or _resolve_default_web_payment_provider() or "").strip().upper()
|
|
if not provider_id:
|
|
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
|
base_url = _resolve_public_base_url(request)
|
|
success_url = validate_redirect_url(str(body.success_url or ""), f"{base_url}/payment-success")
|
|
failure_url = validate_redirect_url(str(body.failure_url or ""), f"{base_url}/payment-failure")
|
|
payment_request = PaymentLinkRequest(
|
|
legacy_user_ref=int(tg_id),
|
|
amount=required_amount,
|
|
currency="RUB",
|
|
provider_id=provider_id,
|
|
success_url=success_url,
|
|
failure_url=failure_url,
|
|
metadata={
|
|
"payment_flow": "tariff_purchase",
|
|
"tariff_id": int(body.tariff_id),
|
|
"selected_device_limit": body.selected_device_limit,
|
|
"selected_traffic_gb": body.selected_traffic_gb,
|
|
"selected_duration_days": int(duration),
|
|
"selected_price_rub": int(final_price),
|
|
"base_price_rub": int(price),
|
|
"discount_rub": int(discount_rub),
|
|
"applied_coupon_code": applied_coupon_code,
|
|
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
|
},
|
|
)
|
|
payment_result = await create_payment_link(session, payment_request)
|
|
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
|
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
|
await create_temporary_data(
|
|
session,
|
|
int(tg_id),
|
|
"waiting_for_payment",
|
|
{
|
|
"tariff_id": int(body.tariff_id),
|
|
"required_amount": int(required_amount),
|
|
"selected_price_rub": int(final_price),
|
|
"selected_device_limit": body.selected_device_limit,
|
|
"selected_traffic_limit_gb": body.selected_traffic_gb,
|
|
"selected_duration_days": int(duration),
|
|
"base_price_rub": int(price),
|
|
"discount_rub": int(discount_rub),
|
|
"applied_coupon_code": applied_coupon_code,
|
|
"coupon_id": int(coupon_id) if coupon_id is not None else None,
|
|
},
|
|
)
|
|
return TariffPurchaseResponse(
|
|
ok=True,
|
|
message="Требуется оплата для оформления подписки",
|
|
key_email=None,
|
|
charged_rub=0,
|
|
base_price_rub=int(price),
|
|
discount_rub=int(discount_rub),
|
|
final_price_rub=int(final_price),
|
|
applied_coupon_code=applied_coupon_code,
|
|
payment_required=True,
|
|
required_amount_rub=required_amount,
|
|
payment_id=payment_result.payment_id,
|
|
payment_url=payment_result.payment_url,
|
|
)
|
|
moscow_tz = tz_moscow("Europe/Moscow")
|
|
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
|
try:
|
|
await create_vpn_key_headless(
|
|
session=session,
|
|
tg_id=tg_id,
|
|
expiry_time=expiry,
|
|
plan=body.tariff_id,
|
|
selected_device_limit=body.selected_device_limit,
|
|
selected_traffic_gb=body.selected_traffic_gb,
|
|
selected_price_rub=final_price,
|
|
)
|
|
if coupon_id is not None:
|
|
await mark_coupon_used(session, int(coupon_id), int(tg_id))
|
|
except Exception:
|
|
logger.exception("web tariff purchase failed")
|
|
raise HTTPException(status_code=500, detail="Не удалось оформить подписку") from None
|
|
return TariffPurchaseResponse(
|
|
ok=True,
|
|
message="Подписка оформлена. Ключ в разделе «Мои ключи».",
|
|
key_email=None,
|
|
charged_rub=final_price,
|
|
base_price_rub=int(price),
|
|
discount_rub=int(discount_rub),
|
|
final_price_rub=int(final_price),
|
|
applied_coupon_code=applied_coupon_code,
|
|
)
|
|
|
|
|
|
@user_tariff_router.post("/trial", response_model=TariffPurchaseResponse)
|
|
async def activate_trial(
|
|
request: Request,
|
|
session: AsyncSession = Depends(get_session),
|
|
identity=Depends(verify_identity_token),
|
|
):
|
|
"""Активация триала (бесплатного или платного). Доступно 1 раз."""
|
|
from database import get_trial, update_trial
|
|
from database.tariffs import get_tariffs
|
|
|
|
tg_id = await idb.ensure_billing_user_for_identity(session, identity)
|
|
|
|
trial_status = await get_trial(session, tg_id)
|
|
if trial_status not in (0, -1):
|
|
raise HTTPException(status_code=409, detail="Пробная подписка уже использована")
|
|
|
|
trial_tariffs = await get_tariffs(session, group_code="trial")
|
|
if not trial_tariffs:
|
|
raise HTTPException(status_code=404, detail="Пробный тариф не найден")
|
|
|
|
tariff = trial_tariffs[0]
|
|
price = int(tariff.get("price_rub", 0) or 0)
|
|
duration = int(tariff.get("duration_days") or 0)
|
|
if duration <= 0:
|
|
raise HTTPException(status_code=400, detail="Некорректная длительность триала")
|
|
|
|
if price <= 0:
|
|
moscow_tz = tz_moscow("Europe/Moscow")
|
|
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
|
try:
|
|
await create_vpn_key_headless(
|
|
session=session,
|
|
tg_id=tg_id,
|
|
expiry_time=expiry,
|
|
plan=int(tariff["id"]),
|
|
selected_price_rub=0,
|
|
skip_balance_charge=True,
|
|
is_trial=True,
|
|
)
|
|
await update_trial(session, tg_id, 1)
|
|
except Exception:
|
|
logger.exception("web trial activation failed")
|
|
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
|
return TariffPurchaseResponse(
|
|
ok=True,
|
|
message="Пробная подписка активирована!",
|
|
charged_rub=0,
|
|
base_price_rub=0,
|
|
final_price_rub=0,
|
|
)
|
|
|
|
balance = float(await get_balance(session, tg_id))
|
|
required_amount = int(max(0, ceil(float(price) - balance)))
|
|
|
|
if required_amount <= 0:
|
|
moscow_tz = tz_moscow("Europe/Moscow")
|
|
expiry = datetime.now(moscow_tz) + timedelta(days=duration)
|
|
try:
|
|
await create_vpn_key_headless(
|
|
session=session,
|
|
tg_id=tg_id,
|
|
expiry_time=expiry,
|
|
plan=int(tariff["id"]),
|
|
selected_price_rub=price,
|
|
is_trial=True,
|
|
)
|
|
await update_trial(session, tg_id, 1)
|
|
except Exception:
|
|
logger.exception("web paid trial activation failed")
|
|
raise HTTPException(status_code=500, detail="Ошибка активации триала") from None
|
|
return TariffPurchaseResponse(
|
|
ok=True,
|
|
message="Пробная подписка активирована!",
|
|
charged_rub=price,
|
|
base_price_rub=price,
|
|
final_price_rub=price,
|
|
)
|
|
|
|
provider_id = str(_resolve_default_web_payment_provider() or "").strip().upper()
|
|
if not provider_id:
|
|
raise HTTPException(status_code=503, detail="Нет доступных провайдеров оплаты")
|
|
base_url = _resolve_public_base_url(request)
|
|
payment_request = PaymentLinkRequest(
|
|
legacy_user_ref=int(tg_id),
|
|
amount=required_amount,
|
|
currency="RUB",
|
|
provider_id=provider_id,
|
|
success_url=f"{base_url}/payment-success",
|
|
failure_url=f"{base_url}/payment-failure",
|
|
metadata={
|
|
"payment_flow": "trial_purchase",
|
|
"tariff_id": int(tariff["id"]),
|
|
"selected_price_rub": price,
|
|
"selected_duration_days": duration,
|
|
},
|
|
)
|
|
payment_result = await create_payment_link(session, payment_request)
|
|
if not payment_result.success or not payment_result.payment_url or not payment_result.payment_id:
|
|
raise HTTPException(status_code=400, detail=payment_result.error or "Не удалось создать ссылку оплаты")
|
|
await create_temporary_data(
|
|
session,
|
|
int(tg_id),
|
|
"waiting_for_payment",
|
|
{
|
|
"payment_flow": "trial_purchase",
|
|
"tariff_id": int(tariff["id"]),
|
|
"required_amount": required_amount,
|
|
"selected_price_rub": price,
|
|
"selected_duration_days": duration,
|
|
},
|
|
)
|
|
return TariffPurchaseResponse(
|
|
ok=True,
|
|
message="Требуется оплата для активации пробной подписки",
|
|
charged_rub=0,
|
|
base_price_rub=price,
|
|
final_price_rub=price,
|
|
payment_required=True,
|
|
required_amount_rub=required_amount,
|
|
payment_id=payment_result.payment_id,
|
|
payment_url=payment_result.payment_url,
|
|
)
|
|
|
|
|
|
router = generate_crud_router(
|
|
model=Tariff,
|
|
schema_response=TariffResponse,
|
|
schema_create=TariffBase,
|
|
schema_update=TariffUpdate,
|
|
identifier_field="name",
|
|
parameter_name="name",
|
|
enabled_methods=["get_all", "get_one", "create", "update", "delete"],
|
|
)
|