Fix promo offer consumption and mobile purchase button

This commit is contained in:
Egor
2025-10-10 20:56:23 +03:00
parent c57d273c7e
commit e48a3f178e
6 changed files with 2423 additions and 114 deletions

View File

@@ -814,60 +814,71 @@ class MiniAppSubscriptionPurchaseService:
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),
)
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("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),
)
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("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),
)
)
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("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),
)
)
traffic_discount_line = build_discount_line(
"MINIAPP_PURCHASE_DISCOUNT_TRAFFIC",
"Traffic discount: -{amount} ({percent}%)",
details.get("traffic_discount_total", 0),
details.get("traffic_discount_percent", 0),
)
if traffic_discount_line:
discount_lines.append(traffic_discount_line)
servers_discount_line = build_discount_line(
"MINIAPP_PURCHASE_DISCOUNT_SERVERS",
"Servers discount: -{amount} ({percent}%)",
details.get("servers_discount_total", 0),
details.get("servers_discount_percent", 0),
)
if servers_discount_line:
discount_lines.append(servers_discount_line)
devices_discount_line = build_discount_line(
"MINIAPP_PURCHASE_DISCOUNT_DEVICES",
"Devices discount: -{amount} ({percent}%)",
details.get("devices_discount_total", 0),
details.get("devices_discount_percent", 0),
)
if devices_discount_line:
discount_lines.append(devices_discount_line)
promo_discount_line = None
if pricing.promo_discount_value:
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,
)
promo_discount_line = texts.t(
"MINIAPP_PURCHASE_DISCOUNT_PROMO",
"Promo offer: -{amount} ({percent}%)",
).format(
amount=texts.format_price(pricing.promo_discount_value),
percent=pricing.promo_discount_percent,
)
discount_lines.append(promo_discount_line)
breakdown = [
{
@@ -879,49 +890,77 @@ 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"):
breakdown.append(
{
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC",
"Traffic",
),
"value": texts.format_price(details["total_traffic_price"]),
}
traffic_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_TRAFFIC",
"Traffic",
),
"value": texts.format_price(details["total_traffic_price"]),
}
traffic_discount_note = build_discount_note(
details.get("traffic_discount_total", 0),
details.get("traffic_discount_percent", 0),
)
if traffic_discount_note:
traffic_item["discount_label"] = traffic_discount_note
traffic_item["discountLabel"] = traffic_discount_note
breakdown.append(traffic_item)
if details.get("total_servers_price"):
breakdown.append(
{
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_SERVERS",
"Servers",
),
"value": texts.format_price(details["total_servers_price"]),
}
servers_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_SERVERS",
"Servers",
),
"value": texts.format_price(details["total_servers_price"]),
}
servers_discount_note = build_discount_note(
details.get("servers_discount_total", 0),
details.get("servers_discount_percent", 0),
)
if servers_discount_note:
servers_item["discount_label"] = servers_discount_note
servers_item["discountLabel"] = servers_discount_note
breakdown.append(servers_item)
if details.get("total_devices_price"):
breakdown.append(
{
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_DEVICES",
"Devices",
),
"value": texts.format_price(details["total_devices_price"]),
}
devices_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_DEVICES",
"Devices",
),
"value": texts.format_price(details["total_devices_price"]),
}
devices_discount_note = build_discount_note(
details.get("devices_discount_total", 0),
details.get("devices_discount_percent", 0),
)
if devices_discount_note:
devices_item["discount_label"] = devices_discount_note
devices_item["discountLabel"] = devices_discount_note
breakdown.append(devices_item)
if pricing.promo_discount_value:
breakdown.append(
{
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_PROMO",
"Promo discount",
),
"value": f"- {texts.format_price(pricing.promo_discount_value)}",
}
)
promo_item = {
"label": texts.t(
"MINIAPP_PURCHASE_BREAKDOWN_PROMO",
"Promo discount",
),
"value": f"- {texts.format_price(pricing.promo_discount_value)}",
}
if promo_discount_line:
promo_item["discount_label"] = promo_discount_line
promo_item["discountLabel"] = promo_discount_line
breakdown.append(promo_item)
missing = max(0, pricing.final_total - context.balance_kopeks)
status_message = ""
@@ -948,6 +987,18 @@ 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,

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

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

View File

@@ -396,11 +396,13 @@
"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📋 <b>Final configuration</b>\n\n📅 <b>Period:</b> {period} days\n📈 <b>Traffic:</b> {traffic}\n🌍 <b>Countries:</b> {countries}\n📱 <b>Devices:</b> {devices}\n\n💰 <b>Total:</b> {total_price}\n\nConfirm the purchase?\n",
"SUBSCRIPTION_TRIAL": "🧪 Trial subscription",

View File

@@ -369,11 +369,13 @@
"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📋 <b>Итоговая конфигурация</b>\n\n📅 <b>Период:</b> {period} дней\n📈 <b>Трафик:</b> {traffic}\n🌍 <b>Страны:</b> {countries}\n📱 <b>Устройства:</b> {devices}\n\n💰 <b>Итого к оплате:</b> {total_price}\n\nПодтвердить покупку?\n",

File diff suppressed because it is too large Load Diff