Files
remnawave-bedolaga-telegram…/app/services/subscription_purchase_service.py
2025-11-29 09:50:35 +03:00

1233 lines
46 KiB
Python

import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional, Sequence, Tuple
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import PERIOD_PRICES, settings
from app.database.crud.server_squad import (
add_user_to_servers,
get_available_server_squads,
get_server_ids_by_uuids,
get_server_squad_by_uuid,
)
from app.database.crud.subscription import (
add_subscription_servers,
create_paid_subscription,
)
from app.database.crud.subscription_conversion import (
create_subscription_conversion,
)
from app.database.crud.transaction import create_transaction
from app.database.crud.user import subtract_user_balance
from app.database.models import ServerSquad, Subscription, SubscriptionStatus, TransactionType, User
from app.localization.texts import get_texts
from app.services.subscription_service import SubscriptionService
from app.utils.pricing_utils import (
calculate_months_from_days,
format_period_description,
validate_pricing_calculation,
)
from app.utils.promo_offer import get_user_active_promo_discount_percent
from app.utils.user_utils import mark_user_as_had_paid_subscription
logger = logging.getLogger(__name__)
@dataclass
class PurchaseTrafficOption:
value: int
label: str
price_per_month: int
price_label: str
original_price_per_month: Optional[int] = None
original_price_label: Optional[str] = None
discount_percent: int = 0
is_available: bool = True
is_default: bool = False
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"value": self.value,
"label": self.label,
"price_kopeks": self.price_per_month,
"price_label": self.price_label,
"is_available": self.is_available,
}
if self.original_price_per_month is not None and (
self.original_price_label and self.original_price_per_month != self.price_per_month
):
payload["original_price_kopeks"] = self.original_price_per_month
payload["original_price_label"] = self.original_price_label
if self.discount_percent:
payload["discount_percent"] = self.discount_percent
if self.is_default:
payload["is_default"] = True
return payload
@dataclass
class PurchaseTrafficConfig:
selectable: bool
mode: str
options: List[PurchaseTrafficOption] = field(default_factory=list)
default_value: Optional[int] = None
current_value: Optional[int] = None
hint: Optional[str] = None
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"selectable": self.selectable,
"mode": self.mode,
}
if self.options:
payload["options"] = [option.to_payload() for option in self.options]
if self.default_value is not None:
payload["default"] = self.default_value
if self.current_value is not None:
payload["current"] = self.current_value
if self.hint:
payload["hint"] = self.hint
return payload
@dataclass
class PurchaseServerOption:
uuid: str
name: str
price_per_month: int
price_label: str
original_price_per_month: Optional[int] = None
original_price_label: Optional[str] = None
discount_percent: int = 0
is_available: bool = True
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"uuid": self.uuid,
"name": self.name,
"price_kopeks": self.price_per_month,
"price_label": self.price_label,
"is_available": self.is_available,
}
if self.original_price_per_month is not None and (
self.original_price_label and self.original_price_per_month != self.price_per_month
):
payload["original_price_kopeks"] = self.original_price_per_month
payload["original_price_label"] = self.original_price_label
if self.discount_percent:
payload["discount_percent"] = self.discount_percent
return payload
@dataclass
class PurchaseServersConfig:
options: List[PurchaseServerOption]
min_selectable: int
max_selectable: int
default_selection: List[str]
hint: Optional[str] = None
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"options": [option.to_payload() for option in self.options],
"min": self.min_selectable,
"max": self.max_selectable,
"default": list(self.default_selection),
"selected": list(self.default_selection),
}
if self.hint:
payload["hint"] = self.hint
return payload
@dataclass
class PurchaseDevicesConfig:
minimum: int
maximum: int
default: int
current: int
price_per_device: int
discounted_price_per_device: int
price_label: str
original_price_label: Optional[str] = None
discount_percent: int = 0
hint: Optional[str] = None
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"min": self.minimum,
"max": self.maximum,
"default": self.default,
"current": self.current,
"price_per_device_kopeks": self.discounted_price_per_device,
"price_per_device_label": self.price_label,
}
if self.price_per_device and self.price_per_device != self.discounted_price_per_device:
payload["price_per_device_original_kopeks"] = self.price_per_device
if self.original_price_label:
payload["price_per_device_original_label"] = self.original_price_label
if self.discount_percent:
payload["discount_percent"] = self.discount_percent
if self.hint:
payload["hint"] = self.hint
return payload
@dataclass
class PurchasePeriodConfig:
id: str
days: int
months: int
label: str
base_price: int
base_price_label: str
base_price_original: int
base_price_original_label: Optional[str]
discount_percent: int
per_month_price: int
per_month_price_label: str
traffic: PurchaseTrafficConfig
servers: PurchaseServersConfig
devices: PurchaseDevicesConfig
def to_payload(self) -> Dict[str, Any]:
payload: Dict[str, Any] = {
"id": self.id,
"code": self.id,
"period_id": self.id,
"period_days": self.days,
"period": self.days,
"months": self.months,
"label": self.label,
"price_kopeks": self.base_price,
"price_label": self.base_price_label,
"per_month_price_kopeks": self.per_month_price,
"per_month_price_label": self.per_month_price_label,
"is_available": True,
"traffic": self.traffic.to_payload(),
"servers": self.servers.to_payload(),
"devices": self.devices.to_payload(),
}
if self.discount_percent:
payload["discount_percent"] = self.discount_percent
if (
self.base_price_original
and self.base_price_original_label
and self.base_price_original != self.base_price
):
payload["original_price_kopeks"] = self.base_price_original
payload["original_price_label"] = self.base_price_original_label
return payload
@dataclass
class PurchaseSelection:
period: PurchasePeriodConfig
traffic_value: int
servers: List[str]
devices: int
@dataclass
class PurchasePricingResult:
selection: PurchaseSelection
server_ids: List[int]
server_prices_for_period: List[int]
base_original_total: int
discounted_total: int
promo_discount_value: int
promo_discount_percent: int
final_total: int
months: int
details: Dict[str, Any]
@dataclass
class PurchaseOptionsContext:
user: User
subscription: Optional[Subscription]
currency: str
balance_kopeks: int
periods: List[PurchasePeriodConfig]
default_period: PurchasePeriodConfig
period_map: Dict[str, PurchasePeriodConfig]
server_uuid_to_id: Dict[str, int]
payload: Dict[str, Any]
class PurchaseValidationError(Exception):
def __init__(self, message: str, code: str = "invalid_selection") -> None:
super().__init__(message)
self.code = code
class PurchaseBalanceError(Exception):
def __init__(self, message: str) -> None:
super().__init__(message)
def _apply_percentage_discount(amount: int, percent: int) -> Tuple[int, int]:
if amount <= 0 or percent <= 0:
return amount, 0
clamped = max(0, min(100, percent))
discount_value = amount * clamped // 100
discounted = amount - discount_value
if discount_value >= 100 and discounted % 100:
discounted += 100 - (discounted % 100)
discounted = min(discounted, amount)
discount_value = amount - discounted
return discounted, discount_value
def _apply_discount_to_monthly_component(amount_per_month: int, percent: int, months: int) -> Dict[str, int]:
discounted_per_month, discount_per_month = _apply_percentage_discount(amount_per_month, percent)
return {
"original_per_month": amount_per_month,
"discounted_per_month": discounted_per_month,
"discount_percent": max(0, min(100, percent)),
"discount_per_month": discount_per_month,
"total": discounted_per_month * months,
"discount_total": discount_per_month * months,
}
def _get_promo_offer_discount_percent(user: Optional[User]) -> int:
return get_user_active_promo_discount_percent(user)
def _apply_promo_offer_discount(user: Optional[User], amount: int) -> Tuple[int, int, int]:
percent = _get_promo_offer_discount_percent(user)
if amount <= 0 or percent <= 0:
return amount, 0, 0
discounted, discount_value = _apply_percentage_discount(amount, percent)
return discounted, discount_value, percent
def _build_server_option(
server: ServerSquad,
discount_percent: int,
texts,
) -> PurchaseServerOption:
base_per_month = int(getattr(server, "price_kopeks", 0) or 0)
discounted_per_month, _ = _apply_percentage_discount(base_per_month, discount_percent)
return PurchaseServerOption(
uuid=server.squad_uuid,
name=getattr(server, "display_name", server.squad_uuid) or server.squad_uuid,
price_per_month=discounted_per_month,
price_label=texts.format_price(discounted_per_month),
original_price_per_month=base_per_month,
original_price_label=texts.format_price(base_per_month) if base_per_month != discounted_per_month else None,
discount_percent=max(0, discount_percent),
is_available=bool(getattr(server, "is_available", True) and not getattr(server, "is_full", False)),
)
class MiniAppSubscriptionPurchaseService:
"""Builds configuration and pricing for subscription purchases in the mini app."""
async def build_options(self, db: AsyncSession, user: User) -> PurchaseOptionsContext:
from app.database.crud.subscription import get_subscription_by_user_id
subscription = await get_subscription_by_user_id(db, user.id)
balance_kopeks = int(getattr(user, "balance_kopeks", 0) or 0)
currency = (getattr(user, "balance_currency", None) or "RUB").upper()
texts = get_texts(getattr(user, "language", None))
available_servers = await get_available_server_squads(
db,
promo_group_id=getattr(user, "promo_group_id", None),
)
server_catalog: Dict[str, ServerSquad] = {server.squad_uuid: server for server in available_servers}
if subscription and subscription.connected_squads:
for uuid in subscription.connected_squads:
if uuid in server_catalog:
continue
try:
existing = await get_server_squad_by_uuid(db, uuid)
except Exception as error: # pragma: no cover - defensive logging
logger.warning("Failed to load server squad %s: %s", uuid, error)
existing = None
if existing:
server_catalog[uuid] = existing
server_uuid_to_id: Dict[str, int] = {}
for server in server_catalog.values():
try:
server_uuid_to_id[server.squad_uuid] = int(getattr(server, "id", 0) or 0)
except (TypeError, ValueError):
continue
default_connected = list(getattr(subscription, "connected_squads", []) or [])
if not default_connected:
for server in available_servers:
if getattr(server, "is_available", True) and not getattr(server, "is_full", False):
default_connected = [server.squad_uuid]
break
available_periods: Sequence[int] = settings.get_available_subscription_periods()
periods: List[PurchasePeriodConfig] = []
period_map: Dict[str, PurchasePeriodConfig] = {}
default_devices = settings.DEFAULT_DEVICE_LIMIT
if subscription and getattr(subscription, "device_limit", None):
default_devices = max(default_devices, int(subscription.device_limit))
fixed_traffic_value = None
if settings.is_traffic_fixed():
fixed_traffic_value = settings.get_fixed_traffic_limit()
elif subscription and subscription.traffic_limit_gb is not None:
fixed_traffic_value = subscription.traffic_limit_gb
default_period_days = available_periods[0] if available_periods else 30
for period_days in available_periods:
months = calculate_months_from_days(period_days)
period_id = f"days:{period_days}"
label = format_period_description(period_days, getattr(user, "language", "ru"))
base_price_original = PERIOD_PRICES.get(period_days, 0)
period_discount_percent = user.get_promo_discount("period", period_days)
base_price, base_discount_total = _apply_percentage_discount(
base_price_original, period_discount_percent
)
base_price_label = texts.format_price(base_price)
base_price_original_label = (
texts.format_price(base_price_original)
if base_discount_total and base_price_original != base_price
else None
)
per_month_price = base_price // months if months else base_price
per_month_price_label = texts.format_price(per_month_price)
traffic_config = self._build_traffic_config(
user,
texts,
period_days,
months,
fixed_traffic_value,
)
servers_config = self._build_servers_config(
user,
texts,
period_days,
server_catalog,
default_connected,
)
devices_config = self._build_devices_config(
user,
texts,
period_days,
default_devices,
)
period_config = PurchasePeriodConfig(
id=period_id,
days=period_days,
months=months,
label=label,
base_price=base_price,
base_price_label=base_price_label,
base_price_original=base_price_original,
base_price_original_label=base_price_original_label,
discount_percent=max(0, period_discount_percent),
per_month_price=per_month_price,
per_month_price_label=per_month_price_label,
traffic=traffic_config,
servers=servers_config,
devices=devices_config,
)
periods.append(period_config)
period_map[period_id] = period_config
if not periods:
raise PurchaseValidationError("No subscription periods configured", code="configuration")
default_period = period_map.get(f"days:{default_period_days}") or periods[0]
default_selection = {
"period_id": default_period.id,
"periodId": default_period.id,
"period_days": default_period.days,
"periodDays": default_period.days,
"traffic_value": default_period.traffic.current_value
if default_period.traffic.current_value is not None
else default_period.traffic.default_value,
"trafficValue": default_period.traffic.current_value
if default_period.traffic.current_value is not None
else default_period.traffic.default_value,
"servers": list(default_period.servers.default_selection),
"countries": list(default_period.servers.default_selection),
"server_uuids": list(default_period.servers.default_selection),
"serverUuids": list(default_period.servers.default_selection),
"devices": default_period.devices.current,
"device_limit": default_period.devices.current,
"deviceLimit": default_period.devices.current,
}
payload = {
"currency": currency,
"balance_kopeks": balance_kopeks,
"balanceKopeks": balance_kopeks,
"balance_label": texts.format_price(balance_kopeks),
"balanceLabel": texts.format_price(balance_kopeks),
"subscription_id": getattr(subscription, "id", None),
"subscriptionId": getattr(subscription, "id", None),
"periods": [period.to_payload() for period in periods],
"traffic": default_period.traffic.to_payload(),
"servers": default_period.servers.to_payload(),
"devices": default_period.devices.to_payload(),
"selection": default_selection,
"summary": None,
}
return PurchaseOptionsContext(
user=user,
subscription=subscription,
currency=currency,
balance_kopeks=balance_kopeks,
periods=periods,
default_period=default_period,
period_map=period_map,
server_uuid_to_id=server_uuid_to_id,
payload=payload,
)
def _build_traffic_config(
self,
user: User,
texts,
period_days: int,
months: int,
fixed_traffic_value: Optional[int],
) -> PurchaseTrafficConfig:
if settings.is_traffic_fixed():
value = fixed_traffic_value if fixed_traffic_value is not None else settings.get_fixed_traffic_limit()
return PurchaseTrafficConfig(
selectable=False,
mode="fixed",
options=[],
default_value=value,
current_value=value,
hint=None,
)
packages = [package for package in settings.get_traffic_packages() if package.get("enabled", True)]
discount_percent = user.get_promo_discount("traffic", period_days)
options: List[PurchaseTrafficOption] = []
for package in packages:
value = int(package.get("gb") or 0)
price_per_month = int(package.get("price") or 0)
discounted_per_month, discount_value = _apply_percentage_discount(price_per_month, discount_percent)
label = texts.format_traffic(value if value else 0)
options.append(
PurchaseTrafficOption(
value=value,
label=label,
price_per_month=discounted_per_month,
price_label=texts.format_price(discounted_per_month),
original_price_per_month=price_per_month,
original_price_label=texts.format_price(price_per_month)
if discount_value and price_per_month != discounted_per_month
else None,
discount_percent=max(0, discount_percent),
is_available=True,
)
)
default_option = None
if fixed_traffic_value is not None:
for option in options:
if option.value == fixed_traffic_value:
default_option = option
option.is_default = True
break
if default_option is None and options:
options[0].is_default = True
default_option = options[0]
default_value = default_option.value if default_option else (fixed_traffic_value or 0)
return PurchaseTrafficConfig(
selectable=True,
mode="selectable",
options=options,
default_value=default_value,
current_value=default_value,
hint=None,
)
def _build_servers_config(
self,
user: User,
texts,
period_days: int,
server_catalog: Dict[str, ServerSquad],
default_selection: List[str],
) -> PurchaseServersConfig:
discount_percent = user.get_promo_discount("servers", period_days)
options: List[PurchaseServerOption] = []
for uuid, server in server_catalog.items():
option = _build_server_option(server, discount_percent, texts)
options.append(option)
if not options:
default_selection = []
return PurchaseServersConfig(
options=options,
min_selectable=1 if options else 0,
max_selectable=len(options),
default_selection=default_selection if default_selection else [opt.uuid for opt in options[:1]],
hint=None,
)
def _build_devices_config(
self,
user: User,
texts,
period_days: int,
default_devices: int,
) -> PurchaseDevicesConfig:
discount_percent = user.get_promo_discount("devices", period_days)
unit_price = settings.PRICE_PER_DEVICE
discounted_unit_price, unit_discount_value = _apply_percentage_discount(unit_price, discount_percent)
price_label = texts.format_price(discounted_unit_price)
original_label = (
texts.format_price(unit_price)
if unit_discount_value and unit_price != discounted_unit_price
else None
)
max_devices_setting = settings.MAX_DEVICES_LIMIT if settings.MAX_DEVICES_LIMIT > 0 else None
if max_devices_setting is not None:
maximum = max(max_devices_setting, default_devices)
else:
maximum = max(default_devices, settings.DEFAULT_DEVICE_LIMIT) + 10
return PurchaseDevicesConfig(
minimum=1,
maximum=maximum,
default=default_devices,
current=default_devices,
price_per_device=unit_price,
discounted_price_per_device=discounted_unit_price,
price_label=price_label,
original_price_label=original_label,
discount_percent=max(0, discount_percent),
hint=None,
)
def parse_selection(
self,
context: PurchaseOptionsContext,
selection_payload: Dict[str, Any],
) -> PurchaseSelection:
period_id = (
selection_payload.get("period_id")
or selection_payload.get("periodId")
or selection_payload.get("period")
or selection_payload.get("code")
)
if not period_id:
period_days = selection_payload.get("period_days") or selection_payload.get("periodDays")
if period_days is not None:
period_id = f"days:{int(period_days)}"
if not period_id or period_id not in context.period_map:
raise PurchaseValidationError("Invalid or missing subscription period", code="invalid_period")
period = context.period_map[period_id]
traffic_value = (
selection_payload.get("traffic_value")
or selection_payload.get("trafficValue")
or selection_payload.get("traffic")
or selection_payload.get("traffic_gb")
or selection_payload.get("trafficGb")
)
if period.traffic.selectable:
available_values = {option.value for option in period.traffic.options}
if traffic_value is None:
traffic_value = period.traffic.current_value or period.traffic.default_value
else:
traffic_value = int(traffic_value)
if available_values and traffic_value not in available_values:
raise PurchaseValidationError("Selected traffic option is not available", code="invalid_traffic")
else:
traffic_value = period.traffic.current_value or period.traffic.default_value or 0
raw_servers: List[str] = []
for key in ("servers", "countries", "server_uuids", "serverUuids"):
value = selection_payload.get(key)
if isinstance(value, list):
raw_servers.extend(value)
servers: List[str] = []
seen = set()
for raw in raw_servers:
if not raw:
continue
uuid = str(raw).strip()
if not uuid or uuid in seen:
continue
seen.add(uuid)
servers.append(uuid)
if not servers:
servers = list(period.servers.default_selection)
if period.servers.min_selectable and len(servers) < period.servers.min_selectable:
raise PurchaseValidationError("Select at least one server", code="invalid_servers")
if period.servers.max_selectable and len(servers) > period.servers.max_selectable:
servers = servers[: period.servers.max_selectable]
devices = (
selection_payload.get("devices")
or selection_payload.get("device_limit")
or selection_payload.get("deviceLimit")
or period.devices.current
or period.devices.default
)
try:
devices = int(devices)
except (TypeError, ValueError):
raise PurchaseValidationError("Invalid devices selection", code="invalid_devices")
if devices < period.devices.minimum:
devices = period.devices.minimum
if period.devices.maximum and devices > period.devices.maximum:
devices = period.devices.maximum
return PurchaseSelection(
period=period,
traffic_value=int(traffic_value or 0),
servers=servers,
devices=devices,
)
async def calculate_pricing(
self,
db: AsyncSession,
context: PurchaseOptionsContext,
selection: PurchaseSelection,
) -> PurchasePricingResult:
texts = get_texts(getattr(context.user, "language", None))
months = selection.period.months
server_ids = await get_server_ids_by_uuids(db, selection.servers)
if len(server_ids) != len(selection.servers):
raise PurchaseValidationError("Some selected servers are not available", code="invalid_servers")
total_without_promo, details = await self._calculate_base_total(
db,
context.user,
selection,
server_ids,
)
base_original_total = (
details["base_price_original"]
+ details["traffic_price_per_month"] * months
+ details["servers_price_per_month"] * months
+ details["devices_price_per_month"] * months
)
final_total, promo_discount_value, promo_percent = _apply_promo_offer_discount(
context.user, total_without_promo
)
discounted_total = total_without_promo
is_valid = validate_pricing_calculation(
details.get("base_price", 0),
(
details.get("traffic_price_per_month", 0)
- details.get("traffic_discount_total", 0) // max(1, months)
)
+ (
details.get("servers_price_per_month", 0)
- details.get("servers_discount_total", 0) // max(1, months)
)
+ (
details.get("devices_price_per_month", 0)
- details.get("devices_discount_total", 0) // max(1, months)
),
months,
discounted_total,
)
if not is_valid:
raise PurchaseValidationError("Failed to validate pricing", code="calculation_error")
return PurchasePricingResult(
selection=selection,
server_ids=server_ids,
server_prices_for_period=list(details.get("servers_individual_prices", [])),
base_original_total=base_original_total,
discounted_total=discounted_total,
promo_discount_value=promo_discount_value,
promo_discount_percent=promo_percent,
final_total=final_total,
months=months,
details=details,
)
async def _calculate_base_total(
self,
db: AsyncSession,
user: User,
selection: PurchaseSelection,
server_ids: List[int],
) -> Tuple[int, Dict[str, Any]]:
from app.database.crud.subscription import calculate_subscription_total_cost
total_cost, details = await calculate_subscription_total_cost(
db,
selection.period.days,
selection.traffic_value,
server_ids,
selection.devices,
user=user,
)
return total_cost, details
def build_preview_payload(
self,
context: PurchaseOptionsContext,
pricing: PurchasePricingResult,
) -> Dict[str, Any]:
texts = get_texts(getattr(context.user, "language", None))
details = pricing.details
total_discount = pricing.base_original_total - pricing.final_total
overall_discount_percent = 0
if pricing.base_original_total > 0 and total_discount > 0:
overall_discount_percent = int(round(total_discount * 100 / pricing.base_original_total))
discount_lines: List[str] = []
def build_discount_line(key: str, default: str, amount: int, percent: int) -> Optional[str]:
if not amount:
return None
return texts.t(key, default).format(
amount=texts.format_price(amount),
percent=percent,
)
def build_discount_note(amount: int, percent: int) -> Optional[str]:
if not amount:
return None
return texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE",
"Discount: -{amount} ({percent}%)",
).format(
amount=texts.format_price(amount),
percent=percent,
)
base_discount_line = build_discount_line(
"MINIAPP_PURCHASE_DISCOUNT_PERIOD",
"Period discount: -{amount} ({percent}%)",
details.get("base_discount_total", 0),
details.get("base_discount_percent", 0),
)
if base_discount_line:
discount_lines.append(base_discount_line)
traffic_discount_line = build_discount_line(
"MINIAPP_PURCHASE_DISCOUNT_TRAFFIC",
"Traffic discount: -{amount} ({percent}%)",
details.get("traffic_discount_total", 0),
details.get("traffic_discount_percent", 0),
)
if traffic_discount_line:
discount_lines.append(traffic_discount_line)
servers_discount_line = build_discount_line(
"MINIAPP_PURCHASE_DISCOUNT_SERVERS",
"Servers discount: -{amount} ({percent}%)",
details.get("servers_discount_total", 0),
details.get("servers_discount_percent", 0),
)
if servers_discount_line:
discount_lines.append(servers_discount_line)
devices_discount_line = build_discount_line(
"MINIAPP_PURCHASE_DISCOUNT_DEVICES",
"Devices discount: -{amount} ({percent}%)",
details.get("devices_discount_total", 0),
details.get("devices_discount_percent", 0),
)
if devices_discount_line:
discount_lines.append(devices_discount_line)
promo_discount_line = None
if pricing.promo_discount_value:
promo_discount_line = texts.t(
"MINIAPP_PURCHASE_DISCOUNT_PROMO",
"Promo offer: -{amount} ({percent}%)",
).format(
amount=texts.format_price(pricing.promo_discount_value),
percent=pricing.promo_discount_percent,
)
discount_lines.append(promo_discount_line)
breakdown = [
{
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_BASE",
"Base plan",
),
"value": texts.format_price(details.get("base_price", 0)),
}
]
base_discount_note = build_discount_note(
details.get("base_discount_total", 0),
details.get("base_discount_percent", 0),
)
if base_discount_note:
breakdown[0]["discount_label"] = base_discount_note
breakdown[0]["discountLabel"] = base_discount_note
if details.get("total_traffic_price"):
traffic_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC",
"Traffic",
),
"value": texts.format_price(details["total_traffic_price"]),
}
traffic_discount_note = build_discount_note(
details.get("traffic_discount_total", 0),
details.get("traffic_discount_percent", 0),
)
if traffic_discount_note:
traffic_item["discount_label"] = traffic_discount_note
traffic_item["discountLabel"] = traffic_discount_note
breakdown.append(traffic_item)
if details.get("total_servers_price"):
servers_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_SERVERS",
"Servers",
),
"value": texts.format_price(details["total_servers_price"]),
}
servers_discount_note = build_discount_note(
details.get("servers_discount_total", 0),
details.get("servers_discount_percent", 0),
)
if servers_discount_note:
servers_item["discount_label"] = servers_discount_note
servers_item["discountLabel"] = servers_discount_note
breakdown.append(servers_item)
if details.get("total_devices_price"):
devices_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_DEVICES",
"Devices",
),
"value": texts.format_price(details["total_devices_price"]),
}
devices_discount_note = build_discount_note(
details.get("devices_discount_total", 0),
details.get("devices_discount_percent", 0),
)
if devices_discount_note:
devices_item["discount_label"] = devices_discount_note
devices_item["discountLabel"] = devices_discount_note
breakdown.append(devices_item)
if pricing.promo_discount_value:
promo_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_PROMO",
"Promo discount",
),
"value": f"- {texts.format_price(pricing.promo_discount_value)}",
}
if promo_discount_line:
promo_item["discount_label"] = promo_discount_line
promo_item["discountLabel"] = promo_discount_line
breakdown.append(promo_item)
missing = max(0, pricing.final_total - context.balance_kopeks)
status_message = ""
if missing > 0:
status_message = texts.t(
"MINIAPP_PURCHASE_STATUS_INSUFFICIENT",
"Not enough funds on balance",
)
per_month_price = pricing.final_total // pricing.months if pricing.months else pricing.final_total
return {
"total_price_kopeks": pricing.final_total,
"totalPriceKopeks": pricing.final_total,
"total_price_label": texts.format_price(pricing.final_total),
"totalPriceLabel": texts.format_price(pricing.final_total),
"original_price_kopeks": pricing.base_original_total if total_discount else None,
"originalPriceKopeks": pricing.base_original_total if total_discount else None,
"original_price_label": texts.format_price(pricing.base_original_total)
if total_discount
else None,
"originalPriceLabel": texts.format_price(pricing.base_original_total)
if total_discount
else None,
"discount_percent": overall_discount_percent,
"discountPercent": overall_discount_percent,
"discount_label": texts.t(
"MINIAPP_PURCHASE_SUMMARY_DISCOUNT",
"You save {amount}",
).format(amount=texts.format_price(total_discount))
if total_discount
else None,
"discountLabel": texts.t(
"MINIAPP_PURCHASE_SUMMARY_DISCOUNT",
"You save {amount}",
).format(amount=texts.format_price(total_discount))
if total_discount
else None,
"discount_lines": discount_lines,
"discountLines": discount_lines,
"per_month_price_kopeks": per_month_price,
"perMonthPriceKopeks": per_month_price,
"per_month_price_label": texts.format_price(per_month_price),
"perMonthPriceLabel": texts.format_price(per_month_price),
"breakdown": [
{"label": item["label"], "value": item["value"]}
for item in breakdown
],
"balance_kopeks": context.balance_kopeks,
"balanceKopeks": context.balance_kopeks,
"balance_label": texts.format_price(context.balance_kopeks),
"balanceLabel": texts.format_price(context.balance_kopeks),
"missing_amount_kopeks": missing,
"missingAmountKopeks": missing,
"missing_amount_label": texts.format_price(missing) if missing else None,
"missingAmountLabel": texts.format_price(missing) if missing else None,
"can_purchase": missing == 0,
"canPurchase": missing == 0,
"status_message": status_message,
"statusMessage": status_message,
}
async def submit_purchase(
self,
db: AsyncSession,
context: PurchaseOptionsContext,
pricing: PurchasePricingResult,
) -> Dict[str, Any]:
user = context.user
texts = get_texts(getattr(user, "language", None))
if pricing.final_total <= 0:
raise PurchaseValidationError("Invalid total amount", code="calculation_error")
if user.balance_kopeks < pricing.final_total:
raise PurchaseBalanceError(
texts.t(
"MINIAPP_PURCHASE_STATUS_INSUFFICIENT",
"Not enough funds on balance",
)
)
description = f"Покупка подписки на {pricing.selection.period.days} дней"
success = await subtract_user_balance(
db,
user,
pricing.final_total,
description,
consume_promo_offer=pricing.promo_discount_value > 0,
)
if not success:
raise PurchaseBalanceError(
texts.t(
"MINIAPP_PURCHASE_STATUS_INSUFFICIENT",
"Not enough funds on balance",
)
)
await db.refresh(user)
subscription = context.subscription
if subscription is not None and getattr(subscription, "id", None):
try:
await db.refresh(subscription)
except Exception as refresh_error: # pragma: no cover - defensive logging
logger.warning(
"Failed to refresh existing subscription %s: %s",
getattr(subscription, "id", None),
refresh_error,
)
else:
result = await db.execute(
select(Subscription).where(Subscription.user_id == user.id)
)
subscription = result.scalar_one_or_none()
if subscription is not None:
context.subscription = subscription
was_trial_conversion = False
now = datetime.utcnow()
if subscription:
bonus_period = timedelta()
if subscription.is_trial:
was_trial_conversion = True
trial_duration = (now - subscription.start_date).days
if settings.TRIAL_ADD_REMAINING_DAYS_TO_PAID and subscription.end_date:
remaining = subscription.end_date - now
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=pricing.final_total,
first_paid_period_days=pricing.selection.period.days,
)
except Exception as conversion_error: # pragma: no cover - defensive logging
logger.error("Failed to create subscription conversion record: %s", conversion_error)
subscription.is_trial = False
subscription.status = SubscriptionStatus.ACTIVE.value
subscription.traffic_limit_gb = pricing.selection.traffic_value
subscription.device_limit = pricing.selection.devices
subscription.connected_squads = pricing.selection.servers
subscription.start_date = now
subscription.end_date = now + timedelta(days=pricing.selection.period.days) + bonus_period
subscription.updated_at = now
subscription.traffic_used_gb = 0.0
await db.commit()
await db.refresh(subscription)
else:
subscription = await create_paid_subscription(
db=db,
user_id=user.id,
duration_days=pricing.selection.period.days,
traffic_limit_gb=pricing.selection.traffic_value,
device_limit=pricing.selection.devices,
connected_squads=pricing.selection.servers,
update_server_counters=False,
)
await mark_user_as_had_paid_subscription(db, user)
if pricing.server_ids:
try:
await add_subscription_servers(
db,
subscription,
pricing.server_ids,
pricing.server_prices_for_period,
)
await add_user_to_servers(db, pricing.server_ids)
except Exception as error: # pragma: no cover - defensive logging
logger.error("Failed to register subscription servers: %s", error)
subscription_service = SubscriptionService()
try:
if getattr(user, "remnawave_uuid", None):
await subscription_service.update_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="miniapp purchase",
)
else:
await subscription_service.create_remnawave_user(
db,
subscription,
reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT,
reset_reason="miniapp purchase",
)
except Exception as remnawave_error: # pragma: no cover - defensive logging
logger.error("Failed to sync subscription with RemnaWave: %s", remnawave_error)
transaction = await create_transaction(
db=db,
user_id=user.id,
type=TransactionType.SUBSCRIPTION_PAYMENT,
amount_kopeks=pricing.final_total,
description=f"Подписка на {pricing.selection.period.days} дней ({pricing.months} мес)",
)
await db.refresh(user)
await db.refresh(subscription)
message = texts.t(
"SUBSCRIPTION_PURCHASED",
"🎉 Subscription purchased successfully!",
)
if pricing.promo_discount_value:
note = texts.t(
"SUBSCRIPTION_PROMO_DISCOUNT_NOTE",
"⚡ Extra discount {percent}%: -{amount}",
).format(
percent=pricing.promo_discount_percent,
amount=texts.format_price(pricing.promo_discount_value),
)
message = f"{message}\n\n{note}"
return {
"subscription": subscription,
"transaction": transaction,
"was_trial_conversion": was_trial_conversion,
"message": message,
}
class SubscriptionPurchaseService:
"""Service for handling simple subscription purchases with predefined parameters."""
async def create_subscription_order(
self,
db: AsyncSession,
user_id: int,
period_days: int,
device_limit: int,
traffic_limit_gb: int,
squad_uuid: str,
payment_method: str,
total_price_kopeks: int
):
"""Creates a subscription order with predefined parameters."""
from app.database.crud.subscription import create_pending_subscription
from app.database.models import SubscriptionStatus
# Create a pending subscription
subscription = await create_pending_subscription(
db=db,
user_id=user_id,
duration_days=period_days,
traffic_limit_gb=traffic_limit_gb,
device_limit=device_limit,
connected_squads=[squad_uuid] if squad_uuid else [],
payment_method=payment_method,
total_price_kopeks=total_price_kopeks
)
return subscription
purchase_service = MiniAppSubscriptionPurchaseService()