From 1a4c8eff190668aae4ed5202e89b86ae02ed703e Mon Sep 17 00:00:00 2001 From: Egor Date: Fri, 10 Oct 2025 12:36:39 +0300 Subject: [PATCH] Fix renewal price display when no discount --- app/webapi/routes/miniapp.py | 502 ++++++++++- app/webapi/schemas/miniapp.py | 62 ++ miniapp/index.html | 1586 +++++++++++++++++++++++++++++++++ 3 files changed, 2147 insertions(+), 3 deletions(-) diff --git a/app/webapi/routes/miniapp.py b/app/webapi/routes/miniapp.py index 36df0d25..9af43d8a 100644 --- a/app/webapi/routes/miniapp.py +++ b/app/webapi/routes/miniapp.py @@ -25,12 +25,18 @@ from app.database.crud.promo_group import get_auto_assign_promo_groups from app.database.crud.rules import get_rules_by_language from app.database.crud.promo_offer_template import get_promo_offer_template_by_id from app.database.crud.server_squad import ( - get_available_server_squads, - get_server_squad_by_uuid, add_user_to_servers, + get_available_server_squads, + get_server_ids_by_uuids, + get_server_squad_by_uuid, remove_user_from_servers, ) -from app.database.crud.subscription import add_subscription_servers, remove_subscription_servers +from app.database.crud.subscription import ( + add_subscription_servers, + calculate_subscription_total_cost, + extend_subscription, + remove_subscription_servers, +) from app.database.crud.transaction import ( create_transaction, get_user_total_spent_kopeks, @@ -75,9 +81,13 @@ 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 ( @@ -137,6 +147,11 @@ from ..schemas.miniapp import ( MiniAppSubscriptionPurchasePreviewResponse, MiniAppSubscriptionPurchaseRequest, MiniAppSubscriptionPurchaseResponse, + MiniAppSubscriptionRenewalOptionsRequest, + MiniAppSubscriptionRenewalOptionsResponse, + MiniAppSubscriptionRenewalPeriod, + MiniAppSubscriptionRenewalRequest, + MiniAppSubscriptionRenewalResponse, ) @@ -177,6 +192,9 @@ _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() @@ -2753,6 +2771,231 @@ 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, @@ -3144,6 +3387,259 @@ 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} дней" + + if final_total > 0: + 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 c0a72461..243a2fae 100644 --- a/app/webapi/schemas/miniapp.py +++ b/app/webapi/schemas/miniapp.py @@ -134,6 +134,68 @@ 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/miniapp/index.html b/miniapp/index.html index 2da7b218..dbace41f 100644 --- a/miniapp/index.html +++ b/miniapp/index.html @@ -952,6 +952,267 @@ 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; @@ -3749,6 +4010,67 @@ + + +