Merge pull request #1107 from Fr1ngg/ppvgbo-bedolaga/add-subscription-renewal-section

Refine subscription purchase presentation
This commit is contained in:
Egor
2025-10-10 20:02:18 +03:00
committed by GitHub
3 changed files with 2272 additions and 26 deletions

View File

@@ -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,

View File

@@ -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

File diff suppressed because it is too large Load Diff