diff --git a/app/services/subscription_purchase_service.py b/app/services/subscription_purchase_service.py index c2d6593e..4b6c9787 100644 --- a/app/services/subscription_purchase_service.py +++ b/app/services/subscription_purchase_service.py @@ -814,71 +814,60 @@ class MiniAppSubscriptionPurchaseService: 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, + 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), + ) ) - 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, + 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), + ) ) - 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) + 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), + ) + ) - 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) + 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), + ) + ) - 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( + 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 = [ { @@ -890,77 +879,49 @@ class MiniAppSubscriptionPurchaseService: } ] - 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), + breakdown.append( + { + "label": texts.t( + "MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC", + "Traffic", + ), + "value": texts.format_price(details["total_traffic_price"]), + } ) - 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), + breakdown.append( + { + "label": texts.t( + "MINIAPP_PURCHASE_BREAKDOWN_SERVERS", + "Servers", + ), + "value": texts.format_price(details["total_servers_price"]), + } ) - 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), + breakdown.append( + { + "label": texts.t( + "MINIAPP_PURCHASE_BREAKDOWN_DEVICES", + "Devices", + ), + "value": texts.format_price(details["total_devices_price"]), + } ) - 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) + 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 = "" @@ -987,18 +948,6 @@ class MiniAppSubscriptionPurchaseService: 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, diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index f4e16667..36df0d25 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -25,18 +25,12 @@ 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 ( - add_user_to_servers, get_available_server_squads, - get_server_ids_by_uuids, get_server_squad_by_uuid, + add_user_to_servers, remove_user_from_servers, ) -from app.database.crud.subscription import ( - add_subscription_servers, - calculate_subscription_total_cost, - extend_subscription, - remove_subscription_servers, -) +from app.database.crud.subscription import add_subscription_servers, remove_subscription_servers from app.database.crud.transaction import ( create_transaction, get_user_total_spent_kopeks, @@ -81,13 +75,9 @@ from app.utils.user_utils import ( ) from app.utils.pricing_utils import ( apply_percentage_discount, - calculate_months_from_days, calculate_prorated_price, - format_period_description, get_remaining_months, - validate_pricing_calculation, ) -from app.utils.promo_offer import get_user_active_promo_discount_percent from ..dependencies import get_db_session from ..schemas.miniapp import ( @@ -147,11 +137,6 @@ from ..schemas.miniapp import ( MiniAppSubscriptionPurchasePreviewResponse, MiniAppSubscriptionPurchaseRequest, MiniAppSubscriptionPurchaseResponse, - MiniAppSubscriptionRenewalOptionsRequest, - MiniAppSubscriptionRenewalOptionsResponse, - MiniAppSubscriptionRenewalPeriod, - MiniAppSubscriptionRenewalRequest, - MiniAppSubscriptionRenewalResponse, ) @@ -192,9 +177,6 @@ _PAYMENT_FAILURE_STATUSES = { } -_PERIOD_ID_PATTERN = re.compile(r"(\d+)") - - async def _get_usd_to_rub_rate() -> float: try: rate = await currency_converter.get_usd_to_rub_rate() @@ -2771,231 +2753,6 @@ def _extract_promo_discounts(group: Optional[PromoGroup]) -> Dict[str, Any]: } -def _normalize_language_code(user: Optional[User]) -> str: - language = getattr(user, "language", None) or settings.DEFAULT_LANGUAGE or "ru" - return language.split("-")[0].lower() - - -def _build_renewal_status_message(user: Optional[User]) -> str: - language_code = _normalize_language_code(user) - if language_code == "ru": - return "Стоимость указана с учётом ваших текущих серверов, трафика и устройств." - return "Prices already include your current servers, traffic, and devices." - - -def _build_promo_offer_payload(user: Optional[User]) -> Optional[Dict[str, Any]]: - percent = get_user_active_promo_discount_percent(user) - if percent <= 0: - return None - - payload: Dict[str, Any] = {"percent": percent} - - expires_at = getattr(user, "promo_offer_discount_expires_at", None) - if expires_at: - payload["expires_at"] = expires_at - - language_code = _normalize_language_code(user) - if language_code == "ru": - payload["message"] = "Дополнительная скидка применяется автоматически." - else: - payload["message"] = "Extra discount is applied automatically." - - return payload - - -def _build_renewal_period_id(period_days: int) -> str: - return f"days:{period_days}" - - -def _parse_period_identifier(identifier: Optional[str]) -> Optional[int]: - if not identifier: - return None - - match = _PERIOD_ID_PATTERN.search(str(identifier)) - if not match: - return None - - try: - return int(match.group(1)) - except (TypeError, ValueError): - return None - - -async def _calculate_subscription_renewal_pricing( - db: AsyncSession, - user: User, - subscription: Subscription, - period_days: int, -) -> Dict[str, Any]: - connected_uuids = [str(uuid) for uuid in list(subscription.connected_squads or [])] - server_ids: List[int] = [] - if connected_uuids: - server_ids = await get_server_ids_by_uuids(db, connected_uuids) - - traffic_limit = subscription.traffic_limit_gb - if traffic_limit is None: - traffic_limit = settings.DEFAULT_TRAFFIC_LIMIT_GB - - devices_limit = subscription.device_limit or settings.DEFAULT_DEVICE_LIMIT - - total_cost, details = await calculate_subscription_total_cost( - db, - period_days, - int(traffic_limit or 0), - server_ids, - int(devices_limit or 0), - user=user, - ) - - months = details.get("months_in_period") or calculate_months_from_days(period_days) - - base_original_total = ( - details.get("base_price_original", 0) - + details.get("traffic_price_per_month", 0) * months - + details.get("servers_price_per_month", 0) * months - + details.get("devices_price_per_month", 0) * months - ) - - discounted_total = total_cost - - monthly_additions = 0 - if months > 0: - monthly_additions = ( - details.get("total_servers_price", 0) // months - + details.get("total_devices_price", 0) // months - + details.get("total_traffic_price", 0) // months - ) - - if not validate_pricing_calculation( - details.get("base_price", 0), - monthly_additions, - months, - discounted_total, - ): - logger.warning( - "Renewal pricing validation failed for subscription %s (period %s)", - subscription.id, - period_days, - ) - - promo_percent = get_user_active_promo_discount_percent(user) - final_total = discounted_total - promo_discount_value = 0 - if promo_percent > 0 and discounted_total > 0: - final_total, promo_discount_value = apply_percentage_discount( - discounted_total, - promo_percent, - ) - - overall_discount_value = max(0, base_original_total - final_total) - overall_discount_percent = 0 - if base_original_total > 0 and overall_discount_value > 0: - overall_discount_percent = int( - round(overall_discount_value * 100 / base_original_total) - ) - - per_month = final_total // months if months else final_total - - pricing_payload: Dict[str, Any] = { - "period_id": _build_renewal_period_id(period_days), - "period_days": period_days, - "months": months, - "base_original_total": base_original_total, - "discounted_total": discounted_total, - "final_total": final_total, - "promo_discount_value": promo_discount_value, - "promo_discount_percent": promo_percent if promo_discount_value else 0, - "overall_discount_percent": overall_discount_percent, - "per_month": per_month, - "server_ids": list(server_ids), - "details": details, - } - - return pricing_payload - - -async def _prepare_subscription_renewal_options( - db: AsyncSession, - user: User, - subscription: Subscription, -) -> Tuple[List[MiniAppSubscriptionRenewalPeriod], Dict[Union[str, int], Dict[str, Any]], Optional[str]]: - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] - - option_payloads: List[Tuple[MiniAppSubscriptionRenewalPeriod, Dict[str, Any]]] = [] - - for period_days in available_periods: - try: - pricing = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, - period_days, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", - subscription.id, - period_days, - error, - ) - continue - - label = format_period_description( - period_days, - getattr(user, "language", settings.DEFAULT_LANGUAGE), - ) - - price_label = settings.format_price(pricing["final_total"]) - original_label = None - if pricing["base_original_total"] and pricing["base_original_total"] != pricing["final_total"]: - original_label = settings.format_price(pricing["base_original_total"]) - - per_month_label = settings.format_price(pricing["per_month"]) - - option_model = MiniAppSubscriptionRenewalPeriod( - id=pricing["period_id"], - days=period_days, - months=pricing["months"], - price_kopeks=pricing["final_total"], - price_label=price_label, - original_price_kopeks=pricing["base_original_total"], - original_price_label=original_label, - discount_percent=pricing["overall_discount_percent"], - price_per_month_kopeks=pricing["per_month"], - price_per_month_label=per_month_label, - title=label, - ) - - option_payloads.append((option_model, pricing)) - - if not option_payloads: - return [], {}, None - - option_payloads.sort(key=lambda item: item[0].days or 0) - - recommended_option = max( - option_payloads, - key=lambda item: ( - item[1]["overall_discount_percent"], - item[0].months or 0, - -(item[1]["final_total"] or 0), - ), - ) - recommended_option[0].is_recommended = True - - pricing_map: Dict[Union[str, int], Dict[str, Any]] = {} - for option_model, pricing in option_payloads: - pricing_map[option_model.id] = pricing - pricing_map[pricing["period_days"]] = pricing - pricing_map[str(pricing["period_days"])] = pricing - - periods = [item[0] for item in option_payloads] - - return periods, pricing_map, recommended_option[0].id - - def _get_addon_discount_percent_for_user( user: Optional[User], category: str, @@ -3387,261 +3144,6 @@ async def _build_subscription_settings( return settings_payload -@router.post( - "/subscription/renewal/options", - response_model=MiniAppSubscriptionRenewalOptionsResponse, -) -async def get_subscription_renewal_options_endpoint( - payload: MiniAppSubscriptionRenewalOptionsRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionRenewalOptionsResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_paid_subscription(user) - _validate_subscription_id(payload.subscription_id, subscription) - - periods, pricing_map, default_period_id = await _prepare_subscription_renewal_options( - db, - user, - subscription, - ) - - balance_kopeks = getattr(user, "balance_kopeks", 0) - currency = (getattr(user, "balance_currency", None) or "RUB").upper() - - promo_group = getattr(user, "promo_group", None) - promo_group_model = ( - MiniAppPromoGroup( - id=promo_group.id, - name=promo_group.name, - **_extract_promo_discounts(promo_group), - ) - if promo_group - else None - ) - - promo_offer_payload = _build_promo_offer_payload(user) - - missing_amount = None - if default_period_id and default_period_id in pricing_map: - selected_pricing = pricing_map[default_period_id] - final_total = selected_pricing.get("final_total") - if isinstance(final_total, int) and balance_kopeks < final_total: - missing_amount = final_total - balance_kopeks - - return MiniAppSubscriptionRenewalOptionsResponse( - subscription_id=subscription.id, - currency=currency, - balance_kopeks=balance_kopeks, - balance_label=settings.format_price(balance_kopeks), - promo_group=promo_group_model, - promo_offer=promo_offer_payload, - periods=periods, - default_period_id=default_period_id, - missing_amount_kopeks=missing_amount, - status_message=_build_renewal_status_message(user), - ) - - -@router.post( - "/subscription/renewal", - response_model=MiniAppSubscriptionRenewalResponse, -) -async def submit_subscription_renewal_endpoint( - payload: MiniAppSubscriptionRenewalRequest, - db: AsyncSession = Depends(get_db_session), -) -> MiniAppSubscriptionRenewalResponse: - user = await _authorize_miniapp_user(payload.init_data, db) - subscription = _ensure_paid_subscription(user) - _validate_subscription_id(payload.subscription_id, subscription) - - period_days: Optional[int] = None - if payload.period_days is not None: - try: - period_days = int(payload.period_days) - except (TypeError, ValueError) as error: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_period", "message": "Invalid renewal period"}, - ) from error - - if period_days is None: - period_days = _parse_period_identifier(payload.period_id) - - if period_days is None or period_days <= 0: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "invalid_period", "message": "Invalid renewal period"}, - ) - - available_periods = [ - period for period in settings.get_available_renewal_periods() if period > 0 - ] - if period_days not in available_periods: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - detail={"code": "period_unavailable", "message": "Selected renewal period is not available"}, - ) - - try: - pricing = await _calculate_subscription_renewal_pricing( - db, - user, - subscription, - period_days, - ) - except HTTPException: - raise - except Exception as error: - logger.error( - "Failed to calculate renewal pricing for subscription %s (period %s): %s", - subscription.id, - period_days, - error, - ) - raise HTTPException( - status.HTTP_502_BAD_GATEWAY, - detail={"code": "pricing_failed", "message": "Failed to calculate renewal pricing"}, - ) from error - - final_total = int(pricing.get("final_total") or 0) - balance_kopeks = getattr(user, "balance_kopeks", 0) - - if final_total > 0 and balance_kopeks < final_total: - missing = final_total - balance_kopeks - raise HTTPException( - status.HTTP_402_PAYMENT_REQUIRED, - detail={ - "code": "insufficient_funds", - "message": "Not enough funds to renew the subscription", - "missing_amount_kopeks": missing, - }, - ) - - consume_promo_offer = bool(pricing.get("promo_discount_value")) - description = f"Продление подписки на {period_days} дней" - - should_charge_balance = final_total > 0 or consume_promo_offer - - if should_charge_balance: - success = await subtract_user_balance( - db, - user, - final_total, - description, - consume_promo_offer=consume_promo_offer, - ) - if not success: - raise HTTPException( - status.HTTP_500_INTERNAL_SERVER_ERROR, - detail={"code": "charge_failed", "message": "Failed to charge balance"}, - ) - await db.refresh(user) - - subscription = await extend_subscription(db, subscription, period_days) - - server_ids = pricing.get("server_ids") or [] - server_prices_for_period = pricing.get("details", {}).get( - "servers_individual_prices", - [], - ) - if server_ids: - try: - await add_subscription_servers( - db, - subscription, - server_ids, - server_prices_for_period, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - "Failed to record renewal server prices for subscription %s: %s", - subscription.id, - error, - ) - - subscription_service = SubscriptionService() - try: - await subscription_service.update_remnawave_user( - db, - subscription, - reset_traffic=settings.RESET_TRAFFIC_ON_PAYMENT, - reset_reason="subscription renewal", - ) - except RemnaWaveConfigurationError as error: # pragma: no cover - configuration issues - logger.warning("RemnaWave update skipped: %s", error) - except Exception as error: # pragma: no cover - defensive logging - logger.error( - "Failed to update RemnaWave user for subscription %s: %s", - subscription.id, - error, - ) - - try: - await create_transaction( - db=db, - user_id=user.id, - type=TransactionType.SUBSCRIPTION_PAYMENT, - amount_kopeks=final_total, - description=description, - ) - except Exception as error: # pragma: no cover - defensive logging - logger.warning( - "Failed to create renewal transaction for subscription %s: %s", - subscription.id, - error, - ) - - await db.refresh(user) - await db.refresh(subscription) - - language_code = _normalize_language_code(user) - amount_label = settings.format_price(final_total) - date_label = ( - subscription.end_date.strftime("%d.%m.%Y %H:%M") - if subscription.end_date - else "" - ) - - if language_code == "ru": - if final_total > 0: - message = ( - f"Подписка продлена до {date_label}. " if date_label else "Подписка продлена. " - ) + f"Списано {amount_label}." - else: - message = ( - f"Подписка продлена до {date_label}." - if date_label - else "Подписка успешно продлена." - ) - else: - if final_total > 0: - message = ( - f"Subscription renewed until {date_label}. " if date_label else "Subscription renewed. " - ) + f"Charged {amount_label}." - else: - message = ( - f"Subscription renewed until {date_label}." - if date_label - else "Subscription renewed successfully." - ) - - promo_discount_value = pricing.get("promo_discount_value") or 0 - if consume_promo_offer and promo_discount_value > 0: - discount_label = settings.format_price(promo_discount_value) - if language_code == "ru": - message += f" Применена дополнительная скидка {discount_label}." - else: - message += f" Promo discount applied: {discount_label}." - - return MiniAppSubscriptionRenewalResponse( - message=message, - balance_kopeks=user.balance_kopeks, - balance_label=settings.format_price(user.balance_kopeks), - subscription_id=subscription.id, - renewed_until=subscription.end_date, - ) - - @router.post( "/subscription/purchase/options", response_model=MiniAppSubscriptionPurchaseOptionsResponse, diff --git a/app/webapi/schemas/miniapp.py b/app/webapi/schemas/miniapp.py index 243a2fae..c0a72461 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -134,68 +134,6 @@ class MiniAppPromoOfferClaimResponse(BaseModel): code: Optional[str] = None -class MiniAppSubscriptionRenewalPeriod(BaseModel): - id: str - days: Optional[int] = None - months: Optional[int] = None - price_kopeks: Optional[int] = Field(default=None, alias="priceKopeks") - price_label: Optional[str] = Field(default=None, alias="priceLabel") - original_price_kopeks: Optional[int] = Field(default=None, alias="originalPriceKopeks") - original_price_label: Optional[str] = Field(default=None, alias="originalPriceLabel") - discount_percent: int = Field(default=0, alias="discountPercent") - price_per_month_kopeks: Optional[int] = Field(default=None, alias="pricePerMonthKopeks") - price_per_month_label: Optional[str] = Field(default=None, alias="pricePerMonthLabel") - is_recommended: bool = Field(default=False, alias="isRecommended") - description: Optional[str] = None - badge: Optional[str] = None - title: Optional[str] = None - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionRenewalOptionsRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionRenewalOptionsResponse(BaseModel): - success: bool = True - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - currency: str - balance_kopeks: Optional[int] = Field(default=None, alias="balanceKopeks") - balance_label: Optional[str] = Field(default=None, alias="balanceLabel") - promo_group: Optional[MiniAppPromoGroup] = Field(default=None, alias="promoGroup") - promo_offer: Optional[Dict[str, Any]] = Field(default=None, alias="promoOffer") - periods: List[MiniAppSubscriptionRenewalPeriod] = Field(default_factory=list) - default_period_id: Optional[str] = Field(default=None, alias="defaultPeriodId") - missing_amount_kopeks: Optional[int] = Field(default=None, alias="missingAmountKopeks") - status_message: Optional[str] = Field(default=None, alias="statusMessage") - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionRenewalRequest(BaseModel): - init_data: str = Field(..., alias="initData") - subscription_id: Optional[int] = Field(default=None, alias="subscriptionId") - period_id: Optional[str] = Field(default=None, alias="periodId") - period_days: Optional[int] = Field(default=None, alias="periodDays") - - model_config = ConfigDict(populate_by_name=True) - - -class MiniAppSubscriptionRenewalResponse(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") - renewed_until: Optional[datetime] = Field(default=None, alias="renewedUntil") - - model_config = ConfigDict(populate_by_name=True) - - class MiniAppPromoCode(BaseModel): code: str type: Optional[str] = None diff --git a/locales/en.json b/locales/en.json index e4b12469..b5d40004 100644 --- a/locales/en.json +++ b/locales/en.json @@ -396,13 +396,11 @@ "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_DISCOUNT_NOTE": "Discount: -{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_SUMMARY_DISCOUNT": "You save {amount}", "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", diff --git a/locales/ru.json b/locales/ru.json index 31d2704e..d12c3c7d 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -369,13 +369,11 @@ "MINIAPP_PURCHASE_DISCOUNT_SERVERS": "Скидка на серверы: -{amount} ({percent}%)", "MINIAPP_PURCHASE_DISCOUNT_DEVICES": "Скидка на устройства: -{amount} ({percent}%)", "MINIAPP_PURCHASE_DISCOUNT_PROMO": "Промо-предложение: -{amount} ({percent}%)", - "MINIAPP_PURCHASE_BREAKDOWN_DISCOUNT_NOTE": "Скидка: -{amount} ({percent}%)", "MINIAPP_PURCHASE_BREAKDOWN_BASE": "Базовый план", "MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC": "Трафик", "MINIAPP_PURCHASE_BREAKDOWN_SERVERS": "Серверы", "MINIAPP_PURCHASE_BREAKDOWN_DEVICES": "Устройства", "MINIAPP_PURCHASE_BREAKDOWN_PROMO": "Промо скидка", - "MINIAPP_PURCHASE_SUMMARY_DISCOUNT": "Экономия {amount}", "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", diff --git a/miniapp/index.html b/miniapp/index.html index ff967c52..2da7b218 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -808,10 +808,6 @@ } .subscription-purchase-option-price { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 2px; font-size: 12px; font-weight: 600; color: var(--text-secondary); @@ -839,48 +835,6 @@ padding: 6px 0; } - .subscription-purchase-card .subscription-settings-toggle { - align-items: flex-start; - gap: 16px; - } - - .subscription-purchase-option-aside { - margin-left: auto; - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 6px; - min-width: 120px; - } - - .subscription-purchase-option-note { - font-size: 12px; - color: var(--text-secondary); - } - - .subscription-purchase-option-discount { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 4px; - padding: 2px 8px; - border-radius: 999px; - background: rgba(var(--success-rgb), 0.12); - color: var(--success); - font-size: 12px; - font-weight: 600; - } - - .subscription-purchase-period-option .subscription-purchase-option-price-current { - font-size: 18px; - font-weight: 700; - color: var(--text-primary); - } - - .subscription-purchase-period-option .subscription-purchase-option-price-original { - font-size: 13px; - } - .subscription-purchase-summary { display: flex; flex-direction: column; @@ -946,12 +900,6 @@ } .subscription-purchase-breakdown-item { - display: flex; - flex-direction: column; - gap: 4px; - } - - .subscription-purchase-breakdown-row { display: flex; justify-content: space-between; gap: 12px; @@ -961,12 +909,6 @@ color: var(--text-primary); } - .subscription-purchase-breakdown-note { - font-size: 12px; - color: var(--success); - font-weight: 600; - } - .subscription-purchase-balance { padding: 12px; border-radius: var(--radius); @@ -981,18 +923,6 @@ gap: 10px; } - @media (max-width: 520px) { - .subscription-purchase-actions { - margin-bottom: 8px; - padding-bottom: 4px; - } - - .subscription-purchase-actions .btn { - width: 100%; - min-height: 48px; - } - } - .subscription-purchase-card .btn-secondary { justify-content: center; } @@ -1022,267 +952,6 @@ background: rgba(var(--danger-rgb), 0.16); } - .subscription-renewal-card .card-title { - display: flex; - align-items: center; - gap: 10px; - } - - .subscription-renewal-card .card-title svg { - width: 20px; - height: 20px; - opacity: 0.8; - } - - .subscription-renewal-summary-chip { - display: flex; - flex-wrap: wrap; - gap: 6px; - margin-left: auto; - font-size: 12px; - color: var(--text-secondary); - } - - .subscription-renewal-chip { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 999px; - background: var(--bg-secondary); - color: var(--text-secondary); - font-weight: 600; - } - - .subscription-renewal-content { - display: flex; - flex-direction: column; - gap: 16px; - } - - .subscription-renewal-body { - display: flex; - flex-direction: column; - gap: 16px; - } - - .subscription-renewal-meta { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 12px; - } - - .subscription-renewal-meta-block { - display: flex; - flex-direction: column; - gap: 6px; - padding: 12px; - border-radius: var(--radius-lg); - background: rgba(var(--primary-rgb), 0.06); - } - - .subscription-renewal-meta-title { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-secondary); - } - - .subscription-renewal-meta-body { - display: flex; - flex-wrap: wrap; - gap: 8px; - align-items: center; - } - - .subscription-renewal-meta-empty { - font-size: 12px; - color: var(--text-secondary); - } - - .subscription-renewal-offer { - display: flex; - flex-direction: column; - gap: 4px; - } - - .subscription-renewal-offer-badge { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 6px 12px; - border-radius: 999px; - background: rgba(var(--primary-rgb), 0.12); - color: var(--primary); - font-size: 12px; - font-weight: 700; - } - - .subscription-renewal-offer-note { - font-size: 12px; - color: var(--text-secondary); - } - - .subscription-renewal-options { - display: flex; - flex-direction: column; - gap: 12px; - } - - .subscription-renewal-section { - display: flex; - flex-direction: column; - gap: 12px; - } - - .subscription-renewal-section-title { - font-size: 14px; - font-weight: 700; - color: var(--text-primary); - } - - .subscription-renewal-option .subscription-settings-toggle-label { - gap: 8px; - } - - .subscription-renewal-option-badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 10px; - border-radius: 999px; - background: rgba(var(--primary-rgb), 0.16); - color: var(--primary); - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.04em; - font-weight: 700; - } - - .subscription-settings-toggle.active .subscription-renewal-option-badge { - background: var(--primary); - color: var(--tg-theme-button-text-color); - } - - .subscription-renewal-option-price { - display: flex; - align-items: baseline; - gap: 8px; - font-size: 14px; - font-weight: 700; - color: var(--text-primary); - } - - .subscription-renewal-option-price-current { - color: var(--primary); - } - - .subscription-renewal-option-price-original { - font-size: 12px; - text-decoration: line-through; - color: var(--text-secondary); - font-weight: 600; - } - - .subscription-renewal-option-discount { - font-size: 12px; - font-weight: 700; - color: var(--success); - } - - .subscription-renewal-option-note { - font-size: 12px; - color: var(--text-secondary); - margin-top: 2px; - } - - .subscription-renewal-option-description { - font-size: 12px; - color: var(--text-secondary); - margin-top: 6px; - } - - .subscription-renewal-summary { - display: flex; - flex-direction: column; - gap: 8px; - padding: 16px; - border-radius: var(--radius-lg); - background: var(--bg-secondary); - box-shadow: var(--shadow-sm); - } - - .subscription-renewal-summary-header { - display: flex; - justify-content: space-between; - align-items: baseline; - gap: 12px; - } - - .subscription-renewal-summary-prices { - display: flex; - align-items: baseline; - gap: 8px; - } - - .subscription-renewal-summary-label { - font-size: 12px; - font-weight: 700; - text-transform: uppercase; - letter-spacing: 0.04em; - color: var(--text-secondary); - } - - .subscription-renewal-price-current { - font-size: 22px; - font-weight: 800; - color: var(--text-primary); - } - - .subscription-renewal-price-original { - font-size: 14px; - text-decoration: line-through; - color: var(--text-secondary); - } - - .subscription-renewal-price-discount { - font-size: 13px; - font-weight: 600; - color: var(--success); - } - - .subscription-renewal-price-note { - font-size: 12px; - color: var(--text-secondary); - } - - .subscription-renewal-balance-warning { - font-size: 12px; - font-weight: 600; - color: var(--danger); - } - - .subscription-renewal-status-text { - font-size: 13px; - color: var(--text-secondary); - } - - .subscription-renewal-actions { - display: flex; - flex-direction: column; - gap: 10px; - } - - :root[data-theme="dark"] .subscription-renewal-meta-block { - background: rgba(var(--primary-rgb), 0.12); - } - - :root[data-theme="dark"] .subscription-renewal-summary { - background: rgba(15, 23, 42, 0.65); - } - .promo-offers { display: flex; flex-direction: column; @@ -4080,67 +3749,6 @@ - - -