mirror of
https://github.com/BEDOLAGA-DEV/remnawave-bedolaga-telegram-bot.git
synced 2026-02-23 12:53:41 +00:00
4658 lines
161 KiB
Python
4658 lines
161 KiB
Python
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
import math
|
||
from dataclasses import dataclass
|
||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP, ROUND_FLOOR
|
||
from datetime import datetime, timedelta, timezone
|
||
from types import SimpleNamespace
|
||
from uuid import uuid4
|
||
from typing import Any, Dict, Iterable, List, Optional, Tuple, Union
|
||
|
||
from aiogram import Bot
|
||
from fastapi import APIRouter, Depends, HTTPException, status
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.orm import selectinload
|
||
|
||
from app.config import settings, PERIOD_PRICES
|
||
from app.database.crud.discount_offer import (
|
||
get_latest_claimed_offer_for_user,
|
||
get_offer_by_id,
|
||
list_active_discount_offers_for_user,
|
||
mark_offer_claimed,
|
||
)
|
||
from app.database.crud.promo_group import get_auto_assign_promo_groups
|
||
from app.database.crud.rules import get_rules_by_language
|
||
from app.database.crud.promo_offer_template import get_promo_offer_template_by_id
|
||
from app.database.crud.server_squad import (
|
||
get_available_server_squads,
|
||
get_server_squad_by_uuid,
|
||
get_server_ids_by_uuids,
|
||
add_user_to_servers,
|
||
remove_user_from_servers,
|
||
)
|
||
from app.database.crud.subscription import (
|
||
add_subscription_servers,
|
||
remove_subscription_servers,
|
||
create_paid_subscription,
|
||
)
|
||
from app.database.crud.subscription_conversion import create_subscription_conversion
|
||
from app.database.crud.transaction import (
|
||
create_transaction,
|
||
get_user_total_spent_kopeks,
|
||
)
|
||
from app.database.crud.user import (
|
||
get_user_by_telegram_id,
|
||
subtract_user_balance,
|
||
add_user_balance,
|
||
)
|
||
from app.database.models import (
|
||
PromoGroup,
|
||
PromoOfferTemplate,
|
||
Subscription,
|
||
SubscriptionStatus,
|
||
SubscriptionTemporaryAccess,
|
||
Transaction,
|
||
TransactionType,
|
||
PaymentMethod,
|
||
User,
|
||
)
|
||
from app.services.faq_service import FaqService
|
||
from app.services.privacy_policy_service import PrivacyPolicyService
|
||
from app.services.public_offer_service import PublicOfferService
|
||
from app.services.remnawave_service import (
|
||
RemnaWaveConfigurationError,
|
||
RemnaWaveService,
|
||
)
|
||
from app.services.payment_service import PaymentService
|
||
from app.services.promo_offer_service import promo_offer_service
|
||
from app.services.promocode_service import PromoCodeService
|
||
from app.services.subscription_service import SubscriptionService
|
||
from app.services.tribute_service import TributeService
|
||
from app.utils.currency_converter import currency_converter
|
||
from app.utils.subscription_utils import get_happ_cryptolink_redirect_link
|
||
from app.utils.promo_offer import get_user_active_promo_discount_percent
|
||
from app.utils.telegram_webapp import (
|
||
TelegramWebAppAuthError,
|
||
parse_webapp_init_data,
|
||
)
|
||
from app.utils.user_utils import (
|
||
get_detailed_referral_list,
|
||
get_user_referral_summary,
|
||
mark_user_as_had_paid_subscription,
|
||
)
|
||
from app.utils.pricing_utils import (
|
||
apply_percentage_discount,
|
||
calculate_prorated_price,
|
||
get_remaining_months,
|
||
calculate_months_from_days,
|
||
format_period_description,
|
||
)
|
||
|
||
from ..dependencies import get_db_session
|
||
from ..schemas.miniapp import (
|
||
MiniAppAutoPromoGroupLevel,
|
||
MiniAppConnectedServer,
|
||
MiniAppDevice,
|
||
MiniAppDeviceRemovalRequest,
|
||
MiniAppDeviceRemovalResponse,
|
||
MiniAppFaq,
|
||
MiniAppFaqItem,
|
||
MiniAppLegalDocuments,
|
||
MiniAppPaymentCreateRequest,
|
||
MiniAppPaymentCreateResponse,
|
||
MiniAppPaymentMethod,
|
||
MiniAppPaymentMethodsRequest,
|
||
MiniAppPaymentMethodsResponse,
|
||
MiniAppPaymentStatusQuery,
|
||
MiniAppPaymentStatusRequest,
|
||
MiniAppPaymentStatusResponse,
|
||
MiniAppPaymentStatusResult,
|
||
MiniAppPromoCode,
|
||
MiniAppPromoCodeActivationRequest,
|
||
MiniAppPromoCodeActivationResponse,
|
||
MiniAppPromoGroup,
|
||
MiniAppPromoOffer,
|
||
MiniAppPromoOfferClaimRequest,
|
||
MiniAppPromoOfferClaimResponse,
|
||
MiniAppReferralInfo,
|
||
MiniAppReferralItem,
|
||
MiniAppReferralList,
|
||
MiniAppReferralRecentEarning,
|
||
MiniAppReferralStats,
|
||
MiniAppReferralTerms,
|
||
MiniAppRichTextDocument,
|
||
MiniAppSubscriptionRequest,
|
||
MiniAppSubscriptionResponse,
|
||
MiniAppSubscriptionUser,
|
||
MiniAppTransaction,
|
||
MiniAppSubscriptionSettingsRequest,
|
||
MiniAppSubscriptionSettingsResponse,
|
||
MiniAppSubscriptionSettings,
|
||
MiniAppSubscriptionCurrentSettings,
|
||
MiniAppSubscriptionServersSettings,
|
||
MiniAppSubscriptionServerOption,
|
||
MiniAppSubscriptionTrafficSettings,
|
||
MiniAppSubscriptionTrafficOption,
|
||
MiniAppSubscriptionDevicesSettings,
|
||
MiniAppSubscriptionDeviceOption,
|
||
MiniAppSubscriptionBillingContext,
|
||
MiniAppSubscriptionServersUpdateRequest,
|
||
MiniAppSubscriptionTrafficUpdateRequest,
|
||
MiniAppSubscriptionDevicesUpdateRequest,
|
||
MiniAppSubscriptionUpdateResponse,
|
||
MiniAppSubscriptionPurchaseOptions,
|
||
MiniAppSubscriptionPurchaseOptionsRequest,
|
||
MiniAppSubscriptionPurchaseOptionsResponse,
|
||
MiniAppSubscriptionPurchasePreview,
|
||
MiniAppSubscriptionPurchasePreviewRequest,
|
||
MiniAppSubscriptionPurchasePreviewResponse,
|
||
MiniAppSubscriptionPurchaseSubmitRequest,
|
||
MiniAppSubscriptionPurchaseSubmitResponse,
|
||
MiniAppSubscriptionPurchasePeriod,
|
||
)
|
||
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
|
||
promo_code_service = PromoCodeService()
|
||
|
||
|
||
_CRYPTOBOT_MIN_USD = 1.0
|
||
_CRYPTOBOT_MAX_USD = 1000.0
|
||
_CRYPTOBOT_FALLBACK_RATE = 95.0
|
||
|
||
_DECIMAL_ONE_HUNDRED = Decimal(100)
|
||
_DECIMAL_CENT = Decimal("0.01")
|
||
|
||
_PAYMENT_SUCCESS_STATUSES = {
|
||
"paid",
|
||
"success",
|
||
"succeeded",
|
||
"completed",
|
||
"captured",
|
||
"done",
|
||
"overpaid",
|
||
}
|
||
_PAYMENT_FAILURE_STATUSES = {
|
||
"fail",
|
||
"failed",
|
||
"canceled",
|
||
"cancelled",
|
||
"declined",
|
||
"expired",
|
||
"rejected",
|
||
"error",
|
||
"refunded",
|
||
"chargeback",
|
||
}
|
||
|
||
|
||
async def _get_usd_to_rub_rate() -> float:
|
||
try:
|
||
rate = await currency_converter.get_usd_to_rub_rate()
|
||
except Exception:
|
||
rate = 0.0
|
||
if not rate or rate <= 0:
|
||
rate = _CRYPTOBOT_FALLBACK_RATE
|
||
return float(rate)
|
||
|
||
|
||
def _compute_cryptobot_limits(rate: float) -> Tuple[int, int]:
|
||
min_kopeks = max(1, int(math.ceil(rate * _CRYPTOBOT_MIN_USD * 100)))
|
||
max_kopeks = int(math.floor(rate * _CRYPTOBOT_MAX_USD * 100))
|
||
if max_kopeks < min_kopeks:
|
||
max_kopeks = min_kopeks
|
||
return min_kopeks, max_kopeks
|
||
|
||
|
||
def _current_request_timestamp() -> str:
|
||
return datetime.utcnow().replace(microsecond=0).isoformat()
|
||
|
||
|
||
def _compute_stars_min_amount() -> Optional[int]:
|
||
try:
|
||
rate = Decimal(str(settings.get_stars_rate()))
|
||
except (InvalidOperation, TypeError):
|
||
return None
|
||
|
||
if rate <= 0:
|
||
return None
|
||
|
||
return int((rate * _DECIMAL_ONE_HUNDRED).to_integral_value(rounding=ROUND_HALF_UP))
|
||
|
||
|
||
def _normalize_stars_amount(amount_kopeks: int) -> Tuple[int, int]:
|
||
try:
|
||
rate = Decimal(str(settings.get_stars_rate()))
|
||
except (InvalidOperation, TypeError):
|
||
raise ValueError("Stars rate is not configured")
|
||
|
||
if rate <= 0:
|
||
raise ValueError("Stars rate must be positive")
|
||
|
||
amount_rubles = Decimal(amount_kopeks) / _DECIMAL_ONE_HUNDRED
|
||
stars_amount = int((amount_rubles / rate).to_integral_value(rounding=ROUND_FLOOR))
|
||
if stars_amount <= 0:
|
||
stars_amount = 1
|
||
|
||
normalized_rubles = (Decimal(stars_amount) * rate).quantize(
|
||
_DECIMAL_CENT,
|
||
rounding=ROUND_HALF_UP,
|
||
)
|
||
normalized_amount_kopeks = int(
|
||
(normalized_rubles * _DECIMAL_ONE_HUNDRED).to_integral_value(
|
||
rounding=ROUND_HALF_UP
|
||
)
|
||
)
|
||
|
||
return stars_amount, normalized_amount_kopeks
|
||
|
||
|
||
def _build_balance_invoice_payload(user_id: int, amount_kopeks: int) -> str:
|
||
suffix = uuid4().hex[:8]
|
||
return f"balance_{user_id}_{amount_kopeks}_{suffix}"
|
||
|
||
|
||
def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional[datetime]:
|
||
if value is None:
|
||
return None
|
||
if isinstance(value, (int, float)):
|
||
try:
|
||
timestamp = float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if timestamp > 1e12:
|
||
timestamp /= 1000.0
|
||
try:
|
||
return datetime.fromtimestamp(timestamp, tz=timezone.utc).replace(tzinfo=None)
|
||
except (OverflowError, OSError, ValueError):
|
||
return None
|
||
if isinstance(value, str):
|
||
normalized = value.strip()
|
||
if not normalized:
|
||
return None
|
||
if normalized.isdigit():
|
||
return _parse_client_timestamp(int(normalized))
|
||
for suffix in ("Z", "z"):
|
||
if normalized.endswith(suffix):
|
||
normalized = normalized[:-1] + "+00:00"
|
||
break
|
||
try:
|
||
parsed = datetime.fromisoformat(normalized)
|
||
except ValueError:
|
||
return None
|
||
if parsed.tzinfo:
|
||
return parsed.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return parsed
|
||
return None
|
||
|
||
|
||
async def _find_recent_deposit(
|
||
db: AsyncSession,
|
||
*,
|
||
user_id: int,
|
||
payment_method: PaymentMethod,
|
||
amount_kopeks: Optional[int],
|
||
started_at: Optional[datetime],
|
||
tolerance: timedelta = timedelta(minutes=5),
|
||
) -> Optional[Transaction]:
|
||
def _transaction_matches_started_at(
|
||
transaction: Transaction,
|
||
reference: Optional[datetime],
|
||
) -> bool:
|
||
if not reference:
|
||
return True
|
||
timestamp = transaction.completed_at or transaction.created_at
|
||
if not timestamp:
|
||
return False
|
||
if timestamp.tzinfo:
|
||
timestamp = timestamp.astimezone(timezone.utc).replace(tzinfo=None)
|
||
return timestamp >= reference
|
||
|
||
query = (
|
||
select(Transaction)
|
||
.where(
|
||
Transaction.user_id == user_id,
|
||
Transaction.type == TransactionType.DEPOSIT.value,
|
||
Transaction.payment_method == payment_method.value,
|
||
)
|
||
.order_by(Transaction.created_at.desc())
|
||
.limit(1)
|
||
)
|
||
|
||
if amount_kopeks is not None:
|
||
query = query.where(Transaction.amount_kopeks == amount_kopeks)
|
||
if started_at:
|
||
query = query.where(Transaction.created_at >= started_at - tolerance)
|
||
|
||
result = await db.execute(query)
|
||
transaction = result.scalar_one_or_none()
|
||
|
||
if not transaction:
|
||
return None
|
||
|
||
if not _transaction_matches_started_at(transaction, started_at):
|
||
return None
|
||
|
||
return transaction
|
||
|
||
|
||
def _classify_status(status: Optional[str], is_paid: bool) -> str:
|
||
if is_paid:
|
||
return "paid"
|
||
normalized = (status or "").strip().lower()
|
||
if not normalized:
|
||
return "pending"
|
||
if normalized in _PAYMENT_SUCCESS_STATUSES:
|
||
return "paid"
|
||
if normalized in _PAYMENT_FAILURE_STATUSES:
|
||
return "failed"
|
||
return "pending"
|
||
|
||
def _format_gb(value: Optional[float]) -> float:
|
||
if value is None:
|
||
return 0.0
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return 0.0
|
||
|
||
|
||
def _format_gb_label(value: float) -> str:
|
||
absolute = abs(value)
|
||
if absolute >= 100:
|
||
return f"{value:.0f} GB"
|
||
if absolute >= 10:
|
||
return f"{value:.1f} GB"
|
||
return f"{value:.2f} GB"
|
||
|
||
|
||
def _format_limit_label(limit: Optional[int]) -> str:
|
||
if not limit:
|
||
return "Unlimited"
|
||
return f"{limit} GB"
|
||
|
||
|
||
async def _resolve_user_from_init_data(
|
||
db: AsyncSession,
|
||
init_data: str,
|
||
) -> Tuple[User, Dict[str, Any]]:
|
||
if not init_data:
|
||
raise HTTPException(
|
||
status.HTTP_401_UNAUTHORIZED,
|
||
detail="Missing initData",
|
||
)
|
||
|
||
try:
|
||
webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN)
|
||
except TelegramWebAppAuthError as error:
|
||
raise HTTPException(
|
||
status.HTTP_401_UNAUTHORIZED,
|
||
detail=str(error),
|
||
) from error
|
||
|
||
telegram_user = webapp_data.get("user")
|
||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail="Invalid Telegram user payload",
|
||
)
|
||
|
||
try:
|
||
telegram_id = int(telegram_user["id"])
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail="Invalid Telegram user identifier",
|
||
) from None
|
||
|
||
user = await get_user_by_telegram_id(db, telegram_id)
|
||
if not user:
|
||
raise HTTPException(
|
||
status.HTTP_404_NOT_FOUND,
|
||
detail="User not found",
|
||
)
|
||
|
||
return user, webapp_data
|
||
|
||
|
||
def _normalize_amount_kopeks(
|
||
amount_rubles: Optional[float],
|
||
amount_kopeks: Optional[int],
|
||
) -> Optional[int]:
|
||
if amount_kopeks is not None:
|
||
try:
|
||
normalized = int(amount_kopeks)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
return normalized if normalized >= 0 else None
|
||
|
||
if amount_rubles is None:
|
||
return None
|
||
|
||
try:
|
||
decimal_amount = Decimal(str(amount_rubles)).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
)
|
||
except (InvalidOperation, ValueError):
|
||
return None
|
||
|
||
normalized = int((decimal_amount * 100).to_integral_value(rounding=ROUND_HALF_UP))
|
||
return normalized if normalized >= 0 else None
|
||
|
||
|
||
@router.post(
|
||
"/payments/methods",
|
||
response_model=MiniAppPaymentMethodsResponse,
|
||
)
|
||
async def get_payment_methods(
|
||
payload: MiniAppPaymentMethodsRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppPaymentMethodsResponse:
|
||
_, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||
|
||
methods: List[MiniAppPaymentMethod] = []
|
||
|
||
if settings.TELEGRAM_STARS_ENABLED:
|
||
stars_min_amount = _compute_stars_min_amount()
|
||
methods.append(
|
||
MiniAppPaymentMethod(
|
||
id="stars",
|
||
icon="⭐",
|
||
requires_amount=True,
|
||
currency="RUB",
|
||
min_amount_kopeks=stars_min_amount,
|
||
amount_step_kopeks=stars_min_amount,
|
||
)
|
||
)
|
||
|
||
if settings.is_yookassa_enabled():
|
||
methods.append(
|
||
MiniAppPaymentMethod(
|
||
id="yookassa",
|
||
icon="💳",
|
||
requires_amount=True,
|
||
currency="RUB",
|
||
min_amount_kopeks=settings.YOOKASSA_MIN_AMOUNT_KOPEKS,
|
||
max_amount_kopeks=settings.YOOKASSA_MAX_AMOUNT_KOPEKS,
|
||
)
|
||
)
|
||
|
||
if settings.is_mulenpay_enabled():
|
||
methods.append(
|
||
MiniAppPaymentMethod(
|
||
id="mulenpay",
|
||
icon="💳",
|
||
requires_amount=True,
|
||
currency="RUB",
|
||
min_amount_kopeks=settings.MULENPAY_MIN_AMOUNT_KOPEKS,
|
||
max_amount_kopeks=settings.MULENPAY_MAX_AMOUNT_KOPEKS,
|
||
)
|
||
)
|
||
|
||
if settings.is_pal24_enabled():
|
||
methods.append(
|
||
MiniAppPaymentMethod(
|
||
id="pal24",
|
||
icon="🏦",
|
||
requires_amount=True,
|
||
currency="RUB",
|
||
min_amount_kopeks=settings.PAL24_MIN_AMOUNT_KOPEKS,
|
||
max_amount_kopeks=settings.PAL24_MAX_AMOUNT_KOPEKS,
|
||
)
|
||
)
|
||
|
||
if settings.is_cryptobot_enabled():
|
||
rate = await _get_usd_to_rub_rate()
|
||
min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate)
|
||
methods.append(
|
||
MiniAppPaymentMethod(
|
||
id="cryptobot",
|
||
icon="🪙",
|
||
requires_amount=True,
|
||
currency="RUB",
|
||
min_amount_kopeks=min_amount_kopeks,
|
||
max_amount_kopeks=max_amount_kopeks,
|
||
)
|
||
)
|
||
|
||
if settings.TRIBUTE_ENABLED:
|
||
methods.append(
|
||
MiniAppPaymentMethod(
|
||
id="tribute",
|
||
icon="💎",
|
||
requires_amount=False,
|
||
currency="RUB",
|
||
)
|
||
)
|
||
|
||
order_map = {
|
||
"stars": 1,
|
||
"yookassa": 2,
|
||
"mulenpay": 3,
|
||
"pal24": 4,
|
||
"cryptobot": 5,
|
||
"tribute": 6,
|
||
}
|
||
methods.sort(key=lambda item: order_map.get(item.id, 99))
|
||
|
||
return MiniAppPaymentMethodsResponse(methods=methods)
|
||
|
||
|
||
@router.post(
|
||
"/payments/create",
|
||
response_model=MiniAppPaymentCreateResponse,
|
||
)
|
||
async def create_payment_link(
|
||
payload: MiniAppPaymentCreateRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppPaymentCreateResponse:
|
||
user, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||
|
||
method = (payload.method or "").strip().lower()
|
||
if not method:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail="Payment method is required",
|
||
)
|
||
|
||
amount_kopeks = _normalize_amount_kopeks(
|
||
payload.amount_rubles,
|
||
payload.amount_kopeks,
|
||
)
|
||
|
||
if method == "stars":
|
||
if not settings.TELEGRAM_STARS_ENABLED:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||
if amount_kopeks is None or amount_kopeks <= 0:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||
if not settings.BOT_TOKEN:
|
||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured")
|
||
|
||
requested_amount_kopeks = amount_kopeks
|
||
try:
|
||
stars_amount, amount_kopeks = _normalize_stars_amount(amount_kopeks)
|
||
except ValueError as exc:
|
||
logger.error("Failed to normalize Stars amount: %s", exc)
|
||
raise HTTPException(
|
||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail="Failed to prepare Stars payment",
|
||
) from exc
|
||
|
||
bot = Bot(token=settings.BOT_TOKEN)
|
||
invoice_payload = _build_balance_invoice_payload(user.id, amount_kopeks)
|
||
try:
|
||
payment_service = PaymentService(bot)
|
||
invoice_link = await payment_service.create_stars_invoice(
|
||
amount_kopeks=amount_kopeks,
|
||
description=settings.get_balance_payment_description(amount_kopeks),
|
||
payload=invoice_payload,
|
||
stars_amount=stars_amount,
|
||
)
|
||
finally:
|
||
await bot.session.close()
|
||
|
||
if not invoice_link:
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create invoice")
|
||
|
||
return MiniAppPaymentCreateResponse(
|
||
method=method,
|
||
payment_url=invoice_link,
|
||
amount_kopeks=amount_kopeks,
|
||
extra={
|
||
"invoice_payload": invoice_payload,
|
||
"requested_at": _current_request_timestamp(),
|
||
"stars_amount": stars_amount,
|
||
"requested_amount_kopeks": requested_amount_kopeks,
|
||
},
|
||
)
|
||
|
||
if method == "yookassa":
|
||
if not settings.is_yookassa_enabled():
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||
if amount_kopeks is None or amount_kopeks <= 0:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||
if amount_kopeks < settings.YOOKASSA_MIN_AMOUNT_KOPEKS:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||
if amount_kopeks > settings.YOOKASSA_MAX_AMOUNT_KOPEKS:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||
|
||
payment_service = PaymentService()
|
||
result = await payment_service.create_yookassa_payment(
|
||
db=db,
|
||
user_id=user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=settings.get_balance_payment_description(amount_kopeks),
|
||
)
|
||
if not result or not result.get("confirmation_url"):
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||
|
||
return MiniAppPaymentCreateResponse(
|
||
method=method,
|
||
payment_url=result["confirmation_url"],
|
||
amount_kopeks=amount_kopeks,
|
||
extra={
|
||
"local_payment_id": result.get("local_payment_id"),
|
||
"payment_id": result.get("yookassa_payment_id"),
|
||
"status": result.get("status"),
|
||
"requested_at": _current_request_timestamp(),
|
||
},
|
||
)
|
||
|
||
if method == "mulenpay":
|
||
if not settings.is_mulenpay_enabled():
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||
if amount_kopeks is None or amount_kopeks <= 0:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||
if amount_kopeks < settings.MULENPAY_MIN_AMOUNT_KOPEKS:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||
if amount_kopeks > settings.MULENPAY_MAX_AMOUNT_KOPEKS:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||
|
||
payment_service = PaymentService()
|
||
result = await payment_service.create_mulenpay_payment(
|
||
db=db,
|
||
user_id=user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=settings.get_balance_payment_description(amount_kopeks),
|
||
language=user.language,
|
||
)
|
||
if not result or not result.get("payment_url"):
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||
|
||
return MiniAppPaymentCreateResponse(
|
||
method=method,
|
||
payment_url=result["payment_url"],
|
||
amount_kopeks=amount_kopeks,
|
||
extra={
|
||
"local_payment_id": result.get("local_payment_id"),
|
||
"payment_id": result.get("mulen_payment_id"),
|
||
"requested_at": _current_request_timestamp(),
|
||
},
|
||
)
|
||
|
||
if method == "pal24":
|
||
if not settings.is_pal24_enabled():
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||
if amount_kopeks is None or amount_kopeks <= 0:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||
if amount_kopeks < settings.PAL24_MIN_AMOUNT_KOPEKS:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount is below minimum")
|
||
if amount_kopeks > settings.PAL24_MAX_AMOUNT_KOPEKS:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount exceeds maximum")
|
||
|
||
option = (payload.payment_option or "").strip().lower()
|
||
if option not in {"card", "sbp"}:
|
||
option = "sbp"
|
||
provider_method = "CARD" if option == "card" else "SBP"
|
||
|
||
payment_service = PaymentService()
|
||
result = await payment_service.create_pal24_payment(
|
||
db=db,
|
||
user_id=user.id,
|
||
amount_kopeks=amount_kopeks,
|
||
description=settings.get_balance_payment_description(amount_kopeks),
|
||
language=user.language or settings.DEFAULT_LANGUAGE,
|
||
payment_method=provider_method,
|
||
)
|
||
if not result:
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||
|
||
preferred_urls: List[Optional[str]] = []
|
||
if option == "sbp":
|
||
preferred_urls.append(result.get("sbp_url"))
|
||
elif option == "card":
|
||
preferred_urls.append(result.get("card_url"))
|
||
preferred_urls.extend(
|
||
[
|
||
result.get("link_url"),
|
||
result.get("link_page_url"),
|
||
result.get("payment_url"),
|
||
result.get("transfer_url"),
|
||
]
|
||
)
|
||
payment_url = next((url for url in preferred_urls if url), None)
|
||
if not payment_url:
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url")
|
||
|
||
return MiniAppPaymentCreateResponse(
|
||
method=method,
|
||
payment_url=payment_url,
|
||
amount_kopeks=amount_kopeks,
|
||
extra={
|
||
"local_payment_id": result.get("local_payment_id"),
|
||
"bill_id": result.get("bill_id"),
|
||
"order_id": result.get("order_id"),
|
||
"payment_method": result.get("payment_method") or provider_method,
|
||
"sbp_url": result.get("sbp_url"),
|
||
"card_url": result.get("card_url"),
|
||
"link_url": result.get("link_url"),
|
||
"link_page_url": result.get("link_page_url"),
|
||
"transfer_url": result.get("transfer_url"),
|
||
"selected_option": option,
|
||
"requested_at": _current_request_timestamp(),
|
||
},
|
||
)
|
||
|
||
if method == "cryptobot":
|
||
if not settings.is_cryptobot_enabled():
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||
if amount_kopeks is None or amount_kopeks <= 0:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Amount must be positive")
|
||
rate = await _get_usd_to_rub_rate()
|
||
min_amount_kopeks, max_amount_kopeks = _compute_cryptobot_limits(rate)
|
||
if amount_kopeks < min_amount_kopeks:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Amount is below minimum ({min_amount_kopeks / 100:.2f} RUB)",
|
||
)
|
||
if amount_kopeks > max_amount_kopeks:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail=f"Amount exceeds maximum ({max_amount_kopeks / 100:.2f} RUB)",
|
||
)
|
||
|
||
try:
|
||
amount_usd = float(
|
||
(Decimal(amount_kopeks) / Decimal(100) / Decimal(str(rate)))
|
||
.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||
)
|
||
except (InvalidOperation, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail="Unable to convert amount to USD",
|
||
)
|
||
|
||
payment_service = PaymentService()
|
||
result = await payment_service.create_cryptobot_payment(
|
||
db=db,
|
||
user_id=user.id,
|
||
amount_usd=amount_usd,
|
||
asset=settings.CRYPTOBOT_DEFAULT_ASSET,
|
||
description=settings.get_balance_payment_description(amount_kopeks),
|
||
payload=f"balance_{user.id}_{amount_kopeks}",
|
||
)
|
||
if not result:
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||
|
||
payment_url = (
|
||
result.get("bot_invoice_url")
|
||
or result.get("mini_app_invoice_url")
|
||
or result.get("web_app_invoice_url")
|
||
)
|
||
if not payment_url:
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to obtain payment url")
|
||
|
||
return MiniAppPaymentCreateResponse(
|
||
method=method,
|
||
payment_url=payment_url,
|
||
amount_kopeks=amount_kopeks,
|
||
extra={
|
||
"local_payment_id": result.get("local_payment_id"),
|
||
"invoice_id": result.get("invoice_id"),
|
||
"amount_usd": amount_usd,
|
||
"rate": rate,
|
||
"requested_at": _current_request_timestamp(),
|
||
},
|
||
)
|
||
|
||
if method == "tribute":
|
||
if not settings.TRIBUTE_ENABLED:
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Payment method is unavailable")
|
||
if not settings.BOT_TOKEN:
|
||
raise HTTPException(status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Bot token is not configured")
|
||
|
||
bot = Bot(token=settings.BOT_TOKEN)
|
||
try:
|
||
tribute_service = TributeService(bot)
|
||
payment_url = await tribute_service.create_payment_link(
|
||
user_id=user.telegram_id,
|
||
amount_kopeks=amount_kopeks or 0,
|
||
description=settings.get_balance_payment_description(amount_kopeks or 0),
|
||
)
|
||
finally:
|
||
await bot.session.close()
|
||
|
||
if not payment_url:
|
||
raise HTTPException(status.HTTP_502_BAD_GATEWAY, detail="Failed to create payment")
|
||
|
||
return MiniAppPaymentCreateResponse(
|
||
method=method,
|
||
payment_url=payment_url,
|
||
amount_kopeks=amount_kopeks,
|
||
extra={
|
||
"requested_at": _current_request_timestamp(),
|
||
},
|
||
)
|
||
|
||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="Unknown payment method")
|
||
|
||
|
||
@router.post(
|
||
"/payments/status",
|
||
response_model=MiniAppPaymentStatusResponse,
|
||
)
|
||
async def get_payment_statuses(
|
||
payload: MiniAppPaymentStatusRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppPaymentStatusResponse:
|
||
user, _ = await _resolve_user_from_init_data(db, payload.init_data)
|
||
|
||
entries = payload.payments or []
|
||
if not entries:
|
||
return MiniAppPaymentStatusResponse(results=[])
|
||
|
||
payment_service = PaymentService()
|
||
results: List[MiniAppPaymentStatusResult] = []
|
||
|
||
for entry in entries:
|
||
result = await _resolve_payment_status_entry(
|
||
payment_service=payment_service,
|
||
db=db,
|
||
user=user,
|
||
query=entry,
|
||
)
|
||
if result:
|
||
results.append(result)
|
||
|
||
return MiniAppPaymentStatusResponse(results=results)
|
||
|
||
|
||
async def _resolve_payment_status_entry(
|
||
*,
|
||
payment_service: PaymentService,
|
||
db: AsyncSession,
|
||
user: User,
|
||
query: MiniAppPaymentStatusQuery,
|
||
) -> MiniAppPaymentStatusResult:
|
||
method = (query.method or "").strip().lower()
|
||
if not method:
|
||
return MiniAppPaymentStatusResult(
|
||
method="",
|
||
status="unknown",
|
||
message="Payment method is required",
|
||
)
|
||
|
||
if method == "yookassa":
|
||
return await _resolve_yookassa_payment_status(db, user, query)
|
||
if method == "mulenpay":
|
||
return await _resolve_mulenpay_payment_status(payment_service, db, user, query)
|
||
if method == "pal24":
|
||
return await _resolve_pal24_payment_status(payment_service, db, user, query)
|
||
if method == "cryptobot":
|
||
return await _resolve_cryptobot_payment_status(db, user, query)
|
||
if method == "stars":
|
||
return await _resolve_stars_payment_status(db, user, query)
|
||
if method == "tribute":
|
||
return await _resolve_tribute_payment_status(db, user, query)
|
||
|
||
return MiniAppPaymentStatusResult(
|
||
method=method,
|
||
status="unknown",
|
||
message="Unsupported payment method",
|
||
)
|
||
|
||
|
||
async def _resolve_yookassa_payment_status(
|
||
db: AsyncSession,
|
||
user: User,
|
||
query: MiniAppPaymentStatusQuery,
|
||
) -> MiniAppPaymentStatusResult:
|
||
from app.database.crud.yookassa import (
|
||
get_yookassa_payment_by_id,
|
||
get_yookassa_payment_by_local_id,
|
||
)
|
||
|
||
payment = None
|
||
if query.local_payment_id:
|
||
payment = await get_yookassa_payment_by_local_id(db, query.local_payment_id)
|
||
if not payment and query.payment_id:
|
||
payment = await get_yookassa_payment_by_id(db, query.payment_id)
|
||
|
||
if not payment or payment.user_id != user.id:
|
||
return MiniAppPaymentStatusResult(
|
||
method="yookassa",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Payment not found",
|
||
extra={
|
||
"local_payment_id": query.local_payment_id,
|
||
"payment_id": query.payment_id,
|
||
"invoice_id": query.payment_id,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
succeeded = bool(payment.is_paid and (payment.status or "").lower() == "succeeded")
|
||
status = _classify_status(payment.status, succeeded)
|
||
completed_at = payment.captured_at or payment.updated_at or payment.created_at
|
||
|
||
return MiniAppPaymentStatusResult(
|
||
method="yookassa",
|
||
status=status,
|
||
is_paid=status == "paid",
|
||
amount_kopeks=payment.amount_kopeks,
|
||
currency=payment.currency,
|
||
completed_at=completed_at,
|
||
transaction_id=payment.transaction_id,
|
||
external_id=payment.yookassa_payment_id,
|
||
extra={
|
||
"status": payment.status,
|
||
"is_paid": payment.is_paid,
|
||
"local_payment_id": payment.id,
|
||
"payment_id": payment.yookassa_payment_id,
|
||
"invoice_id": payment.yookassa_payment_id,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
|
||
async def _resolve_mulenpay_payment_status(
|
||
payment_service: PaymentService,
|
||
db: AsyncSession,
|
||
user: User,
|
||
query: MiniAppPaymentStatusQuery,
|
||
) -> MiniAppPaymentStatusResult:
|
||
if not query.local_payment_id:
|
||
return MiniAppPaymentStatusResult(
|
||
method="mulenpay",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Missing payment identifier",
|
||
extra={
|
||
"local_payment_id": query.local_payment_id,
|
||
"invoice_id": query.invoice_id,
|
||
"payment_id": query.payment_id,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
status_info = await payment_service.get_mulenpay_payment_status(db, query.local_payment_id)
|
||
payment = status_info.get("payment") if status_info else None
|
||
|
||
if not payment or payment.user_id != user.id:
|
||
return MiniAppPaymentStatusResult(
|
||
method="mulenpay",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Payment not found",
|
||
extra={
|
||
"local_payment_id": query.local_payment_id,
|
||
"invoice_id": query.invoice_id,
|
||
"payment_id": query.payment_id,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
status_raw = status_info.get("status") or payment.status
|
||
is_paid = bool(payment.is_paid)
|
||
status = _classify_status(status_raw, is_paid)
|
||
completed_at = payment.paid_at or payment.updated_at or payment.created_at
|
||
message = None
|
||
if status == "failed":
|
||
remote_status = status_info.get("remote_status_code") or status_raw
|
||
if remote_status:
|
||
message = f"Status: {remote_status}"
|
||
|
||
return MiniAppPaymentStatusResult(
|
||
method="mulenpay",
|
||
status=status,
|
||
is_paid=status == "paid",
|
||
amount_kopeks=payment.amount_kopeks,
|
||
currency=payment.currency,
|
||
completed_at=completed_at,
|
||
transaction_id=payment.transaction_id,
|
||
external_id=str(payment.mulen_payment_id or payment.uuid),
|
||
message=message,
|
||
extra={
|
||
"status": payment.status,
|
||
"remote_status": status_info.get("remote_status_code"),
|
||
"local_payment_id": payment.id,
|
||
"payment_id": payment.mulen_payment_id,
|
||
"uuid": str(payment.uuid),
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
|
||
async def _resolve_pal24_payment_status(
|
||
payment_service: PaymentService,
|
||
db: AsyncSession,
|
||
user: User,
|
||
query: MiniAppPaymentStatusQuery,
|
||
) -> MiniAppPaymentStatusResult:
|
||
from app.database.crud.pal24 import get_pal24_payment_by_bill_id
|
||
|
||
local_id = query.local_payment_id
|
||
if not local_id and query.invoice_id:
|
||
payment_by_bill = await get_pal24_payment_by_bill_id(db, query.invoice_id)
|
||
if payment_by_bill and payment_by_bill.user_id == user.id:
|
||
local_id = payment_by_bill.id
|
||
|
||
if not local_id:
|
||
return MiniAppPaymentStatusResult(
|
||
method="pal24",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Missing payment identifier",
|
||
extra={
|
||
"local_payment_id": query.local_payment_id,
|
||
"bill_id": query.invoice_id,
|
||
"order_id": None,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
status_info = await payment_service.get_pal24_payment_status(db, local_id)
|
||
payment = status_info.get("payment") if status_info else None
|
||
|
||
if not payment or payment.user_id != user.id:
|
||
return MiniAppPaymentStatusResult(
|
||
method="pal24",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Payment not found",
|
||
extra={
|
||
"local_payment_id": local_id,
|
||
"bill_id": query.invoice_id,
|
||
"order_id": None,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
status_raw = status_info.get("status") or payment.status
|
||
is_paid = bool(payment.is_paid)
|
||
status = _classify_status(status_raw, is_paid)
|
||
completed_at = payment.paid_at or payment.updated_at or payment.created_at
|
||
message = None
|
||
if status == "failed":
|
||
remote_status = status_info.get("remote_status") or status_raw
|
||
if remote_status:
|
||
message = f"Status: {remote_status}"
|
||
|
||
return MiniAppPaymentStatusResult(
|
||
method="pal24",
|
||
status=status,
|
||
is_paid=status == "paid",
|
||
amount_kopeks=payment.amount_kopeks,
|
||
currency=payment.currency,
|
||
completed_at=completed_at,
|
||
transaction_id=payment.transaction_id,
|
||
external_id=payment.bill_id,
|
||
message=message,
|
||
extra={
|
||
"status": payment.status,
|
||
"remote_status": status_info.get("remote_status"),
|
||
"local_payment_id": payment.id,
|
||
"bill_id": payment.bill_id,
|
||
"order_id": payment.order_id,
|
||
"payment_method": getattr(payment, "payment_method", None),
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
|
||
async def _resolve_cryptobot_payment_status(
|
||
db: AsyncSession,
|
||
user: User,
|
||
query: MiniAppPaymentStatusQuery,
|
||
) -> MiniAppPaymentStatusResult:
|
||
from app.database.crud.cryptobot import (
|
||
get_cryptobot_payment_by_id,
|
||
get_cryptobot_payment_by_invoice_id,
|
||
)
|
||
|
||
payment = None
|
||
if query.local_payment_id:
|
||
payment = await get_cryptobot_payment_by_id(db, query.local_payment_id)
|
||
if not payment and query.invoice_id:
|
||
payment = await get_cryptobot_payment_by_invoice_id(db, query.invoice_id)
|
||
|
||
if not payment or payment.user_id != user.id:
|
||
return MiniAppPaymentStatusResult(
|
||
method="cryptobot",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Payment not found",
|
||
extra={
|
||
"local_payment_id": query.local_payment_id,
|
||
"invoice_id": query.invoice_id,
|
||
"payment_id": query.payment_id,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
status_raw = payment.status
|
||
is_paid = (status_raw or "").lower() == "paid"
|
||
status = _classify_status(status_raw, is_paid)
|
||
completed_at = payment.paid_at or payment.updated_at or payment.created_at
|
||
|
||
amount_kopeks = None
|
||
try:
|
||
amount_kopeks = int(Decimal(payment.amount) * Decimal(100))
|
||
except (InvalidOperation, TypeError):
|
||
amount_kopeks = None
|
||
|
||
return MiniAppPaymentStatusResult(
|
||
method="cryptobot",
|
||
status=status,
|
||
is_paid=status == "paid",
|
||
amount_kopeks=amount_kopeks,
|
||
currency=payment.asset,
|
||
completed_at=completed_at,
|
||
transaction_id=payment.transaction_id,
|
||
external_id=payment.invoice_id,
|
||
extra={
|
||
"status": payment.status,
|
||
"asset": payment.asset,
|
||
"local_payment_id": payment.id,
|
||
"invoice_id": payment.invoice_id,
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
|
||
async def _resolve_stars_payment_status(
|
||
db: AsyncSession,
|
||
user: User,
|
||
query: MiniAppPaymentStatusQuery,
|
||
) -> MiniAppPaymentStatusResult:
|
||
started_at = _parse_client_timestamp(query.started_at)
|
||
transaction = await _find_recent_deposit(
|
||
db,
|
||
user_id=user.id,
|
||
payment_method=PaymentMethod.TELEGRAM_STARS,
|
||
amount_kopeks=query.amount_kopeks,
|
||
started_at=started_at,
|
||
)
|
||
|
||
if not transaction:
|
||
return MiniAppPaymentStatusResult(
|
||
method="stars",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Waiting for confirmation",
|
||
extra={
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
return MiniAppPaymentStatusResult(
|
||
method="stars",
|
||
status="paid",
|
||
is_paid=True,
|
||
amount_kopeks=transaction.amount_kopeks,
|
||
currency="RUB",
|
||
completed_at=transaction.completed_at or transaction.created_at,
|
||
transaction_id=transaction.id,
|
||
external_id=transaction.external_id,
|
||
extra={
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
|
||
async def _resolve_tribute_payment_status(
|
||
db: AsyncSession,
|
||
user: User,
|
||
query: MiniAppPaymentStatusQuery,
|
||
) -> MiniAppPaymentStatusResult:
|
||
started_at = _parse_client_timestamp(query.started_at)
|
||
transaction = await _find_recent_deposit(
|
||
db,
|
||
user_id=user.id,
|
||
payment_method=PaymentMethod.TRIBUTE,
|
||
amount_kopeks=query.amount_kopeks,
|
||
started_at=started_at,
|
||
)
|
||
|
||
if not transaction:
|
||
return MiniAppPaymentStatusResult(
|
||
method="tribute",
|
||
status="pending",
|
||
is_paid=False,
|
||
amount_kopeks=query.amount_kopeks,
|
||
message="Waiting for confirmation",
|
||
extra={
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
return MiniAppPaymentStatusResult(
|
||
method="tribute",
|
||
status="paid",
|
||
is_paid=True,
|
||
amount_kopeks=transaction.amount_kopeks,
|
||
currency="RUB",
|
||
completed_at=transaction.completed_at or transaction.created_at,
|
||
transaction_id=transaction.id,
|
||
external_id=transaction.external_id,
|
||
extra={
|
||
"payload": query.payload,
|
||
"started_at": query.started_at,
|
||
},
|
||
)
|
||
|
||
|
||
_TEMPLATE_ID_PATTERN = re.compile(r"promo_template_(?P<template_id>\d+)$")
|
||
_OFFER_TYPE_ICONS = {
|
||
"extend_discount": "💎",
|
||
"purchase_discount": "🎯",
|
||
"test_access": "🧪",
|
||
}
|
||
_EFFECT_TYPE_ICONS = {
|
||
"percent_discount": "🎁",
|
||
"test_access": "🧪",
|
||
"balance_bonus": "💰",
|
||
}
|
||
_DEFAULT_OFFER_ICON = "🎉"
|
||
|
||
ActiveOfferContext = Tuple[Any, Optional[int], Optional[datetime]]
|
||
|
||
|
||
def _extract_template_id(notification_type: Optional[str]) -> Optional[int]:
|
||
if not notification_type:
|
||
return None
|
||
|
||
match = _TEMPLATE_ID_PATTERN.match(notification_type)
|
||
if not match:
|
||
return None
|
||
|
||
try:
|
||
return int(match.group("template_id"))
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _extract_offer_extra(offer: Any) -> Dict[str, Any]:
|
||
extra = getattr(offer, "extra_data", None)
|
||
return extra if isinstance(extra, dict) else {}
|
||
|
||
|
||
def _extract_offer_type(offer: Any, template: Optional[PromoOfferTemplate]) -> Optional[str]:
|
||
extra = _extract_offer_extra(offer)
|
||
offer_type = extra.get("offer_type") if isinstance(extra.get("offer_type"), str) else None
|
||
if offer_type:
|
||
return offer_type
|
||
template_type = getattr(template, "offer_type", None)
|
||
return template_type if isinstance(template_type, str) else None
|
||
|
||
|
||
def _normalize_effect_type(effect_type: Optional[str]) -> str:
|
||
normalized = (effect_type or "percent_discount").strip().lower()
|
||
if normalized == "balance_bonus":
|
||
return "percent_discount"
|
||
return normalized or "percent_discount"
|
||
|
||
|
||
def _determine_offer_icon(offer_type: Optional[str], effect_type: str) -> str:
|
||
if offer_type and offer_type in _OFFER_TYPE_ICONS:
|
||
return _OFFER_TYPE_ICONS[offer_type]
|
||
if effect_type in _EFFECT_TYPE_ICONS:
|
||
return _EFFECT_TYPE_ICONS[effect_type]
|
||
return _DEFAULT_OFFER_ICON
|
||
|
||
|
||
def _extract_offer_test_squad_uuids(offer: Any) -> List[str]:
|
||
extra = _extract_offer_extra(offer)
|
||
raw = extra.get("test_squad_uuids") or extra.get("squads") or []
|
||
|
||
if isinstance(raw, str):
|
||
raw = [raw]
|
||
|
||
uuids: List[str] = []
|
||
try:
|
||
for item in raw:
|
||
if not item:
|
||
continue
|
||
uuids.append(str(item))
|
||
except TypeError:
|
||
return []
|
||
|
||
return uuids
|
||
|
||
|
||
def _format_offer_message(
|
||
template: Optional[PromoOfferTemplate],
|
||
offer: Any,
|
||
*,
|
||
server_name: Optional[str] = None,
|
||
) -> Optional[str]:
|
||
message_template: Optional[str] = None
|
||
|
||
if template and isinstance(template.message_text, str):
|
||
message_template = template.message_text
|
||
else:
|
||
extra = _extract_offer_extra(offer)
|
||
raw_message = extra.get("message_text") or extra.get("text")
|
||
if isinstance(raw_message, str):
|
||
message_template = raw_message
|
||
|
||
if not message_template:
|
||
return None
|
||
|
||
extra = _extract_offer_extra(offer)
|
||
discount_percent = getattr(offer, "discount_percent", None)
|
||
try:
|
||
discount_percent = int(discount_percent)
|
||
except (TypeError, ValueError):
|
||
discount_percent = None
|
||
|
||
replacements: Dict[str, Any] = {}
|
||
if discount_percent is not None:
|
||
replacements.setdefault("discount_percent", discount_percent)
|
||
|
||
for key in ("valid_hours", "active_discount_hours", "test_duration_hours"):
|
||
value = extra.get(key)
|
||
if value is None and template is not None:
|
||
template_value = getattr(template, key, None)
|
||
else:
|
||
template_value = None
|
||
replacements.setdefault(key, value if value is not None else template_value)
|
||
|
||
if replacements.get("active_discount_hours") is None and template:
|
||
replacements["active_discount_hours"] = getattr(template, "valid_hours", None)
|
||
|
||
if replacements.get("test_duration_hours") is None and template:
|
||
replacements["test_duration_hours"] = getattr(template, "test_duration_hours", None)
|
||
|
||
if server_name:
|
||
replacements.setdefault("server_name", server_name)
|
||
|
||
for key, value in extra.items():
|
||
if (
|
||
isinstance(key, str)
|
||
and key not in replacements
|
||
and isinstance(value, (str, int, float))
|
||
):
|
||
replacements[key] = value
|
||
|
||
try:
|
||
return message_template.format(**replacements)
|
||
except Exception: # pragma: no cover - fallback for malformed templates
|
||
return message_template
|
||
|
||
|
||
def _extract_offer_duration_hours(
|
||
offer: Any,
|
||
template: Optional[PromoOfferTemplate],
|
||
effect_type: str,
|
||
) -> Optional[int]:
|
||
extra = _extract_offer_extra(offer)
|
||
if effect_type == "test_access":
|
||
source = extra.get("test_duration_hours")
|
||
if source is None and template is not None:
|
||
source = getattr(template, "test_duration_hours", None)
|
||
else:
|
||
source = extra.get("active_discount_hours")
|
||
if source is None and template is not None:
|
||
source = getattr(template, "active_discount_hours", None)
|
||
|
||
try:
|
||
if source is None:
|
||
return None
|
||
hours = int(float(source))
|
||
return hours if hours > 0 else None
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _format_bonus_label(amount_kopeks: int) -> Optional[str]:
|
||
if amount_kopeks <= 0:
|
||
return None
|
||
try:
|
||
return settings.format_price(amount_kopeks)
|
||
except Exception: # pragma: no cover - defensive
|
||
return f"{amount_kopeks / 100:.2f}"
|
||
|
||
|
||
async def _find_active_test_access_offers(
|
||
db: AsyncSession,
|
||
subscription: Optional[Subscription],
|
||
) -> List[ActiveOfferContext]:
|
||
if not subscription or not getattr(subscription, "id", None):
|
||
return []
|
||
|
||
now = datetime.utcnow()
|
||
result = await db.execute(
|
||
select(SubscriptionTemporaryAccess)
|
||
.options(selectinload(SubscriptionTemporaryAccess.offer))
|
||
.where(
|
||
SubscriptionTemporaryAccess.subscription_id == subscription.id,
|
||
SubscriptionTemporaryAccess.is_active == True, # noqa: E712
|
||
SubscriptionTemporaryAccess.expires_at > now,
|
||
)
|
||
.order_by(SubscriptionTemporaryAccess.expires_at.desc())
|
||
)
|
||
|
||
entries = list(result.scalars().all())
|
||
if not entries:
|
||
return []
|
||
|
||
offer_map: Dict[int, Tuple[Any, Optional[datetime]]] = {}
|
||
for entry in entries:
|
||
offer = getattr(entry, "offer", None)
|
||
if not offer:
|
||
continue
|
||
|
||
effect_type = _normalize_effect_type(getattr(offer, "effect_type", None))
|
||
if effect_type != "test_access":
|
||
continue
|
||
|
||
expires_at = getattr(entry, "expires_at", None)
|
||
if not expires_at or expires_at <= now:
|
||
continue
|
||
|
||
offer_id = getattr(offer, "id", None)
|
||
if not isinstance(offer_id, int):
|
||
continue
|
||
|
||
current = offer_map.get(offer_id)
|
||
if current is None:
|
||
offer_map[offer_id] = (offer, expires_at)
|
||
else:
|
||
_, current_expiry = current
|
||
if current_expiry is None or (expires_at and expires_at > current_expiry):
|
||
offer_map[offer_id] = (offer, expires_at)
|
||
|
||
contexts: List[ActiveOfferContext] = []
|
||
for offer_id, (offer, expires_at) in offer_map.items():
|
||
contexts.append((offer, None, expires_at))
|
||
|
||
contexts.sort(key=lambda item: item[2] or now, reverse=True)
|
||
return contexts
|
||
|
||
|
||
async def _build_promo_offer_models(
|
||
db: AsyncSession,
|
||
available_offers: List[Any],
|
||
active_offers: Optional[List[ActiveOfferContext]],
|
||
*,
|
||
user: User,
|
||
) -> List[MiniAppPromoOffer]:
|
||
promo_offers: List[MiniAppPromoOffer] = []
|
||
template_cache: Dict[int, Optional[PromoOfferTemplate]] = {}
|
||
|
||
candidates: List[Any] = [offer for offer in available_offers if offer]
|
||
active_offer_contexts: List[ActiveOfferContext] = []
|
||
if active_offers:
|
||
for offer, discount_override, expires_override in active_offers:
|
||
if not offer:
|
||
continue
|
||
active_offer_contexts.append((offer, discount_override, expires_override))
|
||
candidates.append(offer)
|
||
|
||
squad_map: Dict[str, MiniAppConnectedServer] = {}
|
||
if candidates:
|
||
all_uuids: List[str] = []
|
||
for offer in candidates:
|
||
all_uuids.extend(_extract_offer_test_squad_uuids(offer))
|
||
if all_uuids:
|
||
unique = list(dict.fromkeys(all_uuids))
|
||
resolved = await _resolve_connected_servers(db, unique)
|
||
squad_map = {server.uuid: server for server in resolved}
|
||
|
||
async def get_template(template_id: Optional[int]) -> Optional[PromoOfferTemplate]:
|
||
if not template_id:
|
||
return None
|
||
if template_id not in template_cache:
|
||
template_cache[template_id] = await get_promo_offer_template_by_id(db, template_id)
|
||
return template_cache[template_id]
|
||
|
||
def build_test_squads(offer: Any) -> List[MiniAppConnectedServer]:
|
||
test_squads: List[MiniAppConnectedServer] = []
|
||
for uuid in _extract_offer_test_squad_uuids(offer):
|
||
resolved = squad_map.get(uuid)
|
||
if resolved:
|
||
test_squads.append(
|
||
MiniAppConnectedServer(uuid=resolved.uuid, name=resolved.name)
|
||
)
|
||
else:
|
||
test_squads.append(MiniAppConnectedServer(uuid=uuid, name=uuid))
|
||
return test_squads
|
||
|
||
def resolve_title(
|
||
offer: Any,
|
||
template: Optional[PromoOfferTemplate],
|
||
offer_type: Optional[str],
|
||
) -> Optional[str]:
|
||
extra = _extract_offer_extra(offer)
|
||
if isinstance(extra.get("title"), str) and extra["title"].strip():
|
||
return extra["title"].strip()
|
||
if template and template.name:
|
||
return template.name
|
||
if offer_type:
|
||
return offer_type.replace("_", " ").title()
|
||
return None
|
||
|
||
for offer in available_offers:
|
||
template_id = _extract_template_id(getattr(offer, "notification_type", None))
|
||
template = await get_template(template_id)
|
||
effect_type = _normalize_effect_type(getattr(offer, "effect_type", None))
|
||
offer_type = _extract_offer_type(offer, template)
|
||
test_squads = build_test_squads(offer)
|
||
server_name = test_squads[0].name if test_squads else None
|
||
message_text = _format_offer_message(template, offer, server_name=server_name)
|
||
bonus_label = _format_bonus_label(int(getattr(offer, "bonus_amount_kopeks", 0) or 0))
|
||
discount_percent = getattr(offer, "discount_percent", 0)
|
||
try:
|
||
discount_percent = int(discount_percent)
|
||
except (TypeError, ValueError):
|
||
discount_percent = 0
|
||
|
||
extra = _extract_offer_extra(offer)
|
||
button_text = None
|
||
if isinstance(extra.get("button_text"), str) and extra["button_text"].strip():
|
||
button_text = extra["button_text"].strip()
|
||
elif template and isinstance(template.button_text, str):
|
||
button_text = template.button_text
|
||
|
||
promo_offers.append(
|
||
MiniAppPromoOffer(
|
||
id=int(getattr(offer, "id", 0) or 0),
|
||
status="pending",
|
||
notification_type=getattr(offer, "notification_type", None),
|
||
offer_type=offer_type,
|
||
effect_type=effect_type,
|
||
discount_percent=max(0, discount_percent),
|
||
bonus_amount_kopeks=int(getattr(offer, "bonus_amount_kopeks", 0) or 0),
|
||
bonus_amount_label=bonus_label,
|
||
expires_at=getattr(offer, "expires_at", None),
|
||
claimed_at=getattr(offer, "claimed_at", None),
|
||
is_active=bool(getattr(offer, "is_active", False)),
|
||
template_id=template_id,
|
||
template_name=getattr(template, "name", None),
|
||
button_text=button_text,
|
||
title=resolve_title(offer, template, offer_type),
|
||
message_text=message_text,
|
||
icon=_determine_offer_icon(offer_type, effect_type),
|
||
test_squads=test_squads,
|
||
)
|
||
)
|
||
|
||
if active_offer_contexts:
|
||
seen_active_ids: set[int] = set()
|
||
for active_offer_record, discount_override, expires_override in reversed(active_offer_contexts):
|
||
offer_id = int(getattr(active_offer_record, "id", 0) or 0)
|
||
if offer_id and offer_id in seen_active_ids:
|
||
continue
|
||
if offer_id:
|
||
seen_active_ids.add(offer_id)
|
||
|
||
template_id = _extract_template_id(getattr(active_offer_record, "notification_type", None))
|
||
template = await get_template(template_id)
|
||
effect_type = _normalize_effect_type(getattr(active_offer_record, "effect_type", None))
|
||
offer_type = _extract_offer_type(active_offer_record, template)
|
||
show_active = False
|
||
discount_value = discount_override if discount_override is not None else 0
|
||
if discount_value and discount_value > 0:
|
||
show_active = True
|
||
elif effect_type == "test_access":
|
||
show_active = True
|
||
if not show_active:
|
||
continue
|
||
|
||
test_squads = build_test_squads(active_offer_record)
|
||
server_name = test_squads[0].name if test_squads else None
|
||
message_text = _format_offer_message(
|
||
template,
|
||
active_offer_record,
|
||
server_name=server_name,
|
||
)
|
||
bonus_label = _format_bonus_label(
|
||
int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0)
|
||
)
|
||
|
||
started_at = getattr(active_offer_record, "claimed_at", None)
|
||
expires_at = expires_override or getattr(active_offer_record, "expires_at", None)
|
||
duration_seconds: Optional[int] = None
|
||
duration_hours = _extract_offer_duration_hours(active_offer_record, template, effect_type)
|
||
if expires_at is None and duration_hours and started_at:
|
||
expires_at = started_at + timedelta(hours=duration_hours)
|
||
if expires_at and started_at:
|
||
try:
|
||
duration_seconds = int((expires_at - started_at).total_seconds())
|
||
except Exception: # pragma: no cover - defensive
|
||
duration_seconds = None
|
||
|
||
if (discount_value is None or discount_value <= 0) and effect_type != "test_access":
|
||
try:
|
||
discount_value = int(getattr(active_offer_record, "discount_percent", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
discount_value = 0
|
||
if discount_value is None:
|
||
discount_value = 0
|
||
|
||
extra = _extract_offer_extra(active_offer_record)
|
||
button_text = None
|
||
if isinstance(extra.get("button_text"), str) and extra["button_text"].strip():
|
||
button_text = extra["button_text"].strip()
|
||
elif template and isinstance(template.button_text, str):
|
||
button_text = template.button_text
|
||
|
||
promo_offers.insert(
|
||
0,
|
||
MiniAppPromoOffer(
|
||
id=offer_id,
|
||
status="active",
|
||
notification_type=getattr(active_offer_record, "notification_type", None),
|
||
offer_type=offer_type,
|
||
effect_type=effect_type,
|
||
discount_percent=max(0, discount_value or 0),
|
||
bonus_amount_kopeks=int(getattr(active_offer_record, "bonus_amount_kopeks", 0) or 0),
|
||
bonus_amount_label=bonus_label,
|
||
expires_at=getattr(active_offer_record, "expires_at", None),
|
||
claimed_at=started_at,
|
||
is_active=False,
|
||
template_id=template_id,
|
||
template_name=getattr(template, "name", None),
|
||
button_text=button_text,
|
||
title=resolve_title(active_offer_record, template, offer_type),
|
||
message_text=message_text,
|
||
icon=_determine_offer_icon(offer_type, effect_type),
|
||
test_squads=test_squads,
|
||
active_discount_expires_at=expires_at,
|
||
active_discount_started_at=started_at,
|
||
active_discount_duration_seconds=duration_seconds,
|
||
),
|
||
)
|
||
|
||
return promo_offers
|
||
|
||
|
||
def _bytes_to_gb(bytes_value: Optional[int]) -> float:
|
||
if not bytes_value:
|
||
return 0.0
|
||
return round(bytes_value / (1024 ** 3), 2)
|
||
|
||
|
||
def _status_label(status: str) -> str:
|
||
mapping = {
|
||
"active": "Active",
|
||
"trial": "Trial",
|
||
"expired": "Expired",
|
||
"disabled": "Disabled",
|
||
}
|
||
return mapping.get(status, status.title())
|
||
|
||
|
||
def _parse_datetime_string(value: Optional[str]) -> Optional[str]:
|
||
if not value:
|
||
return None
|
||
|
||
try:
|
||
cleaned = value.strip()
|
||
if cleaned.endswith("Z"):
|
||
cleaned = f"{cleaned[:-1]}+00:00"
|
||
# Normalize duplicated timezone suffixes like +00:00+00:00
|
||
if "+00:00+00:00" in cleaned:
|
||
cleaned = cleaned.replace("+00:00+00:00", "+00:00")
|
||
|
||
datetime.fromisoformat(cleaned)
|
||
return cleaned
|
||
except Exception: # pragma: no cover - defensive
|
||
return value
|
||
|
||
|
||
async def _resolve_connected_servers(
|
||
db: AsyncSession,
|
||
squad_uuids: List[str],
|
||
) -> List[MiniAppConnectedServer]:
|
||
if not squad_uuids:
|
||
return []
|
||
|
||
resolved: Dict[str, str] = {}
|
||
missing: List[str] = []
|
||
|
||
for squad_uuid in squad_uuids:
|
||
if squad_uuid in resolved:
|
||
continue
|
||
server = await get_server_squad_by_uuid(db, squad_uuid)
|
||
if server and server.display_name:
|
||
resolved[squad_uuid] = server.display_name
|
||
else:
|
||
missing.append(squad_uuid)
|
||
|
||
if missing:
|
||
try:
|
||
service = RemnaWaveService()
|
||
if service.is_configured:
|
||
squads = await service.get_all_squads()
|
||
for squad in squads:
|
||
uuid = squad.get("uuid")
|
||
name = squad.get("name")
|
||
if uuid in missing and name:
|
||
resolved[uuid] = name
|
||
except RemnaWaveConfigurationError:
|
||
logger.debug("RemnaWave is not configured; skipping server name enrichment")
|
||
except Exception as error: # pragma: no cover - defensive logging
|
||
logger.warning("Failed to resolve server names from RemnaWave: %s", error)
|
||
|
||
connected_servers: List[MiniAppConnectedServer] = []
|
||
for squad_uuid in squad_uuids:
|
||
name = resolved.get(squad_uuid, squad_uuid)
|
||
connected_servers.append(MiniAppConnectedServer(uuid=squad_uuid, name=name))
|
||
|
||
return connected_servers
|
||
|
||
|
||
async def _load_devices_info(user: User) -> Tuple[int, List[MiniAppDevice]]:
|
||
remnawave_uuid = getattr(user, "remnawave_uuid", None)
|
||
if not remnawave_uuid:
|
||
return 0, []
|
||
|
||
try:
|
||
service = RemnaWaveService()
|
||
except Exception as error: # pragma: no cover - defensive logging
|
||
logger.warning("Failed to initialise RemnaWave service: %s", error)
|
||
return 0, []
|
||
|
||
if not service.is_configured:
|
||
return 0, []
|
||
|
||
try:
|
||
async with service.get_api_client() as api:
|
||
response = await api.get_user_devices(remnawave_uuid)
|
||
except RemnaWaveConfigurationError:
|
||
logger.debug("RemnaWave configuration missing while loading devices")
|
||
return 0, []
|
||
except Exception as error: # pragma: no cover - defensive logging
|
||
logger.warning("Failed to load devices from RemnaWave: %s", error)
|
||
return 0, []
|
||
|
||
total_devices = int(response.get("total") or 0)
|
||
devices_payload = response.get("devices") or []
|
||
|
||
devices: List[MiniAppDevice] = []
|
||
for device in devices_payload:
|
||
hwid = device.get("hwid") or device.get("deviceId") or device.get("id")
|
||
platform = device.get("platform") or device.get("platformType")
|
||
model = device.get("deviceModel") or device.get("model") or device.get("name")
|
||
app_version = device.get("appVersion") or device.get("version")
|
||
last_seen_raw = (
|
||
device.get("updatedAt")
|
||
or device.get("lastSeen")
|
||
or device.get("lastActiveAt")
|
||
or device.get("createdAt")
|
||
)
|
||
last_ip = device.get("ip") or device.get("ipAddress")
|
||
|
||
devices.append(
|
||
MiniAppDevice(
|
||
hwid=hwid,
|
||
platform=platform,
|
||
device_model=model,
|
||
app_version=app_version,
|
||
last_seen=_parse_datetime_string(last_seen_raw),
|
||
last_ip=last_ip,
|
||
)
|
||
)
|
||
|
||
if total_devices == 0:
|
||
total_devices = len(devices)
|
||
|
||
return total_devices, devices
|
||
|
||
|
||
def _resolve_display_name(user_data: Dict[str, Any]) -> str:
|
||
username = user_data.get("username")
|
||
if username:
|
||
return username
|
||
|
||
first = user_data.get("first_name")
|
||
last = user_data.get("last_name")
|
||
parts = [part for part in [first, last] if part]
|
||
if parts:
|
||
return " ".join(parts)
|
||
|
||
telegram_id = user_data.get("telegram_id")
|
||
return f"User {telegram_id}" if telegram_id else "User"
|
||
|
||
|
||
def _is_remnawave_configured() -> bool:
|
||
params = settings.get_remnawave_auth_params()
|
||
return bool(params.get("base_url") and params.get("api_key"))
|
||
|
||
|
||
def _serialize_transaction(transaction: Transaction) -> MiniAppTransaction:
|
||
return MiniAppTransaction(
|
||
id=transaction.id,
|
||
type=transaction.type,
|
||
amount_kopeks=transaction.amount_kopeks,
|
||
amount_rubles=round(transaction.amount_kopeks / 100, 2),
|
||
description=transaction.description,
|
||
payment_method=transaction.payment_method,
|
||
external_id=transaction.external_id,
|
||
is_completed=transaction.is_completed,
|
||
created_at=transaction.created_at,
|
||
completed_at=transaction.completed_at,
|
||
)
|
||
|
||
|
||
async def _load_subscription_links(
|
||
subscription: Subscription,
|
||
) -> Dict[str, Any]:
|
||
if not subscription.remnawave_short_uuid or not _is_remnawave_configured():
|
||
return {}
|
||
|
||
try:
|
||
service = SubscriptionService()
|
||
info = await service.get_subscription_info(subscription.remnawave_short_uuid)
|
||
except Exception as error: # pragma: no cover - defensive logging
|
||
logger.warning("Failed to load subscription info from RemnaWave: %s", error)
|
||
return {}
|
||
|
||
if not info:
|
||
return {}
|
||
|
||
payload: Dict[str, Any] = {
|
||
"links": list(info.links or []),
|
||
"ss_conf_links": dict(info.ss_conf_links or {}),
|
||
"subscription_url": info.subscription_url,
|
||
"happ": info.happ,
|
||
"happ_link": getattr(info, "happ_link", None),
|
||
"happ_crypto_link": getattr(info, "happ_crypto_link", None),
|
||
}
|
||
|
||
return payload
|
||
|
||
|
||
async def _build_referral_info(
|
||
db: AsyncSession,
|
||
user: User,
|
||
) -> Optional[MiniAppReferralInfo]:
|
||
referral_code = getattr(user, "referral_code", None)
|
||
referral_settings = settings.get_referral_settings() or {}
|
||
|
||
bot_username = settings.get_bot_username()
|
||
referral_link = None
|
||
if referral_code and bot_username:
|
||
referral_link = f"https://t.me/{bot_username}?start={referral_code}"
|
||
|
||
minimum_topup_kopeks = int(referral_settings.get("minimum_topup_kopeks") or 0)
|
||
first_topup_bonus_kopeks = int(referral_settings.get("first_topup_bonus_kopeks") or 0)
|
||
inviter_bonus_kopeks = int(referral_settings.get("inviter_bonus_kopeks") or 0)
|
||
commission_percent = float(referral_settings.get("commission_percent") or 0)
|
||
|
||
referred_user_reward_kopeks = settings.get_referred_user_reward_kopeks()
|
||
for key in ("referred_user_reward_kopeks", "referred_user_reward"):
|
||
candidate = referral_settings.get(key)
|
||
if candidate is None:
|
||
continue
|
||
try:
|
||
value = int(candidate)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if value <= 0:
|
||
referred_user_reward_kopeks = 0
|
||
break
|
||
if key == "referred_user_reward" and value < 1000:
|
||
value *= 100
|
||
referred_user_reward_kopeks = value
|
||
break
|
||
|
||
terms = MiniAppReferralTerms(
|
||
minimum_topup_kopeks=minimum_topup_kopeks,
|
||
minimum_topup_label=settings.format_price(minimum_topup_kopeks),
|
||
first_topup_bonus_kopeks=first_topup_bonus_kopeks,
|
||
first_topup_bonus_label=settings.format_price(first_topup_bonus_kopeks),
|
||
inviter_bonus_kopeks=inviter_bonus_kopeks,
|
||
inviter_bonus_label=settings.format_price(inviter_bonus_kopeks),
|
||
commission_percent=commission_percent,
|
||
referred_user_reward_kopeks=referred_user_reward_kopeks,
|
||
referred_user_reward_label=settings.format_price(referred_user_reward_kopeks),
|
||
)
|
||
|
||
summary = await get_user_referral_summary(db, user.id)
|
||
stats: Optional[MiniAppReferralStats] = None
|
||
recent_earnings: List[MiniAppReferralRecentEarning] = []
|
||
|
||
if summary:
|
||
total_earned_kopeks = int(summary.get("total_earned_kopeks") or 0)
|
||
month_earned_kopeks = int(summary.get("month_earned_kopeks") or 0)
|
||
|
||
stats = MiniAppReferralStats(
|
||
invited_count=int(summary.get("invited_count") or 0),
|
||
paid_referrals_count=int(summary.get("paid_referrals_count") or 0),
|
||
active_referrals_count=int(summary.get("active_referrals_count") or 0),
|
||
total_earned_kopeks=total_earned_kopeks,
|
||
total_earned_label=settings.format_price(total_earned_kopeks),
|
||
month_earned_kopeks=month_earned_kopeks,
|
||
month_earned_label=settings.format_price(month_earned_kopeks),
|
||
conversion_rate=float(summary.get("conversion_rate") or 0.0),
|
||
)
|
||
|
||
for earning in summary.get("recent_earnings", []) or []:
|
||
amount = int(earning.get("amount_kopeks") or 0)
|
||
recent_earnings.append(
|
||
MiniAppReferralRecentEarning(
|
||
amount_kopeks=amount,
|
||
amount_label=settings.format_price(amount),
|
||
reason=earning.get("reason"),
|
||
referral_name=earning.get("referral_name"),
|
||
created_at=earning.get("created_at"),
|
||
)
|
||
)
|
||
|
||
detailed = await get_detailed_referral_list(db, user.id, limit=50, offset=0)
|
||
referral_items: List[MiniAppReferralItem] = []
|
||
if detailed:
|
||
for item in detailed.get("referrals", []) or []:
|
||
total_earned = int(item.get("total_earned_kopeks") or 0)
|
||
balance = int(item.get("balance_kopeks") or 0)
|
||
referral_items.append(
|
||
MiniAppReferralItem(
|
||
id=int(item.get("id") or 0),
|
||
telegram_id=item.get("telegram_id"),
|
||
full_name=item.get("full_name"),
|
||
username=item.get("username"),
|
||
created_at=item.get("created_at"),
|
||
last_activity=item.get("last_activity"),
|
||
has_made_first_topup=bool(item.get("has_made_first_topup")),
|
||
balance_kopeks=balance,
|
||
balance_label=settings.format_price(balance),
|
||
total_earned_kopeks=total_earned,
|
||
total_earned_label=settings.format_price(total_earned),
|
||
topups_count=int(item.get("topups_count") or 0),
|
||
days_since_registration=item.get("days_since_registration"),
|
||
days_since_activity=item.get("days_since_activity"),
|
||
status=item.get("status"),
|
||
)
|
||
)
|
||
|
||
referral_list = MiniAppReferralList(
|
||
total_count=int(detailed.get("total_count") or 0) if detailed else 0,
|
||
has_next=bool(detailed.get("has_next")) if detailed else False,
|
||
has_prev=bool(detailed.get("has_prev")) if detailed else False,
|
||
current_page=int(detailed.get("current_page") or 1) if detailed else 1,
|
||
total_pages=int(detailed.get("total_pages") or 1) if detailed else 1,
|
||
items=referral_items,
|
||
)
|
||
|
||
if (
|
||
not referral_code
|
||
and not referral_link
|
||
and not referral_items
|
||
and not recent_earnings
|
||
and (not stats or (stats.invited_count == 0 and stats.total_earned_kopeks == 0))
|
||
):
|
||
return None
|
||
|
||
return MiniAppReferralInfo(
|
||
referral_code=referral_code,
|
||
referral_link=referral_link,
|
||
terms=terms,
|
||
stats=stats,
|
||
recent_earnings=recent_earnings,
|
||
referrals=referral_list,
|
||
)
|
||
|
||
|
||
@router.post("/subscription", response_model=MiniAppSubscriptionResponse)
|
||
async def get_subscription_details(
|
||
payload: MiniAppSubscriptionRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionResponse:
|
||
try:
|
||
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
|
||
except TelegramWebAppAuthError as error:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||
detail=str(error),
|
||
) from error
|
||
|
||
telegram_user = webapp_data.get("user")
|
||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Invalid Telegram user payload",
|
||
)
|
||
|
||
try:
|
||
telegram_id = int(telegram_user["id"])
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="Invalid Telegram user identifier",
|
||
) from None
|
||
|
||
user = await get_user_by_telegram_id(db, telegram_id)
|
||
purchase_url = (settings.MINIAPP_PURCHASE_URL or "").strip()
|
||
if not user or not user.subscription:
|
||
detail: Union[str, Dict[str, str]] = "Subscription not found"
|
||
if purchase_url:
|
||
detail = {
|
||
"message": "Subscription not found",
|
||
"purchase_url": purchase_url,
|
||
}
|
||
raise HTTPException(
|
||
status_code=status.HTTP_404_NOT_FOUND,
|
||
detail=detail,
|
||
)
|
||
|
||
subscription = user.subscription
|
||
traffic_used = _format_gb(subscription.traffic_used_gb)
|
||
traffic_limit = subscription.traffic_limit_gb or 0
|
||
lifetime_used = _bytes_to_gb(getattr(user, "lifetime_used_traffic_bytes", 0))
|
||
|
||
status_actual = subscription.actual_status
|
||
links_payload = await _load_subscription_links(subscription)
|
||
|
||
subscription_url = links_payload.get("subscription_url") or subscription.subscription_url
|
||
subscription_crypto_link = (
|
||
links_payload.get("happ_crypto_link")
|
||
or subscription.subscription_crypto_link
|
||
)
|
||
|
||
happ_redirect_link = get_happ_cryptolink_redirect_link(subscription_crypto_link)
|
||
|
||
connected_squads: List[str] = list(subscription.connected_squads or [])
|
||
connected_servers = await _resolve_connected_servers(db, connected_squads)
|
||
devices_count, devices = await _load_devices_info(user)
|
||
links: List[str] = links_payload.get("links") or connected_squads
|
||
ss_conf_links: Dict[str, str] = links_payload.get("ss_conf_links") or {}
|
||
|
||
transactions_query = (
|
||
select(Transaction)
|
||
.where(Transaction.user_id == user.id)
|
||
.order_by(Transaction.created_at.desc())
|
||
.limit(10)
|
||
)
|
||
transactions_result = await db.execute(transactions_query)
|
||
transactions = list(transactions_result.scalars().all())
|
||
|
||
balance_currency = getattr(user, "balance_currency", None)
|
||
if isinstance(balance_currency, str):
|
||
balance_currency = balance_currency.upper()
|
||
|
||
promo_group = getattr(user, "promo_group", None)
|
||
total_spent_kopeks = await get_user_total_spent_kopeks(db, user.id)
|
||
auto_assign_groups = await get_auto_assign_promo_groups(db)
|
||
|
||
auto_promo_levels: List[MiniAppAutoPromoGroupLevel] = []
|
||
for group in auto_assign_groups:
|
||
threshold = group.auto_assign_total_spent_kopeks or 0
|
||
if threshold <= 0:
|
||
continue
|
||
|
||
auto_promo_levels.append(
|
||
MiniAppAutoPromoGroupLevel(
|
||
id=group.id,
|
||
name=group.name,
|
||
threshold_kopeks=threshold,
|
||
threshold_rubles=round(threshold / 100, 2),
|
||
threshold_label=settings.format_price(threshold),
|
||
is_reached=total_spent_kopeks >= threshold,
|
||
is_current=bool(promo_group and promo_group.id == group.id),
|
||
**_extract_promo_discounts(group),
|
||
)
|
||
)
|
||
|
||
active_discount_percent = 0
|
||
try:
|
||
active_discount_percent = int(getattr(user, "promo_offer_discount_percent", 0) or 0)
|
||
except (TypeError, ValueError):
|
||
active_discount_percent = 0
|
||
|
||
active_discount_expires_at = getattr(user, "promo_offer_discount_expires_at", None)
|
||
now = datetime.utcnow()
|
||
if active_discount_expires_at and active_discount_expires_at <= now:
|
||
active_discount_expires_at = None
|
||
active_discount_percent = 0
|
||
|
||
available_promo_offers = await list_active_discount_offers_for_user(db, user.id)
|
||
|
||
promo_offer_source = getattr(user, "promo_offer_discount_source", None)
|
||
active_offer_contexts: List[ActiveOfferContext] = []
|
||
if promo_offer_source or active_discount_percent > 0:
|
||
active_discount_offer = await get_latest_claimed_offer_for_user(
|
||
db,
|
||
user.id,
|
||
promo_offer_source,
|
||
)
|
||
if active_discount_offer and active_discount_percent > 0:
|
||
active_offer_contexts.append(
|
||
(
|
||
active_discount_offer,
|
||
active_discount_percent,
|
||
active_discount_expires_at,
|
||
)
|
||
)
|
||
|
||
active_offer_contexts.extend(await _find_active_test_access_offers(db, subscription))
|
||
|
||
promo_offers = await _build_promo_offer_models(
|
||
db,
|
||
available_promo_offers,
|
||
active_offer_contexts,
|
||
user=user,
|
||
)
|
||
|
||
content_language_preference = user.language or settings.DEFAULT_LANGUAGE or "ru"
|
||
|
||
def _normalize_language_code(language: Optional[str]) -> str:
|
||
base_language = language or settings.DEFAULT_LANGUAGE or "ru"
|
||
return base_language.split("-")[0].lower()
|
||
|
||
faq_payload: Optional[MiniAppFaq] = None
|
||
requested_faq_language = FaqService.normalize_language(content_language_preference)
|
||
faq_pages = await FaqService.get_pages(
|
||
db,
|
||
requested_faq_language,
|
||
include_inactive=False,
|
||
fallback=True,
|
||
)
|
||
|
||
if faq_pages:
|
||
faq_setting = await FaqService.get_setting(
|
||
db,
|
||
requested_faq_language,
|
||
fallback=True,
|
||
)
|
||
is_enabled = bool(faq_setting.is_enabled) if faq_setting else True
|
||
|
||
if is_enabled:
|
||
ordered_pages = sorted(
|
||
faq_pages,
|
||
key=lambda page: (
|
||
(page.display_order or 0),
|
||
page.id,
|
||
),
|
||
)
|
||
faq_items: List[MiniAppFaqItem] = []
|
||
for page in ordered_pages:
|
||
raw_content = (page.content or "").strip()
|
||
if not raw_content:
|
||
continue
|
||
if not re.sub(r"<[^>]+>", "", raw_content).strip():
|
||
continue
|
||
faq_items.append(
|
||
MiniAppFaqItem(
|
||
id=page.id,
|
||
title=page.title or None,
|
||
content=page.content or "",
|
||
display_order=getattr(page, "display_order", None),
|
||
)
|
||
)
|
||
|
||
if faq_items:
|
||
resolved_language = (
|
||
faq_setting.language
|
||
if faq_setting and faq_setting.language
|
||
else ordered_pages[0].language
|
||
)
|
||
faq_payload = MiniAppFaq(
|
||
requested_language=requested_faq_language,
|
||
language=resolved_language or requested_faq_language,
|
||
is_enabled=is_enabled,
|
||
total=len(faq_items),
|
||
items=faq_items,
|
||
)
|
||
|
||
legal_documents_payload: Optional[MiniAppLegalDocuments] = None
|
||
|
||
requested_offer_language = PublicOfferService.normalize_language(content_language_preference)
|
||
public_offer = await PublicOfferService.get_active_offer(
|
||
db,
|
||
requested_offer_language,
|
||
)
|
||
if public_offer and (public_offer.content or "").strip():
|
||
legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments()
|
||
legal_documents_payload.public_offer = MiniAppRichTextDocument(
|
||
requested_language=requested_offer_language,
|
||
language=public_offer.language,
|
||
title=None,
|
||
is_enabled=bool(public_offer.is_enabled),
|
||
content=public_offer.content or "",
|
||
created_at=public_offer.created_at,
|
||
updated_at=public_offer.updated_at,
|
||
)
|
||
|
||
requested_policy_language = PrivacyPolicyService.normalize_language(
|
||
content_language_preference
|
||
)
|
||
privacy_policy = await PrivacyPolicyService.get_active_policy(
|
||
db,
|
||
requested_policy_language,
|
||
)
|
||
if privacy_policy and (privacy_policy.content or "").strip():
|
||
legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments()
|
||
legal_documents_payload.privacy_policy = MiniAppRichTextDocument(
|
||
requested_language=requested_policy_language,
|
||
language=privacy_policy.language,
|
||
title=None,
|
||
is_enabled=bool(privacy_policy.is_enabled),
|
||
content=privacy_policy.content or "",
|
||
created_at=privacy_policy.created_at,
|
||
updated_at=privacy_policy.updated_at,
|
||
)
|
||
|
||
requested_rules_language = _normalize_language_code(content_language_preference)
|
||
default_rules_language = _normalize_language_code(settings.DEFAULT_LANGUAGE)
|
||
service_rules = await get_rules_by_language(db, requested_rules_language)
|
||
if not service_rules and requested_rules_language != default_rules_language:
|
||
service_rules = await get_rules_by_language(db, default_rules_language)
|
||
|
||
if service_rules and (service_rules.content or "").strip():
|
||
legal_documents_payload = legal_documents_payload or MiniAppLegalDocuments()
|
||
legal_documents_payload.service_rules = MiniAppRichTextDocument(
|
||
requested_language=requested_rules_language,
|
||
language=service_rules.language,
|
||
title=getattr(service_rules, "title", None),
|
||
is_enabled=bool(getattr(service_rules, "is_active", True)),
|
||
content=service_rules.content or "",
|
||
created_at=getattr(service_rules, "created_at", None),
|
||
updated_at=getattr(service_rules, "updated_at", None),
|
||
)
|
||
|
||
response_user = MiniAppSubscriptionUser(
|
||
telegram_id=user.telegram_id,
|
||
username=user.username,
|
||
first_name=user.first_name,
|
||
last_name=user.last_name,
|
||
display_name=_resolve_display_name(
|
||
{
|
||
"username": user.username,
|
||
"first_name": user.first_name,
|
||
"last_name": user.last_name,
|
||
"telegram_id": user.telegram_id,
|
||
}
|
||
),
|
||
language=user.language,
|
||
status=user.status,
|
||
subscription_status=subscription.status,
|
||
subscription_actual_status=status_actual,
|
||
status_label=_status_label(status_actual),
|
||
expires_at=subscription.end_date,
|
||
device_limit=subscription.device_limit,
|
||
traffic_used_gb=round(traffic_used, 2),
|
||
traffic_used_label=_format_gb_label(traffic_used),
|
||
traffic_limit_gb=traffic_limit,
|
||
traffic_limit_label=_format_limit_label(traffic_limit),
|
||
lifetime_used_traffic_gb=lifetime_used,
|
||
has_active_subscription=status_actual in {"active", "trial"},
|
||
promo_offer_discount_percent=active_discount_percent,
|
||
promo_offer_discount_expires_at=active_discount_expires_at,
|
||
promo_offer_discount_source=promo_offer_source,
|
||
)
|
||
|
||
referral_info = await _build_referral_info(db, user)
|
||
|
||
return MiniAppSubscriptionResponse(
|
||
subscription_id=subscription.id,
|
||
remnawave_short_uuid=subscription.remnawave_short_uuid,
|
||
user=response_user,
|
||
subscription_url=subscription_url,
|
||
subscription_crypto_link=subscription_crypto_link,
|
||
subscription_purchase_url=purchase_url or None,
|
||
links=links,
|
||
ss_conf_links=ss_conf_links,
|
||
connected_squads=connected_squads,
|
||
connected_servers=connected_servers,
|
||
connected_devices_count=devices_count,
|
||
connected_devices=devices,
|
||
happ=links_payload.get("happ"),
|
||
happ_link=links_payload.get("happ_link"),
|
||
happ_crypto_link=links_payload.get("happ_crypto_link"),
|
||
happ_cryptolink_redirect_link=happ_redirect_link,
|
||
balance_kopeks=user.balance_kopeks,
|
||
balance_rubles=round(user.balance_rubles, 2),
|
||
balance_currency=balance_currency,
|
||
transactions=[_serialize_transaction(tx) for tx in transactions],
|
||
promo_offers=promo_offers,
|
||
promo_group=(
|
||
MiniAppPromoGroup(
|
||
id=promo_group.id,
|
||
name=promo_group.name,
|
||
**_extract_promo_discounts(promo_group),
|
||
)
|
||
if promo_group
|
||
else None
|
||
),
|
||
auto_assign_promo_groups=auto_promo_levels,
|
||
total_spent_kopeks=total_spent_kopeks,
|
||
total_spent_rubles=round(total_spent_kopeks / 100, 2),
|
||
total_spent_label=settings.format_price(total_spent_kopeks),
|
||
subscription_type="trial" if subscription.is_trial else "paid",
|
||
autopay_enabled=bool(subscription.autopay_enabled),
|
||
branding=settings.get_miniapp_branding(),
|
||
faq=faq_payload,
|
||
legal_documents=legal_documents_payload,
|
||
referral=referral_info,
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/promo-codes/activate",
|
||
response_model=MiniAppPromoCodeActivationResponse,
|
||
)
|
||
async def activate_promo_code(
|
||
payload: MiniAppPromoCodeActivationRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppPromoCodeActivationResponse:
|
||
try:
|
||
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
|
||
except TelegramWebAppAuthError as error:
|
||
raise HTTPException(
|
||
status.HTTP_401_UNAUTHORIZED,
|
||
detail={"code": "unauthorized", "message": str(error)},
|
||
) from error
|
||
|
||
telegram_user = webapp_data.get("user")
|
||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user payload"},
|
||
)
|
||
|
||
try:
|
||
telegram_id = int(telegram_user["id"])
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"},
|
||
) from None
|
||
|
||
user = await get_user_by_telegram_id(db, telegram_id)
|
||
if not user:
|
||
raise HTTPException(
|
||
status.HTTP_404_NOT_FOUND,
|
||
detail={"code": "user_not_found", "message": "User not found"},
|
||
)
|
||
|
||
code = (payload.code or "").strip().upper()
|
||
if not code:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid", "message": "Promo code must not be empty"},
|
||
)
|
||
|
||
result = await promo_code_service.activate_promocode(db, user.id, code)
|
||
if result.get("success"):
|
||
promocode_data = result.get("promocode") or {}
|
||
|
||
try:
|
||
balance_bonus = int(promocode_data.get("balance_bonus_kopeks") or 0)
|
||
except (TypeError, ValueError):
|
||
balance_bonus = 0
|
||
|
||
try:
|
||
subscription_days = int(promocode_data.get("subscription_days") or 0)
|
||
except (TypeError, ValueError):
|
||
subscription_days = 0
|
||
|
||
promo_payload = MiniAppPromoCode(
|
||
code=str(promocode_data.get("code") or code),
|
||
type=promocode_data.get("type"),
|
||
balance_bonus_kopeks=balance_bonus,
|
||
subscription_days=subscription_days,
|
||
max_uses=promocode_data.get("max_uses"),
|
||
current_uses=promocode_data.get("current_uses"),
|
||
valid_until=promocode_data.get("valid_until"),
|
||
)
|
||
|
||
return MiniAppPromoCodeActivationResponse(
|
||
success=True,
|
||
description=result.get("description"),
|
||
promocode=promo_payload,
|
||
)
|
||
|
||
error_code = str(result.get("error") or "generic")
|
||
status_map = {
|
||
"user_not_found": status.HTTP_404_NOT_FOUND,
|
||
"not_found": status.HTTP_404_NOT_FOUND,
|
||
"expired": status.HTTP_410_GONE,
|
||
"used": status.HTTP_409_CONFLICT,
|
||
"already_used_by_user": status.HTTP_409_CONFLICT,
|
||
"server_error": status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
}
|
||
message_map = {
|
||
"invalid": "Promo code must not be empty",
|
||
"not_found": "Promo code not found",
|
||
"expired": "Promo code expired",
|
||
"used": "Promo code already used",
|
||
"already_used_by_user": "Promo code already used by this user",
|
||
"user_not_found": "User not found",
|
||
"server_error": "Failed to activate promo code",
|
||
}
|
||
|
||
http_status = status_map.get(error_code, status.HTTP_400_BAD_REQUEST)
|
||
message = message_map.get(error_code, "Unable to activate promo code")
|
||
|
||
raise HTTPException(
|
||
http_status,
|
||
detail={"code": error_code, "message": message},
|
||
)
|
||
|
||
|
||
@router.post(
|
||
"/promo-offers/{offer_id}/claim",
|
||
response_model=MiniAppPromoOfferClaimResponse,
|
||
)
|
||
async def claim_promo_offer(
|
||
offer_id: int,
|
||
payload: MiniAppPromoOfferClaimRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppPromoOfferClaimResponse:
|
||
try:
|
||
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
|
||
except TelegramWebAppAuthError as error:
|
||
raise HTTPException(
|
||
status.HTTP_401_UNAUTHORIZED,
|
||
detail={"code": "unauthorized", "message": str(error)},
|
||
) from error
|
||
|
||
telegram_user = webapp_data.get("user")
|
||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user payload"},
|
||
)
|
||
|
||
try:
|
||
telegram_id = int(telegram_user["id"])
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"},
|
||
) from None
|
||
|
||
user = await get_user_by_telegram_id(db, telegram_id)
|
||
if not user:
|
||
raise HTTPException(
|
||
status.HTTP_404_NOT_FOUND,
|
||
detail={"code": "user_not_found", "message": "User not found"},
|
||
)
|
||
|
||
offer = await get_offer_by_id(db, offer_id)
|
||
if not offer or offer.user_id != user.id:
|
||
raise HTTPException(
|
||
status.HTTP_404_NOT_FOUND,
|
||
detail={"code": "offer_not_found", "message": "Offer not found"},
|
||
)
|
||
|
||
now = datetime.utcnow()
|
||
if offer.claimed_at is not None:
|
||
raise HTTPException(
|
||
status.HTTP_409_CONFLICT,
|
||
detail={"code": "already_claimed", "message": "Offer already claimed"},
|
||
)
|
||
|
||
if not offer.is_active or offer.expires_at <= now:
|
||
offer.is_active = False
|
||
await db.commit()
|
||
raise HTTPException(
|
||
status.HTTP_410_GONE,
|
||
detail={"code": "offer_expired", "message": "Offer expired"},
|
||
)
|
||
|
||
effect_type = _normalize_effect_type(getattr(offer, "effect_type", None))
|
||
|
||
if effect_type == "test_access":
|
||
success, newly_added, expires_at, error_code = await promo_offer_service.grant_test_access(
|
||
db,
|
||
user,
|
||
offer,
|
||
)
|
||
|
||
if not success:
|
||
code = error_code or "claim_failed"
|
||
message_map = {
|
||
"subscription_missing": "Active subscription required",
|
||
"squads_missing": "No squads configured for test access",
|
||
"already_connected": "Servers already connected",
|
||
"remnawave_sync_failed": "Failed to apply servers",
|
||
}
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": code, "message": message_map.get(code, "Unable to activate offer")},
|
||
)
|
||
|
||
await mark_offer_claimed(
|
||
db,
|
||
offer,
|
||
details={
|
||
"context": "test_access_claim",
|
||
"new_squads": newly_added,
|
||
"expires_at": expires_at.isoformat() if expires_at else None,
|
||
},
|
||
)
|
||
|
||
return MiniAppPromoOfferClaimResponse(success=True, code="test_access_claimed")
|
||
|
||
discount_percent = int(getattr(offer, "discount_percent", 0) or 0)
|
||
if discount_percent <= 0:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_discount", "message": "Offer does not contain discount"},
|
||
)
|
||
|
||
user.promo_offer_discount_percent = discount_percent
|
||
user.promo_offer_discount_source = offer.notification_type
|
||
user.updated_at = now
|
||
|
||
extra_data = _extract_offer_extra(offer)
|
||
raw_duration = extra_data.get("active_discount_hours")
|
||
template_id = extra_data.get("template_id")
|
||
|
||
if raw_duration in (None, "") and template_id:
|
||
try:
|
||
template = await get_promo_offer_template_by_id(db, int(template_id))
|
||
except (TypeError, ValueError):
|
||
template = None
|
||
if template and template.active_discount_hours:
|
||
raw_duration = template.active_discount_hours
|
||
else:
|
||
template = None
|
||
|
||
try:
|
||
duration_hours = int(raw_duration) if raw_duration is not None else None
|
||
except (TypeError, ValueError):
|
||
duration_hours = None
|
||
|
||
if duration_hours and duration_hours > 0:
|
||
discount_expires_at = now + timedelta(hours=duration_hours)
|
||
else:
|
||
discount_expires_at = None
|
||
|
||
user.promo_offer_discount_expires_at = discount_expires_at
|
||
|
||
await mark_offer_claimed(
|
||
db,
|
||
offer,
|
||
details={
|
||
"context": "discount_claim",
|
||
"discount_percent": discount_percent,
|
||
"discount_expires_at": discount_expires_at.isoformat() if discount_expires_at else None,
|
||
},
|
||
)
|
||
await db.refresh(user)
|
||
|
||
return MiniAppPromoOfferClaimResponse(success=True, code="discount_claimed")
|
||
|
||
|
||
@router.post(
|
||
"/devices/remove",
|
||
response_model=MiniAppDeviceRemovalResponse,
|
||
)
|
||
async def remove_connected_device(
|
||
payload: MiniAppDeviceRemovalRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppDeviceRemovalResponse:
|
||
try:
|
||
webapp_data = parse_webapp_init_data(payload.init_data, settings.BOT_TOKEN)
|
||
except TelegramWebAppAuthError as error:
|
||
raise HTTPException(
|
||
status.HTTP_401_UNAUTHORIZED,
|
||
detail={"code": "unauthorized", "message": str(error)},
|
||
) from error
|
||
|
||
telegram_user = webapp_data.get("user")
|
||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user payload"},
|
||
)
|
||
|
||
try:
|
||
telegram_id = int(telegram_user["id"])
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"},
|
||
) from None
|
||
|
||
user = await get_user_by_telegram_id(db, telegram_id)
|
||
if not user:
|
||
raise HTTPException(
|
||
status.HTTP_404_NOT_FOUND,
|
||
detail={"code": "user_not_found", "message": "User not found"},
|
||
)
|
||
|
||
remnawave_uuid = getattr(user, "remnawave_uuid", None)
|
||
if not remnawave_uuid:
|
||
raise HTTPException(
|
||
status.HTTP_409_CONFLICT,
|
||
detail={"code": "remnawave_unavailable", "message": "RemnaWave user is not linked"},
|
||
)
|
||
|
||
hwid = (payload.hwid or "").strip()
|
||
if not hwid:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_hwid", "message": "Device identifier is required"},
|
||
)
|
||
|
||
service = RemnaWaveService()
|
||
if not service.is_configured:
|
||
raise HTTPException(
|
||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail={"code": "service_unavailable", "message": "Device management is temporarily unavailable"},
|
||
)
|
||
|
||
try:
|
||
async with service.get_api_client() as api:
|
||
success = await api.remove_device(remnawave_uuid, hwid)
|
||
except RemnaWaveConfigurationError as error:
|
||
raise HTTPException(
|
||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||
detail={"code": "service_unavailable", "message": str(error)},
|
||
) from error
|
||
except Exception as error: # pragma: no cover - defensive
|
||
logger.warning(
|
||
"Failed to remove device %s for user %s: %s",
|
||
hwid,
|
||
telegram_id,
|
||
error,
|
||
)
|
||
raise HTTPException(
|
||
status.HTTP_502_BAD_GATEWAY,
|
||
detail={"code": "remnawave_error", "message": "Failed to remove device"},
|
||
) from error
|
||
|
||
if not success:
|
||
raise HTTPException(
|
||
status.HTTP_502_BAD_GATEWAY,
|
||
detail={"code": "remnawave_error", "message": "Failed to remove device"},
|
||
)
|
||
|
||
return MiniAppDeviceRemovalResponse(success=True)
|
||
|
||
|
||
def _safe_int(value: Any) -> int:
|
||
try:
|
||
return int(value)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
def _normalize_period_discounts(
|
||
raw: Optional[Dict[Any, Any]]
|
||
) -> Dict[int, int]:
|
||
if not isinstance(raw, dict):
|
||
return {}
|
||
|
||
normalized: Dict[int, int] = {}
|
||
for key, value in raw.items():
|
||
try:
|
||
period = int(key)
|
||
normalized[period] = int(value)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
return normalized
|
||
|
||
|
||
def _extract_promo_discounts(group: Optional[PromoGroup]) -> Dict[str, Any]:
|
||
if not group:
|
||
return {
|
||
"server_discount_percent": 0,
|
||
"traffic_discount_percent": 0,
|
||
"device_discount_percent": 0,
|
||
"period_discounts": {},
|
||
"apply_discounts_to_addons": True,
|
||
}
|
||
|
||
return {
|
||
"server_discount_percent": max(0, _safe_int(getattr(group, "server_discount_percent", 0))),
|
||
"traffic_discount_percent": max(0, _safe_int(getattr(group, "traffic_discount_percent", 0))),
|
||
"device_discount_percent": max(0, _safe_int(getattr(group, "device_discount_percent", 0))),
|
||
"period_discounts": _normalize_period_discounts(getattr(group, "period_discounts", None)),
|
||
"apply_discounts_to_addons": bool(
|
||
getattr(group, "apply_discounts_to_addons", True)
|
||
),
|
||
}
|
||
|
||
|
||
def _get_addon_discount_percent_for_user(
|
||
user: Optional[User],
|
||
category: str,
|
||
period_days_hint: Optional[int] = None,
|
||
) -> int:
|
||
if user is None:
|
||
return 0
|
||
|
||
promo_group = getattr(user, "promo_group", None)
|
||
if promo_group is None:
|
||
return 0
|
||
|
||
if not getattr(promo_group, "apply_discounts_to_addons", True):
|
||
return 0
|
||
|
||
try:
|
||
percent = user.get_promo_discount(category, period_days_hint)
|
||
except AttributeError:
|
||
return 0
|
||
|
||
try:
|
||
return int(percent)
|
||
except (TypeError, ValueError):
|
||
return 0
|
||
|
||
|
||
@dataclass
|
||
class PurchaseSelection:
|
||
period_days: int
|
||
traffic_gb: int
|
||
servers: List[str]
|
||
devices: int
|
||
|
||
|
||
def _format_price_label(amount_kopeks: Optional[int]) -> Optional[str]:
|
||
if amount_kopeks is None:
|
||
return None
|
||
try:
|
||
return settings.format_price(int(amount_kopeks))
|
||
except Exception: # pragma: no cover - defensive fallback
|
||
return None
|
||
|
||
|
||
def _normalize_server_list(values: Optional[Iterable[Any]]) -> List[str]:
|
||
normalized: List[str] = []
|
||
seen: set[str] = set()
|
||
if not values:
|
||
return normalized
|
||
|
||
for value in values:
|
||
if value is None:
|
||
continue
|
||
text = str(value).strip()
|
||
if not text or text in seen:
|
||
continue
|
||
seen.add(text)
|
||
normalized.append(text)
|
||
|
||
return normalized
|
||
|
||
|
||
def _get_available_purchase_periods() -> List[int]:
|
||
raw_periods = settings.get_available_subscription_periods()
|
||
normalized: List[int] = []
|
||
|
||
for raw in raw_periods:
|
||
try:
|
||
days = int(raw)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if days <= 0:
|
||
continue
|
||
if PERIOD_PRICES.get(days, 0) <= 0 and days not in PERIOD_PRICES:
|
||
continue
|
||
normalized.append(days)
|
||
|
||
if not normalized:
|
||
normalized = [days for days in sorted(PERIOD_PRICES.keys()) if PERIOD_PRICES.get(days, 0) > 0]
|
||
|
||
if not normalized:
|
||
normalized = [30]
|
||
|
||
normalized = sorted(set(normalized))
|
||
return normalized
|
||
|
||
|
||
def _get_available_traffic_packages() -> List[int]:
|
||
packages: List[int] = []
|
||
for package in settings.get_traffic_packages():
|
||
try:
|
||
gb_value = int(package.get("gb"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
if gb_value < 0:
|
||
continue
|
||
|
||
is_enabled = bool(package.get("enabled", True))
|
||
if package.get("is_active") is False:
|
||
is_enabled = False
|
||
|
||
if is_enabled:
|
||
packages.append(gb_value)
|
||
|
||
packages = sorted(set(packages))
|
||
return packages
|
||
|
||
|
||
def _resolve_purchase_period_days(
|
||
payload: Any,
|
||
available_periods: List[int],
|
||
) -> int:
|
||
if not available_periods:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "period_unavailable", "message": "No subscription periods are available"},
|
||
)
|
||
|
||
candidates = [
|
||
getattr(payload, "period_id", None),
|
||
getattr(payload, "period_key", None),
|
||
getattr(payload, "period_code", None),
|
||
getattr(payload, "period", None),
|
||
]
|
||
|
||
for candidate in candidates:
|
||
if candidate is None:
|
||
continue
|
||
text = str(candidate).strip()
|
||
if not text:
|
||
continue
|
||
if text.startswith("days:"):
|
||
text = text.split(":", 1)[1]
|
||
try:
|
||
value = int(float(text))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if value in available_periods:
|
||
return value
|
||
|
||
month_candidates = [
|
||
getattr(payload, "period_months", None),
|
||
getattr(payload, "months", None),
|
||
]
|
||
for candidate in month_candidates:
|
||
if candidate is None:
|
||
continue
|
||
try:
|
||
months = int(float(candidate))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
days = months * 30
|
||
if days in available_periods:
|
||
return days
|
||
|
||
day_candidates = [getattr(payload, "period_days", None)]
|
||
for candidate in day_candidates:
|
||
if candidate is None:
|
||
continue
|
||
try:
|
||
days = int(float(candidate))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if days in available_periods:
|
||
return days
|
||
|
||
return available_periods[0]
|
||
|
||
|
||
def _resolve_purchase_traffic_value(
|
||
payload: Any,
|
||
subscription: Optional[Subscription],
|
||
selectable: bool,
|
||
available_packages: List[int],
|
||
) -> int:
|
||
if not selectable:
|
||
return settings.get_fixed_traffic_limit()
|
||
|
||
candidates = [
|
||
getattr(payload, "traffic", None),
|
||
getattr(payload, "traffic_value", None),
|
||
getattr(payload, "traffic_gb", None),
|
||
getattr(payload, "limit", None),
|
||
]
|
||
for candidate in candidates:
|
||
if candidate is None:
|
||
continue
|
||
try:
|
||
value = int(float(candidate))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if value < 0:
|
||
continue
|
||
if available_packages and value not in available_packages:
|
||
continue
|
||
return value
|
||
|
||
if subscription and subscription.traffic_limit_gb is not None:
|
||
value = int(subscription.traffic_limit_gb)
|
||
if value >= 0 and (not available_packages or value in available_packages):
|
||
return value
|
||
|
||
if available_packages:
|
||
return available_packages[0]
|
||
|
||
return 0
|
||
|
||
|
||
def _resolve_purchase_devices_value(
|
||
payload: Any,
|
||
subscription: Optional[Subscription],
|
||
) -> int:
|
||
candidates = [
|
||
getattr(payload, "devices", None),
|
||
getattr(payload, "device_limit", None),
|
||
]
|
||
for candidate in candidates:
|
||
if candidate is None:
|
||
continue
|
||
try:
|
||
value = int(float(candidate))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if value > 0:
|
||
return value
|
||
|
||
if subscription and subscription.device_limit:
|
||
try:
|
||
value = int(subscription.device_limit)
|
||
except (TypeError, ValueError):
|
||
value = None
|
||
if value and value > 0:
|
||
return value
|
||
|
||
default_limit = max(1, int(getattr(settings, "DEFAULT_DEVICE_LIMIT", 1)))
|
||
return default_limit
|
||
|
||
|
||
def _resolve_purchase_servers_selection(
|
||
payload: Any,
|
||
subscription: Optional[Subscription],
|
||
available_servers: List[str],
|
||
) -> List[str]:
|
||
raw_servers: List[str] = []
|
||
for attr_name in ("servers", "countries", "server_uuids", "squad_uuids"):
|
||
attr_value = getattr(payload, attr_name, None)
|
||
if isinstance(attr_value, (list, tuple, set)):
|
||
raw_servers.extend(_normalize_server_list(attr_value))
|
||
|
||
selection = _normalize_server_list(raw_servers)
|
||
|
||
if not selection and subscription and getattr(subscription, "connected_squads", None):
|
||
selection = _normalize_server_list(subscription.connected_squads)
|
||
|
||
if not selection and available_servers:
|
||
selection = [available_servers[0]]
|
||
|
||
return selection
|
||
|
||
|
||
def _build_default_purchase_selection(
|
||
payload: Any,
|
||
user: User,
|
||
available_periods: List[int],
|
||
available_packages: List[int],
|
||
available_servers: List[str],
|
||
) -> PurchaseSelection:
|
||
subscription = getattr(user, "subscription", None)
|
||
period_days = _resolve_purchase_period_days(payload, available_periods)
|
||
traffic_value = _resolve_purchase_traffic_value(
|
||
payload,
|
||
subscription,
|
||
settings.is_traffic_selectable(),
|
||
available_packages,
|
||
)
|
||
servers = _resolve_purchase_servers_selection(payload, subscription, available_servers)
|
||
devices = _resolve_purchase_devices_value(payload, subscription)
|
||
|
||
return PurchaseSelection(
|
||
period_days=period_days,
|
||
traffic_gb=traffic_value,
|
||
servers=servers,
|
||
devices=devices,
|
||
)
|
||
|
||
|
||
def _serialize_purchase_selection(selection: PurchaseSelection) -> Dict[str, Any]:
|
||
return {
|
||
"period_id": str(selection.period_days),
|
||
"periodId": str(selection.period_days),
|
||
"period": str(selection.period_days),
|
||
"period_days": selection.period_days,
|
||
"periodDays": selection.period_days,
|
||
"traffic": selection.traffic_gb,
|
||
"traffic_value": selection.traffic_gb,
|
||
"trafficValue": selection.traffic_gb,
|
||
"traffic_gb": selection.traffic_gb,
|
||
"trafficGb": selection.traffic_gb,
|
||
"servers": selection.servers,
|
||
"countries": selection.servers,
|
||
"server_uuids": selection.servers,
|
||
"serverUuids": selection.servers,
|
||
"devices": selection.devices,
|
||
"device_limit": selection.devices,
|
||
"deviceLimit": selection.devices,
|
||
}
|
||
|
||
|
||
async def _build_subscription_purchase_options_payload(
|
||
db: AsyncSession,
|
||
user: User,
|
||
payload: Optional[Any] = None,
|
||
) -> MiniAppSubscriptionPurchaseOptions:
|
||
payload = payload or SimpleNamespace()
|
||
available_periods = _get_available_purchase_periods()
|
||
available_packages = _get_available_traffic_packages()
|
||
available_servers_models = await get_available_server_squads(
|
||
db,
|
||
promo_group_id=getattr(user, "promo_group_id", None),
|
||
)
|
||
available_server_uuids = [
|
||
server.squad_uuid
|
||
for server in available_servers_models
|
||
if getattr(server, "squad_uuid", None)
|
||
]
|
||
|
||
selection = _build_default_purchase_selection(
|
||
payload,
|
||
user,
|
||
available_periods,
|
||
available_packages,
|
||
available_server_uuids,
|
||
)
|
||
|
||
subscription = getattr(user, "subscription", None)
|
||
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
|
||
balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0)
|
||
|
||
traffic_config: Dict[str, Any] = {
|
||
"mode": "selectable" if settings.is_traffic_selectable() else "fixed",
|
||
"selectable": settings.is_traffic_selectable(),
|
||
"options": [
|
||
{
|
||
"value": gb,
|
||
"label": f"{gb} GB",
|
||
}
|
||
for gb in available_packages
|
||
],
|
||
"current": selection.traffic_gb,
|
||
"default": selection.traffic_gb,
|
||
}
|
||
if not settings.is_traffic_selectable():
|
||
traffic_config.update(
|
||
{
|
||
"value": settings.get_fixed_traffic_limit(),
|
||
"label": f"{settings.get_fixed_traffic_limit()} GB",
|
||
}
|
||
)
|
||
|
||
servers_config: Dict[str, Any] = {
|
||
"options": [
|
||
{
|
||
"uuid": server.squad_uuid,
|
||
"name": getattr(server, "display_name", server.squad_uuid),
|
||
}
|
||
for server in available_servers_models
|
||
],
|
||
"min": 1 if available_servers_models else 0,
|
||
"max": len(available_servers_models),
|
||
"selectable": len(available_servers_models) > 1,
|
||
"selected": selection.servers,
|
||
"default": selection.servers,
|
||
}
|
||
|
||
max_devices_setting = getattr(settings, "MAX_DEVICES_LIMIT", 0)
|
||
devices_config: Dict[str, Any] = {
|
||
"min": 1,
|
||
"max": max_devices_setting if max_devices_setting > 0 else 0,
|
||
"step": 1,
|
||
"default": selection.devices,
|
||
"current": selection.devices,
|
||
"price_per_device_kopeks": settings.PRICE_PER_DEVICE,
|
||
"price_per_device_label": _format_price_label(settings.PRICE_PER_DEVICE),
|
||
}
|
||
|
||
language = getattr(user, "language", "ru") or "ru"
|
||
periods_payload: List[Dict[str, Any]] = []
|
||
|
||
for days in available_periods:
|
||
base_price_original = int(PERIOD_PRICES.get(days, 0) or 0)
|
||
if base_price_original <= 0:
|
||
continue
|
||
|
||
months = calculate_months_from_days(days)
|
||
try:
|
||
period_discount_percent = int(user.get_promo_discount("period", days))
|
||
except Exception:
|
||
period_discount_percent = 0
|
||
discounted_base, base_discount_value = apply_percentage_discount(
|
||
base_price_original,
|
||
period_discount_percent,
|
||
)
|
||
|
||
per_month_price = discounted_base // max(1, months)
|
||
|
||
period_payload: Dict[str, Any] = {
|
||
"id": str(days),
|
||
"days": days,
|
||
"months": months,
|
||
"priceKopeks": discounted_base,
|
||
"priceLabel": _format_price_label(discounted_base),
|
||
"perMonthPriceKopeks": per_month_price,
|
||
"perMonthPriceLabel": _format_price_label(per_month_price),
|
||
"description": format_period_description(days, language),
|
||
}
|
||
|
||
if base_discount_value > 0:
|
||
period_payload.update(
|
||
{
|
||
"originalPriceKopeks": base_price_original,
|
||
"originalPriceLabel": _format_price_label(base_price_original),
|
||
"discountPercent": period_discount_percent,
|
||
}
|
||
)
|
||
|
||
if settings.is_traffic_selectable() and available_packages:
|
||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"traffic",
|
||
days,
|
||
)
|
||
traffic_options_override: List[Dict[str, Any]] = []
|
||
for gb in available_packages:
|
||
monthly_price = settings.get_traffic_price(gb)
|
||
discounted_monthly, _ = apply_percentage_discount(
|
||
monthly_price,
|
||
traffic_discount_percent,
|
||
)
|
||
option_payload: Dict[str, Any] = {
|
||
"value": gb,
|
||
"priceKopeks": discounted_monthly,
|
||
"priceLabel": _format_price_label(discounted_monthly),
|
||
}
|
||
if discounted_monthly != monthly_price:
|
||
option_payload.update(
|
||
{
|
||
"originalPriceKopeks": monthly_price,
|
||
"originalPriceLabel": _format_price_label(monthly_price),
|
||
}
|
||
)
|
||
if traffic_discount_percent:
|
||
option_payload["discountPercent"] = traffic_discount_percent
|
||
traffic_options_override.append(option_payload)
|
||
|
||
if traffic_options_override:
|
||
period_payload["traffic"] = {
|
||
"options": traffic_options_override,
|
||
"selectable": True,
|
||
}
|
||
if traffic_discount_percent:
|
||
period_payload["traffic"]["discountPercent"] = traffic_discount_percent
|
||
|
||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"servers",
|
||
days,
|
||
)
|
||
catalog_subscription = subscription or SimpleNamespace(
|
||
connected_squads=selection.servers,
|
||
)
|
||
_, server_option_models, server_catalog = await _prepare_server_catalog(
|
||
db,
|
||
user,
|
||
catalog_subscription,
|
||
servers_discount_percent,
|
||
)
|
||
server_override_options: List[Dict[str, Any]] = []
|
||
for option in server_option_models:
|
||
entry = server_catalog.get(option.uuid, {})
|
||
option_payload = {
|
||
"uuid": option.uuid,
|
||
"name": option.name,
|
||
"priceKopeks": option.price_kopeks,
|
||
"priceLabel": _format_price_label(option.price_kopeks),
|
||
"isAvailable": option.is_available,
|
||
}
|
||
original_per_month = int(entry.get("price_per_month", 0) or 0)
|
||
if original_per_month and original_per_month != option.price_kopeks:
|
||
option_payload.update(
|
||
{
|
||
"originalPriceKopeks": original_per_month,
|
||
"originalPriceLabel": _format_price_label(original_per_month),
|
||
}
|
||
)
|
||
if option.discount_percent:
|
||
option_payload["discountPercent"] = option.discount_percent
|
||
server_override_options.append(option_payload)
|
||
|
||
period_payload["servers"] = {
|
||
"options": server_override_options,
|
||
"min": 1 if server_override_options else 0,
|
||
"max": len(server_override_options),
|
||
"selectable": len(server_override_options) > 1,
|
||
}
|
||
|
||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"devices",
|
||
days,
|
||
)
|
||
discounted_device_price, _ = apply_percentage_discount(
|
||
settings.PRICE_PER_DEVICE,
|
||
devices_discount_percent,
|
||
)
|
||
devices_override: Dict[str, Any] = {
|
||
"pricePerDeviceKopeks": discounted_device_price,
|
||
"pricePerDeviceLabel": _format_price_label(discounted_device_price),
|
||
"min": devices_config["min"],
|
||
"max": devices_config["max"],
|
||
"step": devices_config["step"],
|
||
}
|
||
if discounted_device_price != settings.PRICE_PER_DEVICE:
|
||
devices_override.update(
|
||
{
|
||
"originalPricePerDeviceKopeks": settings.PRICE_PER_DEVICE,
|
||
"originalPricePerDeviceLabel": _format_price_label(settings.PRICE_PER_DEVICE),
|
||
}
|
||
)
|
||
if devices_discount_percent:
|
||
devices_override["discountPercent"] = devices_discount_percent
|
||
period_payload["devices"] = devices_override
|
||
|
||
periods_payload.append(period_payload)
|
||
|
||
purchase_selection_dict = _serialize_purchase_selection(selection)
|
||
|
||
options_model = MiniAppSubscriptionPurchaseOptions(
|
||
currency=currency,
|
||
balanceKopeks=balance_kopeks,
|
||
balanceLabel=_format_price_label(balance_kopeks),
|
||
subscriptionId=getattr(subscription, "id", None),
|
||
periods=[MiniAppSubscriptionPurchasePeriod(**period) for period in periods_payload],
|
||
traffic=traffic_config,
|
||
servers=servers_config,
|
||
devices=devices_config,
|
||
selection=purchase_selection_dict,
|
||
promo={
|
||
"activePromoPercent": get_user_active_promo_discount_percent(user),
|
||
},
|
||
)
|
||
|
||
return options_model
|
||
|
||
|
||
async def _calculate_subscription_purchase_preview(
|
||
db: AsyncSession,
|
||
user: User,
|
||
selection: PurchaseSelection,
|
||
) -> Dict[str, Any]:
|
||
available_periods = _get_available_purchase_periods()
|
||
if selection.period_days not in available_periods:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "period_unavailable", "message": "Selected subscription period is not available"},
|
||
)
|
||
|
||
base_price_original = int(PERIOD_PRICES.get(selection.period_days, 0) or 0)
|
||
if base_price_original <= 0:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "period_unavailable", "message": "Selected subscription period is not available"},
|
||
)
|
||
|
||
months = calculate_months_from_days(selection.period_days)
|
||
months = max(1, months)
|
||
|
||
try:
|
||
period_discount_percent = int(user.get_promo_discount("period", selection.period_days))
|
||
except Exception:
|
||
period_discount_percent = 0
|
||
discounted_base, base_discount_value = apply_percentage_discount(
|
||
base_price_original,
|
||
period_discount_percent,
|
||
)
|
||
|
||
selectable_traffic = settings.is_traffic_selectable()
|
||
if selectable_traffic:
|
||
available_packages = _get_available_traffic_packages()
|
||
if available_packages and selection.traffic_gb not in available_packages:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "traffic_unavailable", "message": "Selected traffic package is not available"},
|
||
)
|
||
traffic_price_per_month = settings.get_traffic_price(selection.traffic_gb)
|
||
traffic_discount_percent = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"traffic",
|
||
selection.period_days,
|
||
)
|
||
discounted_traffic_per_month, traffic_discount_per_month = apply_percentage_discount(
|
||
traffic_price_per_month,
|
||
traffic_discount_percent,
|
||
)
|
||
total_traffic_price = discounted_traffic_per_month * months
|
||
total_traffic_original = traffic_price_per_month * months
|
||
|
||
servers_discount_percent = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"servers",
|
||
selection.period_days,
|
||
)
|
||
subscription = getattr(user, "subscription", None)
|
||
catalog_subscription = subscription or SimpleNamespace(
|
||
connected_squads=selection.servers,
|
||
)
|
||
_, server_option_models, server_catalog = await _prepare_server_catalog(
|
||
db,
|
||
user,
|
||
catalog_subscription,
|
||
servers_discount_percent,
|
||
)
|
||
|
||
if not selection.servers:
|
||
if server_option_models:
|
||
selection.servers = [server_option_models[0].uuid]
|
||
else:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "servers_unavailable", "message": "No servers are available for purchase"},
|
||
)
|
||
|
||
selected_entries = []
|
||
for uuid in selection.servers:
|
||
entry = server_catalog.get(uuid)
|
||
if not entry:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_servers", "message": "Selected server is not available"},
|
||
)
|
||
if not entry.get("available_for_new", False) and not entry.get("is_connected", False):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "server_unavailable", "message": "Selected server is not available"},
|
||
)
|
||
selected_entries.append(entry)
|
||
|
||
servers_original_per_month = sum(int(entry.get("price_per_month", 0) or 0) for entry in selected_entries)
|
||
servers_discounted_per_month = sum(int(entry.get("discounted_per_month", 0) or 0) for entry in selected_entries)
|
||
total_servers_original = servers_original_per_month * months
|
||
total_servers_price = servers_discounted_per_month * months
|
||
|
||
default_device_limit = max(1, getattr(settings, "DEFAULT_DEVICE_LIMIT", 1))
|
||
max_devices_setting = getattr(settings, "MAX_DEVICES_LIMIT", 0)
|
||
if max_devices_setting > 0 and selection.devices > max_devices_setting:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={
|
||
"code": "devices_limit_exceeded",
|
||
"message": f"Device limit exceeds maximum allowed ({max_devices_setting})",
|
||
},
|
||
)
|
||
|
||
additional_devices = max(0, selection.devices - default_device_limit)
|
||
devices_price_per_month = additional_devices * settings.PRICE_PER_DEVICE
|
||
devices_discount_percent = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"devices",
|
||
selection.period_days,
|
||
)
|
||
discounted_devices_per_month, devices_discount_per_month = apply_percentage_discount(
|
||
devices_price_per_month,
|
||
devices_discount_percent,
|
||
)
|
||
total_devices_price = discounted_devices_per_month * months
|
||
total_devices_original = devices_price_per_month * months
|
||
|
||
total_original = (
|
||
base_price_original
|
||
+ total_traffic_original
|
||
+ total_servers_original
|
||
+ total_devices_original
|
||
)
|
||
total_after_discounts = (
|
||
discounted_base
|
||
+ total_traffic_price
|
||
+ total_servers_price
|
||
+ total_devices_price
|
||
)
|
||
|
||
promo_offer_percent = get_user_active_promo_discount_percent(user)
|
||
final_price, promo_discount_value = apply_percentage_discount(
|
||
total_after_discounts,
|
||
promo_offer_percent,
|
||
)
|
||
|
||
breakdown: List[Dict[str, Any]] = []
|
||
period_label = format_period_description(selection.period_days, getattr(user, "language", "ru"))
|
||
breakdown.append(
|
||
{
|
||
"label": period_label,
|
||
"value": _format_price_label(discounted_base),
|
||
}
|
||
)
|
||
if total_servers_price > 0:
|
||
breakdown.append(
|
||
{
|
||
"label": f"Servers ({len(selection.servers)})",
|
||
"value": _format_price_label(total_servers_price),
|
||
}
|
||
)
|
||
if total_traffic_price > 0:
|
||
breakdown.append(
|
||
{
|
||
"label": f"Traffic {selection.traffic_gb} GB",
|
||
"value": _format_price_label(total_traffic_price),
|
||
}
|
||
)
|
||
if total_devices_price > 0:
|
||
breakdown.append(
|
||
{
|
||
"label": f"Devices ({selection.devices})",
|
||
"value": _format_price_label(total_devices_price),
|
||
}
|
||
)
|
||
|
||
discount_lines: List[str] = []
|
||
if base_discount_value > 0 and period_discount_percent > 0:
|
||
discount_lines.append(f"Period discount −{period_discount_percent}%")
|
||
if traffic_discount_per_month > 0 and traffic_discount_percent > 0:
|
||
discount_lines.append(f"Traffic discount −{traffic_discount_percent}%")
|
||
if devices_discount_per_month > 0 and devices_discount_percent > 0:
|
||
discount_lines.append(f"Devices discount −{devices_discount_percent}%")
|
||
if servers_original_per_month > servers_discounted_per_month and servers_discount_percent > 0:
|
||
discount_lines.append(f"Servers discount −{servers_discount_percent}%")
|
||
if promo_discount_value > 0 and promo_offer_percent > 0:
|
||
discount_lines.append(f"Promo offer −{promo_offer_percent}%")
|
||
|
||
balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0)
|
||
missing_amount = max(0, final_price - balance_kopeks)
|
||
|
||
per_month_price = final_price // months
|
||
|
||
preview_payload = {
|
||
"totalPriceKopeks": final_price,
|
||
"totalPriceLabel": _format_price_label(final_price),
|
||
"originalPriceKopeks": total_original if total_original != final_price else None,
|
||
"originalPriceLabel": _format_price_label(total_original) if total_original != final_price else None,
|
||
"perMonthPriceKopeks": per_month_price,
|
||
"perMonthPriceLabel": _format_price_label(per_month_price),
|
||
"discountPercent": promo_offer_percent if promo_offer_percent else None,
|
||
"discountLines": discount_lines,
|
||
"balanceKopeks": balance_kopeks,
|
||
"balanceLabel": _format_price_label(balance_kopeks),
|
||
"missingAmountKopeks": missing_amount if missing_amount > 0 else None,
|
||
"missingAmountLabel": _format_price_label(missing_amount) if missing_amount > 0 else None,
|
||
"breakdown": breakdown,
|
||
"statusMessage": "Insufficient balance" if missing_amount > 0 else None,
|
||
"canPurchase": missing_amount <= 0,
|
||
}
|
||
|
||
calculation_details = {
|
||
"preview": preview_payload,
|
||
"selection": selection,
|
||
"total_price": final_price,
|
||
"total_original": total_original,
|
||
"months": months,
|
||
"base_price": discounted_base,
|
||
"base_price_original": base_price_original,
|
||
"promo_offer_percent": promo_offer_percent,
|
||
"promo_discount_value": promo_discount_value,
|
||
"traffic_total": total_traffic_price,
|
||
"servers_total": total_servers_price,
|
||
"devices_total": total_devices_price,
|
||
"server_catalog": server_catalog,
|
||
"server_option_models": server_option_models,
|
||
"server_prices_for_period": [
|
||
int(entry.get("discounted_per_month", 0) or 0) * months
|
||
for entry in selected_entries
|
||
],
|
||
"traffic_discount_total": traffic_discount_per_month * months,
|
||
"devices_discount_total": devices_discount_per_month * months,
|
||
"servers_discount_total": (servers_original_per_month - servers_discounted_per_month) * months,
|
||
"base_discount_total": base_discount_value,
|
||
"selected_server_entries": selected_entries,
|
||
}
|
||
|
||
return calculation_details
|
||
|
||
|
||
def _get_period_hint_from_subscription(
|
||
subscription: Optional[Subscription],
|
||
) -> Optional[int]:
|
||
if not subscription:
|
||
return None
|
||
|
||
months_remaining = get_remaining_months(subscription.end_date)
|
||
if months_remaining <= 0:
|
||
return None
|
||
|
||
return months_remaining * 30
|
||
|
||
|
||
def _validate_subscription_id(
|
||
requested_id: Optional[int],
|
||
subscription: Subscription,
|
||
) -> None:
|
||
if requested_id is None:
|
||
return
|
||
|
||
try:
|
||
requested = int(requested_id)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={
|
||
"code": "invalid_subscription_id",
|
||
"message": "Invalid subscription identifier",
|
||
},
|
||
) from None
|
||
|
||
if requested != subscription.id:
|
||
raise HTTPException(
|
||
status.HTTP_403_FORBIDDEN,
|
||
detail={
|
||
"code": "subscription_mismatch",
|
||
"message": "Subscription does not belong to the authorized user",
|
||
},
|
||
)
|
||
|
||
|
||
async def _authorize_miniapp_user(
|
||
init_data: str,
|
||
db: AsyncSession,
|
||
) -> User:
|
||
if not init_data:
|
||
raise HTTPException(
|
||
status.HTTP_401_UNAUTHORIZED,
|
||
detail={"code": "unauthorized", "message": "Authorization data is missing"},
|
||
)
|
||
|
||
try:
|
||
webapp_data = parse_webapp_init_data(init_data, settings.BOT_TOKEN)
|
||
except TelegramWebAppAuthError as error:
|
||
raise HTTPException(
|
||
status.HTTP_401_UNAUTHORIZED,
|
||
detail={"code": "unauthorized", "message": str(error)},
|
||
) from error
|
||
|
||
telegram_user = webapp_data.get("user")
|
||
if not isinstance(telegram_user, dict) or "id" not in telegram_user:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user payload"},
|
||
)
|
||
|
||
try:
|
||
telegram_id = int(telegram_user["id"])
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "invalid_user", "message": "Invalid Telegram user identifier"},
|
||
) from None
|
||
|
||
user = await get_user_by_telegram_id(db, telegram_id)
|
||
if not user:
|
||
raise HTTPException(
|
||
status.HTTP_404_NOT_FOUND,
|
||
detail={"code": "user_not_found", "message": "User not found"},
|
||
)
|
||
|
||
return user
|
||
|
||
|
||
def _ensure_paid_subscription(user: User) -> Subscription:
|
||
subscription = getattr(user, "subscription", None)
|
||
if not subscription:
|
||
raise HTTPException(
|
||
status.HTTP_404_NOT_FOUND,
|
||
detail={"code": "subscription_not_found", "message": "Subscription not found"},
|
||
)
|
||
|
||
if getattr(subscription, "is_trial", False):
|
||
raise HTTPException(
|
||
status.HTTP_403_FORBIDDEN,
|
||
detail={
|
||
"code": "paid_subscription_required",
|
||
"message": "This action is available only for paid subscriptions",
|
||
},
|
||
)
|
||
|
||
if not getattr(subscription, "is_active", False):
|
||
raise HTTPException(
|
||
status.HTTP_403_FORBIDDEN,
|
||
detail={
|
||
"code": "subscription_inactive",
|
||
"message": "Subscription must be active to manage settings",
|
||
},
|
||
)
|
||
|
||
return subscription
|
||
|
||
|
||
async def _prepare_server_catalog(
|
||
db: AsyncSession,
|
||
user: User,
|
||
subscription: Subscription,
|
||
discount_percent: int,
|
||
) -> Tuple[
|
||
List[MiniAppConnectedServer],
|
||
List[MiniAppSubscriptionServerOption],
|
||
Dict[str, Dict[str, Any]],
|
||
]:
|
||
available_servers = await get_available_server_squads(
|
||
db,
|
||
promo_group_id=getattr(user, "promo_group_id", None),
|
||
)
|
||
available_by_uuid = {server.squad_uuid: server for server in available_servers}
|
||
|
||
current_squads = list(subscription.connected_squads or [])
|
||
catalog: Dict[str, Dict[str, Any]] = {}
|
||
ordered_uuids: List[str] = []
|
||
|
||
def _register_server(server: Optional[Any], *, is_connected: bool = False) -> None:
|
||
if server is None:
|
||
return
|
||
|
||
uuid = server.squad_uuid
|
||
discounted_per_month, discount_per_month = apply_percentage_discount(
|
||
int(getattr(server, "price_kopeks", 0) or 0),
|
||
discount_percent,
|
||
)
|
||
available_for_new = bool(getattr(server, "is_available", True) and not server.is_full)
|
||
|
||
entry = catalog.get(uuid)
|
||
if entry:
|
||
entry.update(
|
||
{
|
||
"name": getattr(server, "display_name", uuid),
|
||
"server_id": getattr(server, "id", None),
|
||
"price_per_month": int(getattr(server, "price_kopeks", 0) or 0),
|
||
"discounted_per_month": discounted_per_month,
|
||
"discount_per_month": discount_per_month,
|
||
"available_for_new": available_for_new,
|
||
}
|
||
)
|
||
entry["is_connected"] = entry["is_connected"] or is_connected
|
||
return
|
||
|
||
catalog[uuid] = {
|
||
"uuid": uuid,
|
||
"name": getattr(server, "display_name", uuid),
|
||
"server_id": getattr(server, "id", None),
|
||
"price_per_month": int(getattr(server, "price_kopeks", 0) or 0),
|
||
"discounted_per_month": discounted_per_month,
|
||
"discount_per_month": discount_per_month,
|
||
"available_for_new": available_for_new,
|
||
"is_connected": is_connected,
|
||
}
|
||
ordered_uuids.append(uuid)
|
||
|
||
def _register_placeholder(uuid: str, *, is_connected: bool = False) -> None:
|
||
if uuid in catalog:
|
||
catalog[uuid]["is_connected"] = catalog[uuid]["is_connected"] or is_connected
|
||
return
|
||
|
||
catalog[uuid] = {
|
||
"uuid": uuid,
|
||
"name": uuid,
|
||
"server_id": None,
|
||
"price_per_month": 0,
|
||
"discounted_per_month": 0,
|
||
"discount_per_month": 0,
|
||
"available_for_new": False,
|
||
"is_connected": is_connected,
|
||
}
|
||
ordered_uuids.append(uuid)
|
||
|
||
current_set = set(current_squads)
|
||
|
||
for uuid in current_squads:
|
||
server = available_by_uuid.get(uuid)
|
||
if server:
|
||
_register_server(server, is_connected=True)
|
||
continue
|
||
|
||
server = await get_server_squad_by_uuid(db, uuid)
|
||
if server:
|
||
_register_server(server, is_connected=True)
|
||
else:
|
||
_register_placeholder(uuid, is_connected=True)
|
||
|
||
for server in available_servers:
|
||
_register_server(server, is_connected=server.squad_uuid in current_set)
|
||
|
||
current_servers = [
|
||
MiniAppConnectedServer(
|
||
uuid=uuid,
|
||
name=catalog.get(uuid, {}).get("name", uuid),
|
||
)
|
||
for uuid in current_squads
|
||
]
|
||
|
||
server_options: List[MiniAppSubscriptionServerOption] = []
|
||
discount_value = discount_percent if discount_percent > 0 else None
|
||
|
||
for uuid in ordered_uuids:
|
||
entry = catalog[uuid]
|
||
available_for_new = bool(entry.get("available_for_new", False))
|
||
is_connected = bool(entry.get("is_connected", False))
|
||
option_available = available_for_new or is_connected
|
||
server_options.append(
|
||
MiniAppSubscriptionServerOption(
|
||
uuid=uuid,
|
||
name=entry.get("name", uuid),
|
||
price_kopeks=int(entry.get("discounted_per_month", 0)),
|
||
price_label=None,
|
||
discount_percent=discount_value,
|
||
is_connected=is_connected,
|
||
is_available=option_available,
|
||
disabled_reason=None if option_available else "Server is not available",
|
||
)
|
||
)
|
||
|
||
return current_servers, server_options, catalog
|
||
|
||
|
||
async def _build_subscription_settings(
|
||
db: AsyncSession,
|
||
user: User,
|
||
subscription: Subscription,
|
||
) -> MiniAppSubscriptionSettings:
|
||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||
months_remaining = get_remaining_months(subscription.end_date)
|
||
servers_discount = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"servers",
|
||
period_hint_days,
|
||
)
|
||
traffic_discount = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"traffic",
|
||
period_hint_days,
|
||
)
|
||
devices_discount = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"devices",
|
||
period_hint_days,
|
||
)
|
||
|
||
current_servers, server_options, _ = await _prepare_server_catalog(
|
||
db,
|
||
user,
|
||
subscription,
|
||
servers_discount,
|
||
)
|
||
|
||
traffic_options: List[MiniAppSubscriptionTrafficOption] = []
|
||
if settings.is_traffic_selectable():
|
||
for package in settings.get_traffic_packages():
|
||
is_enabled = bool(package.get("enabled", True))
|
||
if package.get("is_active") is False:
|
||
is_enabled = False
|
||
if not is_enabled:
|
||
continue
|
||
try:
|
||
gb_value = int(package.get("gb"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
price = int(package.get("price") or 0)
|
||
discounted_price, _ = apply_percentage_discount(price, traffic_discount)
|
||
traffic_options.append(
|
||
MiniAppSubscriptionTrafficOption(
|
||
value=gb_value,
|
||
label=None,
|
||
price_kopeks=discounted_price,
|
||
price_label=None,
|
||
is_current=(gb_value == subscription.traffic_limit_gb),
|
||
is_available=True,
|
||
description=None,
|
||
)
|
||
)
|
||
|
||
default_device_limit = max(settings.DEFAULT_DEVICE_LIMIT, 1)
|
||
current_device_limit = int(subscription.device_limit or default_device_limit)
|
||
|
||
max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
|
||
if max_devices_setting is not None:
|
||
max_devices = max(max_devices_setting, current_device_limit, default_device_limit)
|
||
else:
|
||
max_devices = max(current_device_limit, default_device_limit) + 10
|
||
|
||
discounted_single_device, _ = apply_percentage_discount(
|
||
settings.PRICE_PER_DEVICE,
|
||
devices_discount,
|
||
)
|
||
|
||
devices_options: List[MiniAppSubscriptionDeviceOption] = []
|
||
for value in range(1, max_devices + 1):
|
||
chargeable = max(0, value - default_device_limit)
|
||
discounted_per_month, _ = apply_percentage_discount(
|
||
chargeable * settings.PRICE_PER_DEVICE,
|
||
devices_discount,
|
||
)
|
||
devices_options.append(
|
||
MiniAppSubscriptionDeviceOption(
|
||
value=value,
|
||
label=None,
|
||
price_kopeks=discounted_per_month,
|
||
price_label=None,
|
||
)
|
||
)
|
||
|
||
settings_payload = MiniAppSubscriptionSettings(
|
||
subscription_id=subscription.id,
|
||
currency=(getattr(user, "balance_currency", None) or "RUB").upper(),
|
||
current=MiniAppSubscriptionCurrentSettings(
|
||
servers=current_servers,
|
||
traffic_limit_gb=subscription.traffic_limit_gb,
|
||
traffic_limit_label=None,
|
||
device_limit=current_device_limit,
|
||
),
|
||
servers=MiniAppSubscriptionServersSettings(
|
||
available=server_options,
|
||
min=1 if server_options else 0,
|
||
max=len(server_options) if server_options else 0,
|
||
can_update=True,
|
||
hint=None,
|
||
),
|
||
traffic=MiniAppSubscriptionTrafficSettings(
|
||
options=traffic_options,
|
||
can_update=settings.is_traffic_selectable(),
|
||
current_value=subscription.traffic_limit_gb,
|
||
),
|
||
devices=MiniAppSubscriptionDevicesSettings(
|
||
options=devices_options,
|
||
can_update=True,
|
||
min=1,
|
||
max=max_devices_setting or 0,
|
||
step=1,
|
||
current=current_device_limit,
|
||
price_kopeks=discounted_single_device,
|
||
price_label=None,
|
||
),
|
||
billing=MiniAppSubscriptionBillingContext(
|
||
months_remaining=max(1, months_remaining),
|
||
period_hint_days=period_hint_days,
|
||
renews_at=subscription.end_date,
|
||
),
|
||
)
|
||
|
||
return settings_payload
|
||
|
||
|
||
@router.post(
|
||
"/subscription/purchase/options",
|
||
response_model=MiniAppSubscriptionPurchaseOptionsResponse,
|
||
)
|
||
async def get_subscription_purchase_options_endpoint(
|
||
payload: MiniAppSubscriptionPurchaseOptionsRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionPurchaseOptionsResponse:
|
||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||
|
||
options_payload = await _build_subscription_purchase_options_payload(db, user, payload)
|
||
|
||
return MiniAppSubscriptionPurchaseOptionsResponse(data=options_payload)
|
||
|
||
|
||
@router.post(
|
||
"/subscription/purchase/preview",
|
||
response_model=MiniAppSubscriptionPurchasePreviewResponse,
|
||
)
|
||
async def preview_subscription_purchase_endpoint(
|
||
payload: MiniAppSubscriptionPurchasePreviewRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionPurchasePreviewResponse:
|
||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||
|
||
available_periods = _get_available_purchase_periods()
|
||
available_packages = _get_available_traffic_packages()
|
||
available_servers_models = await get_available_server_squads(
|
||
db,
|
||
promo_group_id=getattr(user, "promo_group_id", None),
|
||
)
|
||
available_server_uuids = [
|
||
server.squad_uuid
|
||
for server in available_servers_models
|
||
if getattr(server, "squad_uuid", None)
|
||
]
|
||
|
||
selection = _build_default_purchase_selection(
|
||
payload,
|
||
user,
|
||
available_periods,
|
||
available_packages,
|
||
available_server_uuids,
|
||
)
|
||
|
||
calculation = await _calculate_subscription_purchase_preview(db, user, selection)
|
||
preview_model = MiniAppSubscriptionPurchasePreview(**calculation["preview"])
|
||
|
||
return MiniAppSubscriptionPurchasePreviewResponse(preview=preview_model)
|
||
|
||
|
||
@router.post(
|
||
"/subscription/purchase",
|
||
response_model=MiniAppSubscriptionPurchaseSubmitResponse,
|
||
)
|
||
async def submit_subscription_purchase_endpoint(
|
||
payload: MiniAppSubscriptionPurchaseSubmitRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionPurchaseSubmitResponse:
|
||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||
|
||
available_periods = _get_available_purchase_periods()
|
||
available_packages = _get_available_traffic_packages()
|
||
available_servers_models = await get_available_server_squads(
|
||
db,
|
||
promo_group_id=getattr(user, "promo_group_id", None),
|
||
)
|
||
available_server_uuids = [
|
||
server.squad_uuid
|
||
for server in available_servers_models
|
||
if getattr(server, "squad_uuid", None)
|
||
]
|
||
|
||
selection = _build_default_purchase_selection(
|
||
payload,
|
||
user,
|
||
available_periods,
|
||
available_packages,
|
||
available_server_uuids,
|
||
)
|
||
|
||
calculation = await _calculate_subscription_purchase_preview(db, user, selection)
|
||
preview_payload = calculation["preview"]
|
||
|
||
missing_amount = preview_payload.get("missingAmountKopeks") or 0
|
||
if missing_amount and missing_amount > 0:
|
||
raise HTTPException(
|
||
status.HTTP_402_PAYMENT_REQUIRED,
|
||
detail={
|
||
"code": "insufficient_funds",
|
||
"message": "Insufficient funds on balance",
|
||
"missing_amount_kopeks": missing_amount,
|
||
},
|
||
)
|
||
|
||
final_price = calculation["total_price"]
|
||
description = f"Purchase of subscription for {selection.period_days} days"
|
||
|
||
balance_debited = False
|
||
purchase_completed = False
|
||
subscription = getattr(user, "subscription", None)
|
||
server_entries = calculation.get("selected_server_entries", [])
|
||
server_prices_for_period = calculation.get("server_prices_for_period", [])
|
||
|
||
try:
|
||
if final_price > 0:
|
||
success = await subtract_user_balance(
|
||
db,
|
||
user,
|
||
final_price,
|
||
description,
|
||
consume_promo_offer=calculation.get("promo_discount_value", 0) > 0,
|
||
)
|
||
if not success:
|
||
raise HTTPException(
|
||
status.HTTP_402_PAYMENT_REQUIRED,
|
||
detail={
|
||
"code": "insufficient_funds",
|
||
"message": "Insufficient funds on balance",
|
||
},
|
||
)
|
||
balance_debited = True
|
||
|
||
current_time = datetime.utcnow()
|
||
traffic_limit_gb = (
|
||
selection.traffic_gb
|
||
if settings.is_traffic_selectable()
|
||
else settings.get_fixed_traffic_limit()
|
||
)
|
||
|
||
if subscription:
|
||
bonus_period = timedelta()
|
||
if subscription.is_trial:
|
||
try:
|
||
trial_duration = (current_time - (subscription.start_date or current_time)).days
|
||
except Exception:
|
||
trial_duration = 0
|
||
|
||
if (
|
||
settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID
|
||
and getattr(subscription, "end_date", None)
|
||
):
|
||
remaining = subscription.end_date - current_time
|
||
if remaining.total_seconds() > 0:
|
||
bonus_period = remaining
|
||
|
||
try:
|
||
await create_subscription_conversion(
|
||
db=db,
|
||
user_id=user.id,
|
||
trial_duration_days=trial_duration,
|
||
payment_method="balance",
|
||
first_payment_amount_kopeks=final_price,
|
||
first_paid_period_days=selection.period_days,
|
||
)
|
||
except Exception as conversion_error:
|
||
logger.warning(
|
||
"Failed to record trial conversion for user %s: %s",
|
||
user.id,
|
||
conversion_error,
|
||
)
|
||
|
||
subscription.is_trial = False
|
||
|
||
subscription.status = SubscriptionStatus.ACTIVE.value
|
||
subscription.start_date = current_time
|
||
subscription.end_date = current_time + timedelta(days=selection.period_days) + bonus_period
|
||
subscription.traffic_limit_gb = traffic_limit_gb
|
||
subscription.device_limit = selection.devices
|
||
subscription.connected_squads = selection.servers
|
||
subscription.traffic_used_gb = 0.0
|
||
subscription.updated_at = current_time
|
||
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
else:
|
||
subscription = await create_paid_subscription(
|
||
db=db,
|
||
user_id=user.id,
|
||
duration_days=selection.period_days,
|
||
traffic_limit_gb=traffic_limit_gb,
|
||
device_limit=selection.devices,
|
||
connected_squads=selection.servers,
|
||
update_server_counters=False,
|
||
)
|
||
|
||
server_data: List[Tuple[int, int]] = []
|
||
for entry, price in zip(server_entries, server_prices_for_period):
|
||
server_id = entry.get("server_id") if isinstance(entry, dict) else None
|
||
if server_id is None:
|
||
try:
|
||
server = await get_server_squad_by_uuid(db, entry.get("uuid"))
|
||
server_id = getattr(server, "id", None)
|
||
except Exception:
|
||
server_id = None
|
||
if server_id is not None:
|
||
server_data.append((int(server_id), int(price)))
|
||
|
||
if server_data:
|
||
server_ids = [item[0] for item in server_data]
|
||
server_paid_prices = [item[1] for item in server_data]
|
||
await add_subscription_servers(db, subscription, server_ids, server_paid_prices)
|
||
await add_user_to_servers(db, server_ids)
|
||
|
||
await mark_user_as_had_paid_subscription(db, user)
|
||
|
||
service = SubscriptionService()
|
||
try:
|
||
if getattr(user, "remnawave_uuid", None):
|
||
remna_user = await service.update_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=getattr(settings, "RESET_TRAFFIC_ON_PAYMENT", False),
|
||
reset_reason="subscription_purchase",
|
||
)
|
||
else:
|
||
remna_user = await service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=getattr(settings, "RESET_TRAFFIC_ON_PAYMENT", False),
|
||
reset_reason="subscription_purchase",
|
||
)
|
||
except Exception as service_error:
|
||
logger.warning(
|
||
"Failed to synchronize RemnaWave user for %s: %s",
|
||
user.id,
|
||
service_error,
|
||
)
|
||
remna_user = None
|
||
|
||
if not remna_user:
|
||
try:
|
||
remna_user = await service.create_remnawave_user(
|
||
db,
|
||
subscription,
|
||
reset_traffic=getattr(settings, "RESET_TRAFFIC_ON_PAYMENT", False),
|
||
reset_reason="subscription_purchase_retry",
|
||
)
|
||
except Exception as retry_error:
|
||
logger.warning(
|
||
"Failed to create RemnaWave user on retry for %s: %s",
|
||
user.id,
|
||
retry_error,
|
||
)
|
||
|
||
await create_transaction(
|
||
db=db,
|
||
user_id=user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=final_price,
|
||
description=f"Subscription for {selection.period_days} days",
|
||
)
|
||
|
||
await db.refresh(user)
|
||
purchase_completed = True
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as purchase_error:
|
||
logger.error("Subscription purchase failed for user %s: %s", user.id, purchase_error)
|
||
raise HTTPException(
|
||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||
detail={"code": "purchase_failed", "message": "Failed to complete subscription purchase"},
|
||
) from purchase_error
|
||
finally:
|
||
if not purchase_completed and balance_debited and final_price > 0:
|
||
try:
|
||
await add_user_balance(
|
||
db,
|
||
user,
|
||
final_price,
|
||
"Refund for failed subscription purchase",
|
||
)
|
||
except Exception as refund_error:
|
||
logger.error(
|
||
"Failed to refund user %s after purchase failure: %s",
|
||
user.id,
|
||
refund_error,
|
||
)
|
||
|
||
response = MiniAppSubscriptionPurchaseSubmitResponse(
|
||
success=True,
|
||
message="Subscription purchased successfully",
|
||
subscriptionId=getattr(subscription, "id", None),
|
||
balanceKopeks=getattr(user, "balance_kopeks", 0),
|
||
balanceLabel=_format_price_label(getattr(user, "balance_kopeks", 0)),
|
||
)
|
||
|
||
return response
|
||
|
||
|
||
@router.post(
|
||
"/subscription/settings",
|
||
response_model=MiniAppSubscriptionSettingsResponse,
|
||
)
|
||
async def get_subscription_settings_endpoint(
|
||
payload: MiniAppSubscriptionSettingsRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionSettingsResponse:
|
||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||
subscription = _ensure_paid_subscription(user)
|
||
_validate_subscription_id(payload.subscription_id, subscription)
|
||
|
||
settings_payload = await _build_subscription_settings(db, user, subscription)
|
||
|
||
return MiniAppSubscriptionSettingsResponse(settings=settings_payload)
|
||
|
||
|
||
@router.post(
|
||
"/subscription/servers",
|
||
response_model=MiniAppSubscriptionUpdateResponse,
|
||
)
|
||
async def update_subscription_servers_endpoint(
|
||
payload: MiniAppSubscriptionServersUpdateRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionUpdateResponse:
|
||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||
subscription = _ensure_paid_subscription(user)
|
||
_validate_subscription_id(payload.subscription_id, subscription)
|
||
|
||
raw_selection: List[str] = []
|
||
for collection in (
|
||
payload.servers,
|
||
payload.squads,
|
||
payload.server_uuids,
|
||
payload.squad_uuids,
|
||
):
|
||
if collection:
|
||
raw_selection.extend(collection)
|
||
|
||
selected_order: List[str] = []
|
||
seen: set[str] = set()
|
||
for item in raw_selection:
|
||
if not item:
|
||
continue
|
||
uuid = str(item).strip()
|
||
if not uuid or uuid in seen:
|
||
continue
|
||
seen.add(uuid)
|
||
selected_order.append(uuid)
|
||
|
||
if not selected_order:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={
|
||
"code": "validation_error",
|
||
"message": "At least one server must be selected",
|
||
},
|
||
)
|
||
|
||
current_squads = list(subscription.connected_squads or [])
|
||
current_set = set(current_squads)
|
||
selected_set = set(selected_order)
|
||
|
||
added = [uuid for uuid in selected_order if uuid not in current_set]
|
||
removed = [uuid for uuid in current_squads if uuid not in selected_set]
|
||
|
||
if not added and not removed:
|
||
return MiniAppSubscriptionUpdateResponse(
|
||
success=True,
|
||
message="No changes",
|
||
)
|
||
|
||
period_hint_days = _get_period_hint_from_subscription(subscription)
|
||
servers_discount = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"servers",
|
||
period_hint_days,
|
||
)
|
||
|
||
_, _, catalog = await _prepare_server_catalog(
|
||
db,
|
||
user,
|
||
subscription,
|
||
servers_discount,
|
||
)
|
||
|
||
invalid_servers = [uuid for uuid in selected_order if uuid not in catalog]
|
||
if invalid_servers:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={
|
||
"code": "invalid_servers",
|
||
"message": "Some of the selected servers are not available",
|
||
},
|
||
)
|
||
|
||
for uuid in added:
|
||
entry = catalog.get(uuid)
|
||
if not entry or not entry.get("available_for_new", False):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={
|
||
"code": "server_unavailable",
|
||
"message": "Selected server is not available",
|
||
},
|
||
)
|
||
|
||
cost_per_month = sum(int(catalog[uuid].get("discounted_per_month", 0)) for uuid in added)
|
||
total_cost = 0
|
||
charged_months = 0
|
||
if cost_per_month > 0:
|
||
total_cost, charged_months = calculate_prorated_price(
|
||
cost_per_month,
|
||
subscription.end_date,
|
||
)
|
||
else:
|
||
charged_months = get_remaining_months(subscription.end_date)
|
||
|
||
added_server_ids = [
|
||
catalog[uuid].get("server_id")
|
||
for uuid in added
|
||
if catalog[uuid].get("server_id") is not None
|
||
]
|
||
added_server_prices = [
|
||
int(catalog[uuid].get("discounted_per_month", 0)) * charged_months
|
||
for uuid in added
|
||
if catalog[uuid].get("server_id") is not None
|
||
]
|
||
|
||
if total_cost > 0 and getattr(user, "balance_kopeks", 0) < total_cost:
|
||
missing = total_cost - getattr(user, "balance_kopeks", 0)
|
||
raise HTTPException(
|
||
status.HTTP_402_PAYMENT_REQUIRED,
|
||
detail={
|
||
"code": "insufficient_funds",
|
||
"message": (
|
||
"Недостаточно средств на балансе. "
|
||
f"Не хватает {settings.format_price(missing)}"
|
||
),
|
||
},
|
||
)
|
||
|
||
if total_cost > 0:
|
||
added_names = [catalog[uuid].get("name", uuid) for uuid in added]
|
||
description = (
|
||
f"Добавление серверов: {', '.join(added_names)} на {charged_months} мес"
|
||
if added_names
|
||
else "Изменение списка серверов"
|
||
)
|
||
|
||
success = await subtract_user_balance(
|
||
db,
|
||
user,
|
||
total_cost,
|
||
description,
|
||
)
|
||
if not success:
|
||
raise HTTPException(
|
||
status.HTTP_502_BAD_GATEWAY,
|
||
detail={
|
||
"code": "balance_charge_failed",
|
||
"message": "Failed to charge user balance",
|
||
},
|
||
)
|
||
|
||
await create_transaction(
|
||
db=db,
|
||
user_id=user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=total_cost,
|
||
description=description,
|
||
)
|
||
|
||
if added_server_ids:
|
||
await add_subscription_servers(db, subscription, added_server_ids, added_server_prices)
|
||
await add_user_to_servers(db, added_server_ids)
|
||
|
||
removed_server_ids = [
|
||
catalog[uuid].get("server_id")
|
||
for uuid in removed
|
||
if catalog[uuid].get("server_id") is not None
|
||
]
|
||
|
||
if removed_server_ids:
|
||
await remove_subscription_servers(db, subscription.id, removed_server_ids)
|
||
await remove_user_from_servers(db, removed_server_ids)
|
||
|
||
ordered_selection = []
|
||
seen_selection = set()
|
||
for uuid in selected_order:
|
||
if uuid in seen_selection:
|
||
continue
|
||
seen_selection.add(uuid)
|
||
ordered_selection.append(uuid)
|
||
|
||
subscription.connected_squads = ordered_selection
|
||
subscription.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
|
||
service = SubscriptionService()
|
||
await service.update_remnawave_user(db, subscription)
|
||
|
||
return MiniAppSubscriptionUpdateResponse(success=True)
|
||
|
||
|
||
@router.post(
|
||
"/subscription/traffic",
|
||
response_model=MiniAppSubscriptionUpdateResponse,
|
||
)
|
||
async def update_subscription_traffic_endpoint(
|
||
payload: MiniAppSubscriptionTrafficUpdateRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionUpdateResponse:
|
||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||
subscription = _ensure_paid_subscription(user)
|
||
_validate_subscription_id(payload.subscription_id, subscription)
|
||
|
||
raw_value = (
|
||
payload.traffic
|
||
if payload.traffic is not None
|
||
else payload.traffic_gb
|
||
)
|
||
if raw_value is None:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "validation_error", "message": "Traffic amount is required"},
|
||
)
|
||
|
||
try:
|
||
new_traffic = int(raw_value)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "validation_error", "message": "Invalid traffic amount"},
|
||
) from None
|
||
|
||
if new_traffic < 0:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "validation_error", "message": "Traffic amount must be non-negative"},
|
||
)
|
||
|
||
if new_traffic == subscription.traffic_limit_gb:
|
||
return MiniAppSubscriptionUpdateResponse(success=True, message="No changes")
|
||
|
||
if not settings.is_traffic_selectable():
|
||
raise HTTPException(
|
||
status.HTTP_403_FORBIDDEN,
|
||
detail={
|
||
"code": "traffic_fixed",
|
||
"message": "Traffic cannot be changed for this subscription",
|
||
},
|
||
)
|
||
|
||
available_packages: List[int] = []
|
||
for package in settings.get_traffic_packages():
|
||
try:
|
||
gb_value = int(package.get("gb"))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
is_enabled = bool(package.get("enabled", True))
|
||
if package.get("is_active") is False:
|
||
is_enabled = False
|
||
if is_enabled:
|
||
available_packages.append(gb_value)
|
||
|
||
if available_packages and new_traffic not in available_packages:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={
|
||
"code": "traffic_unavailable",
|
||
"message": "Selected traffic package is not available",
|
||
},
|
||
)
|
||
|
||
months_remaining = get_remaining_months(subscription.end_date)
|
||
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
|
||
traffic_discount = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"traffic",
|
||
period_hint_days,
|
||
)
|
||
|
||
old_price_per_month = settings.get_traffic_price(subscription.traffic_limit_gb)
|
||
new_price_per_month = settings.get_traffic_price(new_traffic)
|
||
|
||
discounted_old_per_month, _ = apply_percentage_discount(
|
||
old_price_per_month,
|
||
traffic_discount,
|
||
)
|
||
discounted_new_per_month, _ = apply_percentage_discount(
|
||
new_price_per_month,
|
||
traffic_discount,
|
||
)
|
||
|
||
price_difference_per_month = discounted_new_per_month - discounted_old_per_month
|
||
total_price_difference = 0
|
||
|
||
if price_difference_per_month > 0:
|
||
total_price_difference = price_difference_per_month * months_remaining
|
||
if getattr(user, "balance_kopeks", 0) < total_price_difference:
|
||
missing = total_price_difference - getattr(user, "balance_kopeks", 0)
|
||
raise HTTPException(
|
||
status.HTTP_402_PAYMENT_REQUIRED,
|
||
detail={
|
||
"code": "insufficient_funds",
|
||
"message": (
|
||
"Недостаточно средств на балансе. "
|
||
f"Не хватает {settings.format_price(missing)}"
|
||
),
|
||
},
|
||
)
|
||
|
||
description = (
|
||
"Переключение трафика с "
|
||
f"{subscription.traffic_limit_gb}GB на {new_traffic}GB"
|
||
)
|
||
|
||
success = await subtract_user_balance(
|
||
db,
|
||
user,
|
||
total_price_difference,
|
||
description,
|
||
)
|
||
if not success:
|
||
raise HTTPException(
|
||
status.HTTP_502_BAD_GATEWAY,
|
||
detail={
|
||
"code": "balance_charge_failed",
|
||
"message": "Failed to charge user balance",
|
||
},
|
||
)
|
||
|
||
await create_transaction(
|
||
db=db,
|
||
user_id=user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=total_price_difference,
|
||
description=f"{description} на {months_remaining} мес",
|
||
)
|
||
|
||
subscription.traffic_limit_gb = new_traffic
|
||
subscription.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
|
||
service = SubscriptionService()
|
||
await service.update_remnawave_user(db, subscription)
|
||
|
||
return MiniAppSubscriptionUpdateResponse(success=True)
|
||
|
||
|
||
@router.post(
|
||
"/subscription/devices",
|
||
response_model=MiniAppSubscriptionUpdateResponse,
|
||
)
|
||
async def update_subscription_devices_endpoint(
|
||
payload: MiniAppSubscriptionDevicesUpdateRequest,
|
||
db: AsyncSession = Depends(get_db_session),
|
||
) -> MiniAppSubscriptionUpdateResponse:
|
||
user = await _authorize_miniapp_user(payload.init_data, db)
|
||
subscription = _ensure_paid_subscription(user)
|
||
_validate_subscription_id(payload.subscription_id, subscription)
|
||
|
||
raw_value = payload.devices if payload.devices is not None else payload.device_limit
|
||
if raw_value is None:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "validation_error", "message": "Device limit is required"},
|
||
)
|
||
|
||
try:
|
||
new_devices = int(raw_value)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "validation_error", "message": "Invalid device limit"},
|
||
) from None
|
||
|
||
if new_devices <= 0:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={"code": "validation_error", "message": "Device limit must be positive"},
|
||
)
|
||
|
||
if settings.MAX_DEVICES_LIMIT > 0 and new_devices > settings.MAX_DEVICES_LIMIT:
|
||
raise HTTPException(
|
||
status.HTTP_400_BAD_REQUEST,
|
||
detail={
|
||
"code": "devices_limit_exceeded",
|
||
"message": (
|
||
"Превышен максимальный лимит устройств "
|
||
f"({settings.MAX_DEVICES_LIMIT})"
|
||
),
|
||
},
|
||
)
|
||
|
||
current_devices = int(subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT or 1)
|
||
|
||
if new_devices == current_devices:
|
||
return MiniAppSubscriptionUpdateResponse(success=True, message="No changes")
|
||
|
||
devices_difference = new_devices - current_devices
|
||
price_to_charge = 0
|
||
charged_months = 0
|
||
|
||
if devices_difference > 0:
|
||
current_chargeable = max(0, current_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||
new_chargeable = max(0, new_devices - settings.DEFAULT_DEVICE_LIMIT)
|
||
chargeable_diff = new_chargeable - current_chargeable
|
||
|
||
price_per_month = chargeable_diff * settings.PRICE_PER_DEVICE
|
||
months_remaining = get_remaining_months(subscription.end_date)
|
||
period_hint_days = months_remaining * 30 if months_remaining > 0 else None
|
||
devices_discount = _get_addon_discount_percent_for_user(
|
||
user,
|
||
"devices",
|
||
period_hint_days,
|
||
)
|
||
|
||
discounted_per_month, _ = apply_percentage_discount(
|
||
price_per_month,
|
||
devices_discount,
|
||
)
|
||
price_to_charge, charged_months = calculate_prorated_price(
|
||
discounted_per_month,
|
||
subscription.end_date,
|
||
)
|
||
|
||
if price_to_charge > 0 and getattr(user, "balance_kopeks", 0) < price_to_charge:
|
||
missing = price_to_charge - getattr(user, "balance_kopeks", 0)
|
||
raise HTTPException(
|
||
status.HTTP_402_PAYMENT_REQUIRED,
|
||
detail={
|
||
"code": "insufficient_funds",
|
||
"message": (
|
||
"Недостаточно средств на балансе. "
|
||
f"Не хватает {settings.format_price(missing)}"
|
||
),
|
||
},
|
||
)
|
||
|
||
if price_to_charge > 0:
|
||
description = (
|
||
"Изменение количества устройств с "
|
||
f"{current_devices} до {new_devices}"
|
||
)
|
||
success = await subtract_user_balance(
|
||
db,
|
||
user,
|
||
price_to_charge,
|
||
description,
|
||
)
|
||
if not success:
|
||
raise HTTPException(
|
||
status.HTTP_502_BAD_GATEWAY,
|
||
detail={
|
||
"code": "balance_charge_failed",
|
||
"message": "Failed to charge user balance",
|
||
},
|
||
)
|
||
|
||
await create_transaction(
|
||
db=db,
|
||
user_id=user.id,
|
||
type=TransactionType.SUBSCRIPTION_PAYMENT,
|
||
amount_kopeks=price_to_charge,
|
||
description=f"{description} на {charged_months or get_remaining_months(subscription.end_date)} мес",
|
||
)
|
||
|
||
subscription.device_limit = new_devices
|
||
subscription.updated_at = datetime.utcnow()
|
||
await db.commit()
|
||
await db.refresh(subscription)
|
||
|
||
service = SubscriptionService()
|
||
await service.update_remnawave_user(db, subscription)
|
||
|
||
return MiniAppSubscriptionUpdateResponse(success=True)
|
||
|