diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py deleted file mode 100644 index 4b6c9787..00000000 --- a/app/services/subscription_purchase_service.py +++ /dev/null @@ -1,1129 +0,0 @@ -import logging -from dataclasses import dataclass, field -from datetime import datetime, timedelta -from typing import Any, Dict, List, Optional, Sequence, Tuple - -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: - subscription = getattr(user, "subscription", None) - 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(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) - additional = max(0, default_devices - settings.DEFAULT_DEVICE_LIMIT) - base_price_per_month = additional * settings.PRICE_PER_DEVICE - discounted_per_month, discount_value = _apply_percentage_discount(base_price_per_month, discount_percent) - price_label = texts.format_price(discounted_per_month) - original_label = ( - texts.format_price(base_price_per_month) - if discount_value and base_price_per_month != discounted_per_month - 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=base_price_per_month, - discounted_price_per_device=discounted_per_month, - 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] = [] - - if details.get("base_discount_total"): - discount_lines.append( - texts.t( - "MINIAPP_PURCHASE_DISCOUNT_PERIOD", - "Period discount: -{amount} ({percent}%)", - ).format( - amount=texts.format_price(details["base_discount_total"]), - percent=details.get("base_discount_percent", 0), - ) - ) - - if details.get("traffic_discount_total"): - discount_lines.append( - texts.t( - "MINIAPP_PURCHASE_DISCOUNT_TRAFFIC", - "Traffic discount: -{amount} ({percent}%)", - ).format( - amount=texts.format_price(details["traffic_discount_total"]), - percent=details.get("traffic_discount_percent", 0), - ) - ) - - if details.get("servers_discount_total"): - discount_lines.append( - texts.t( - "MINIAPP_PURCHASE_DISCOUNT_SERVERS", - "Servers discount: -{amount} ({percent}%)", - ).format( - amount=texts.format_price(details["servers_discount_total"]), - percent=details.get("servers_discount_percent", 0), - ) - ) - - if details.get("devices_discount_total"): - discount_lines.append( - texts.t( - "MINIAPP_PURCHASE_DISCOUNT_DEVICES", - "Devices discount: -{amount} ({percent}%)", - ).format( - amount=texts.format_price(details["devices_discount_total"]), - percent=details.get("devices_discount_percent", 0), - ) - ) - - if pricing.promo_discount_value: - discount_lines.append( - texts.t( - "MINIAPP_PURCHASE_DISCOUNT_PROMO", - "Promo offer: -{amount} ({percent}%)", - ).format( - amount=texts.format_price(pricing.promo_discount_value), - percent=pricing.promo_discount_percent, - ) - ) - - breakdown = [ - { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_BASE", - "Base plan", - ), - "value": texts.format_price(details.get("base_price", 0)), - } - ] - - if details.get("total_traffic_price"): - breakdown.append( - { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC", - "Traffic", - ), - "value": texts.format_price(details["total_traffic_price"]), - } - ) - - if details.get("total_servers_price"): - breakdown.append( - { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_SERVERS", - "Servers", - ), - "value": texts.format_price(details["total_servers_price"]), - } - ) - - if details.get("total_devices_price"): - breakdown.append( - { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_DEVICES", - "Devices", - ), - "value": texts.format_price(details["total_devices_price"]), - } - ) - - if pricing.promo_discount_value: - breakdown.append( - { - "label": texts.t( - "MINIAPP_PURCHASE_BREAKDOWN_PROMO", - "Promo discount", - ), - "value": f"- {texts.format_price(pricing.promo_discount_value)}", - } - ) - - 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_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 = getattr(user, "subscription", None) - 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, - } - - -purchase_service = MiniAppSubscriptionPurchaseService() - diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 36df0d25..2fd18b30 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -57,11 +57,6 @@ 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.subscription_purchase_service import ( - purchase_service, - PurchaseBalanceError, - PurchaseValidationError, -) 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 @@ -131,12 +126,6 @@ from ..schemas.miniapp import ( MiniAppSubscriptionTrafficUpdateRequest, MiniAppSubscriptionDevicesUpdateRequest, MiniAppSubscriptionUpdateResponse, - MiniAppSubscriptionPurchaseOptionsRequest, - MiniAppSubscriptionPurchaseOptionsResponse, - MiniAppSubscriptionPurchasePreviewRequest, - MiniAppSubscriptionPurchasePreviewResponse, - MiniAppSubscriptionPurchaseRequest, - MiniAppSubscriptionPurchaseResponse, ) @@ -243,45 +232,6 @@ def _build_balance_invoice_payload(user_id: int, amount_kopeks: int) -> str: return f"balance_{user_id}_{amount_kopeks}_{suffix}" -def _merge_purchase_selection_from_request( - payload: Union[ - "MiniAppSubscriptionPurchasePreviewRequest", - "MiniAppSubscriptionPurchaseRequest", - ] -) -> Dict[str, Any]: - base: Dict[str, Any] = {} - if payload.selection: - base.update(payload.selection) - - def _maybe_set(key: str, value: Any) -> None: - if value is None: - return - if key not in base: - base[key] = value - - _maybe_set("period_id", getattr(payload, "period_id", None)) - _maybe_set("period_days", getattr(payload, "period_days", None)) - - _maybe_set("traffic_value", getattr(payload, "traffic_value", None)) - _maybe_set("traffic", getattr(payload, "traffic", None)) - _maybe_set("traffic_gb", getattr(payload, "traffic_gb", None)) - - servers = getattr(payload, "servers", None) - if servers is not None and "servers" not in base: - base["servers"] = servers - countries = getattr(payload, "countries", None) - if countries is not None and "countries" not in base: - base["countries"] = countries - server_uuids = getattr(payload, "server_uuids", None) - if server_uuids is not None and "server_uuids" not in base: - base["server_uuids"] = server_uuids - - _maybe_set("devices", getattr(payload, "devices", None)) - _maybe_set("device_limit", getattr(payload, "device_limit", None)) - - return base - - def _parse_client_timestamp(value: Optional[Union[str, int, float]]) -> Optional[datetime]: if value is None: return None @@ -3144,113 +3094,6 @@ async def _build_subscription_settings( 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) - context = await purchase_service.build_options(db, user) - - data_payload = dict(context.payload) - data_payload.setdefault("currency", context.currency) - data_payload.setdefault("balance_kopeks", context.balance_kopeks) - data_payload.setdefault("balanceKopeks", context.balance_kopeks) - data_payload.setdefault("balance_label", settings.format_price(context.balance_kopeks)) - data_payload.setdefault("balanceLabel", settings.format_price(context.balance_kopeks)) - - return MiniAppSubscriptionPurchaseOptionsResponse( - currency=context.currency, - balance_kopeks=context.balance_kopeks, - balance_label=settings.format_price(context.balance_kopeks), - subscription_id=data_payload.get("subscription_id") or data_payload.get("subscriptionId"), - data=data_payload, - ) - - -@router.post( - "/subscription/purchase/preview", - response_model=MiniAppSubscriptionPurchasePreviewResponse, -) -async def subscription_purchase_preview_endpoint( - payload: MiniAppSubscriptionPurchasePreviewRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionPurchasePreviewResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - context = await purchase_service.build_options(db, user) - - selection_payload = _merge_purchase_selection_from_request(payload) - try: - selection = purchase_service.parse_selection(context, selection_payload) - except PurchaseValidationError as error: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": error.code, "message": str(error)}, - ) from error - - pricing = await purchase_service.calculate_pricing(db, context, selection) - preview_payload = purchase_service.build_preview_payload(context, pricing) - - balance_label = settings.format_price(getattr(user, "balance_kopeks", 0)) - - return MiniAppSubscriptionPurchasePreviewResponse( - preview=preview_payload, - balance_kopeks=user.balance_kopeks, - balance_label=balance_label, - ) - - -@router.post( - "/subscription/purchase", - response_model=MiniAppSubscriptionPurchaseResponse, -) -async def subscription_purchase_endpoint( - payload: MiniAppSubscriptionPurchaseRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionPurchaseResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - context = await purchase_service.build_options(db, user) - - selection_payload = _merge_purchase_selection_from_request(payload) - try: - selection = purchase_service.parse_selection(context, selection_payload) - except PurchaseValidationError as error: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": error.code, "message": str(error)}, - ) from error - - pricing = await purchase_service.calculate_pricing(db, context, selection) - - try: - result = await purchase_service.submit_purchase(db, context, pricing) - except PurchaseBalanceError as error: - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={"code": "insufficient_funds", "message": str(error)}, - ) from error - except PurchaseValidationError as error: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": error.code, "message": str(error)}, - ) from error - - await db.refresh(user) - - subscription = result.get("subscription") - balance_label = settings.format_price(getattr(user, "balance_kopeks", 0)) - - return MiniAppSubscriptionPurchaseResponse( - message=result.get("message"), - balance_kopeks=user.balance_kopeks, - balance_label=balance_label, - subscription_id=getattr(subscription, "id", None), - ) - - @router.post( "/subscription/settings", response_model=MiniAppSubscriptionSettingsResponse, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index c0a72461..f461f372 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -524,88 +524,3 @@ class MiniAppSubscriptionUpdateResponse(BaseModel): success: bool = True message: Optional[str] = None - -class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel): - success: bool = True - currency: str - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - data: Dict[str, Any] = Field(default_factory=dict) - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionPurchasePreviewRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - selection: Optional[Dict[str, Any]] = None - period_id: Optional[str] = Field(default=None, alias="periodId") - period_days: Optional[int] = Field(default=None, alias="periodDays") - period: Optional[str] = None - traffic_value: Optional[int] = Field(default=None, alias="trafficValue") - traffic: Optional[int] = None - traffic_gb: Optional[int] = Field(default=None, alias="trafficGb") - servers: Optional[List[str]] = None - countries: Optional[List[str]] = None - server_uuids: Optional[List[str]] = Field(default=None, alias="serverUuids") - devices: Optional[int] = None - device_limit: Optional[int] = Field(default=None, alias="deviceLimit") - - model_config = ConfigDict(populate_by_name=True) - - @model_validator(mode="before") - @classmethod - def _merge_selection(cls, values: Any) -> Any: - if not isinstance(values, dict): - return values - selection = values.get("selection") - if isinstance(selection, dict): - merged = {**selection, **values} - else: - merged = dict(values) - aliases = { - "period_id": ("periodId", "period", "code"), - "period_days": ("periodDays",), - "traffic_value": ("trafficValue", "traffic", "trafficGb"), - "servers": ("countries", "server_uuids", "serverUuids"), - "devices": ("deviceLimit",), - } - for target, sources in aliases.items(): - if merged.get(target) is not None: - continue - for source in sources: - if source in merged and merged[source] is not None: - merged[target] = merged[source] - break - return merged - - -class MiniAppSubscriptionPurchasePreviewResponse(BaseModel): - success: bool = True - preview: Dict[str, Any] = Field(default_factory=dict) - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionPurchaseRequest(MiniAppSubscriptionPurchasePreviewRequest): - pass - - -class MiniAppSubscriptionPurchaseResponse(BaseModel): - success: bool = True - message: Optional[str] = None - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - - model_config = ConfigDict(populate_by_name=True) - diff --git a/locales/en.json b/locales/en.json index b5d40004..217035a6 100644 --- a/locales/en.json +++ b/locales/en.json @@ -391,17 +391,6 @@ "SUBSCRIPTION_NONE": "❌ No active subscription", "SUBSCRIPTION_NOT_FOUND": "❌ Subscription not found", "SUBSCRIPTION_PURCHASED": "🎉 Subscription purchased successfully!", - "MINIAPP_PURCHASE_DISCOUNT_PERIOD": "Period discount: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_TRAFFIC": "Traffic discount: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_SERVERS": "Servers discount: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_DEVICES": "Devices discount: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_PROMO": "Promo offer: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_BREAKDOWN_BASE": "Base plan", - "MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC": "Traffic", - "MINIAPP_PURCHASE_BREAKDOWN_SERVERS": "Servers", - "MINIAPP_PURCHASE_BREAKDOWN_DEVICES": "Devices", - "MINIAPP_PURCHASE_BREAKDOWN_PROMO": "Promo discount", - "MINIAPP_PURCHASE_STATUS_INSUFFICIENT": "Not enough funds on balance", "SUBSCRIPTION_SUMMARY": "\n📋 Final configuration\n\n📅 Period: {period} days\n📈 Traffic: {traffic}\n🌍 Countries: {countries}\n📱 Devices: {devices}\n\n💰 Total: {total_price}\n\nConfirm the purchase?\n", "SUBSCRIPTION_TRIAL": "🧪 Trial subscription", "SUPPORT_INFO": "\n🛠️ Technical support\n\nFor any questions contact our support:\n\n👤 {settings.SUPPORT_USERNAME}\n\nWe can help with:\n• Connection setup\n• Troubleshooting issues\n• Payment questions\n• Other requests\n\n⏰ Response time: usually within 1-2 hours\n", diff --git a/locales/ru.json b/locales/ru.json index d12c3c7d..2348dd4b 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -364,17 +364,6 @@ "SUBSCRIPTION_NONE": "❌ Нет активной подписки", "SUBSCRIPTION_NOT_FOUND": "❌ Подписка не найдена", "SUBSCRIPTION_PURCHASED": "🎉 Подписка успешно приобретена!", - "MINIAPP_PURCHASE_DISCOUNT_PERIOD": "Скидка на период: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_TRAFFIC": "Скидка на трафик: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_SERVERS": "Скидка на серверы: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_DEVICES": "Скидка на устройства: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_DISCOUNT_PROMO": "Промо-предложение: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_BREAKDOWN_BASE": "Базовый план", - "MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC": "Трафик", - "MINIAPP_PURCHASE_BREAKDOWN_SERVERS": "Серверы", - "MINIAPP_PURCHASE_BREAKDOWN_DEVICES": "Устройства", - "MINIAPP_PURCHASE_BREAKDOWN_PROMO": "Промо скидка", - "MINIAPP_PURCHASE_STATUS_INSUFFICIENT": "Недостаточно средств на балансе", "SUBSCRIPTION_SETTINGS_BUTTON": "⚙️ Настройки подписки", "SUBSCRIPTION_SUMMARY": "\n📋 Итоговая конфигурация\n\n📅 Период: {period} дней\n📈 Трафик: {traffic}\n🌍 Страны: {countries}\n📱 Устройства: {devices}\n\n💰 Итого к оплате: {total_price}\n\nПодтвердить покупку?\n", "SUBSCRIPTION_TRIAL": "🧪 Тестовая подписка", diff --git a/miniapp/index.html b/miniapp/index.html index c7216315..a19febf0 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -885,12 +885,6 @@ font-weight: 600; } - .subscription-purchase-price-per-month { - font-size: 12px; - color: var(--text-secondary); - font-weight: 600; - } - .subscription-purchase-breakdown { display: flex; flex-direction: column; @@ -3733,7 +3727,6 @@
-
@@ -11828,11 +11821,6 @@ currency, ); - const discountPercent = coercePositiveInt( - period.discount_percent ?? period.discountPercent, - null, - ); - let perMonthLabel = ''; const perMonthInfo = resolvePurchasePrice( [ @@ -11897,13 +11885,6 @@ meta.appendChild(perMonthEl); } - if (discountPercent) { - const discountEl = document.createElement('span'); - discountEl.className = 'subscription-purchase-price-discount'; - discountEl.textContent = `-${discountPercent}%`; - meta.appendChild(discountEl); - } - if (meta.childNodes.length) { labelContainer.appendChild(meta); } @@ -12031,11 +12012,6 @@ currency, ); - const discountPercent = coercePositiveInt( - option.discount_percent ?? option.discountPercent, - null, - ); - if (priceInfo.label || originalInfo.label) { const meta = document.createElement('div'); meta.className = 'subscription-settings-toggle-meta'; @@ -12058,13 +12034,6 @@ } meta.appendChild(priceWrapper); - - if (discountPercent) { - const discountEl = document.createElement('span'); - discountEl.className = 'subscription-purchase-price-discount'; - discountEl.textContent = `-${discountPercent}%`; - meta.appendChild(discountEl); - } labelContainer.appendChild(meta); } @@ -12317,43 +12286,7 @@ ], currency, ); - const originalInfo = resolvePurchasePrice( - [ - config.price_per_device_original_kopeks, - config.pricePerDeviceOriginalKopeks, - config.price_per_device_base_kopeks, - config.pricePerDeviceBaseKopeks, - ], - [ - config.price_per_device_original_label, - config.pricePerDeviceOriginalLabel, - config.price_per_device_base_label, - config.pricePerDeviceBaseLabel, - ], - currency, - ); - const discountPercent = coercePositiveInt( - config.discount_percent ?? config.discountPercent, - null, - ); - - const fragments = []; - if (originalInfo.label && originalInfo.label !== priceInfo.label) { - fragments.push( - `${escapeHtml(originalInfo.label)}` - ); - } - if (priceInfo.label) { - fragments.push( - `${escapeHtml(priceInfo.label)}` - ); - } - if (discountPercent) { - fragments.push( - `-${discountPercent}%` - ); - } - priceElement.innerHTML = fragments.join(' ') || ''; + priceElement.textContent = priceInfo.label || ''; } if (hintElement) { @@ -12368,7 +12301,6 @@ const priceCurrent = document.getElementById('subscriptionPurchasePriceCurrent'); const priceOriginal = document.getElementById('subscriptionPurchasePriceOriginal'); const discountElement = document.getElementById('subscriptionPurchaseDiscount'); - const pricePerMonth = document.getElementById('subscriptionPurchasePricePerMonth'); const breakdownContainer = document.getElementById('subscriptionPurchaseBreakdown'); const balanceWarning = document.getElementById('subscriptionPurchaseBalanceWarning'); const submitButton = document.getElementById('subscriptionPurchaseSubmit'); @@ -12392,17 +12324,6 @@ priceOriginal.classList.toggle('hidden', !showOriginal); } - if (pricePerMonth) { - const showPerMonth = !loading && Boolean(preview?.perMonthLabel); - if (showPerMonth) { - pricePerMonth.textContent = t('subscription_purchase.summary.per_month') - .replace('{amount}', preview.perMonthLabel || '—'); - } else { - pricePerMonth.textContent = '—'; - } - pricePerMonth.classList.toggle('hidden', !showPerMonth); - } - if (discountElement) { const lines = []; if (preview?.discountLabel) {