Merge pull request #1064 from Fr1ngg/revert-1063-ny2u3l-bedolaga/expand-api-for-subscription-purchase

Revert "Improve miniapp subscription purchase configurator"
This commit is contained in:
Egor
2025-10-10 09:36:41 +03:00
committed by GitHub
3 changed files with 30 additions and 1586 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -360,13 +360,10 @@ class MiniAppSubscriptionServerOption(BaseModel):
name: Optional[str] = None
price_kopeks: Optional[int] = None
price_label: Optional[str] = None
original_price_kopeks: Optional[int] = None
original_price_label: Optional[str] = None
discount_percent: Optional[int] = None
is_connected: bool = False
is_available: bool = True
disabled_reason: Optional[str] = None
description: Optional[str] = None
class MiniAppSubscriptionTrafficOption(BaseModel):
@@ -374,11 +371,8 @@ class MiniAppSubscriptionTrafficOption(BaseModel):
label: Optional[str] = None
price_kopeks: Optional[int] = None
price_label: Optional[str] = None
original_price_kopeks: Optional[int] = None
original_price_label: Optional[str] = None
is_current: bool = False
is_available: bool = True
is_default: bool = False
description: Optional[str] = None
@@ -387,10 +381,6 @@ class MiniAppSubscriptionDeviceOption(BaseModel):
label: Optional[str] = None
price_kopeks: Optional[int] = None
price_label: Optional[str] = None
original_price_kopeks: Optional[int] = None
original_price_label: Optional[str] = None
included: Optional[int] = None
is_default: bool = False
class MiniAppSubscriptionCurrentSettings(BaseModel):
@@ -421,8 +411,6 @@ class MiniAppSubscriptionDevicesSettings(BaseModel):
max: int = 0
step: int = 1
current: int = 0
default: Optional[int] = None
included: Optional[int] = None
price_kopeks: Optional[int] = None
price_label: Optional[str] = None
@@ -536,188 +524,3 @@ class MiniAppSubscriptionUpdateResponse(BaseModel):
success: bool = True
message: Optional[str] = None
def _merge_purchase_selection(values: Any) -> Any:
if not isinstance(values, dict):
return values
alias_map = {
"periodId": "period_id",
"periodDays": "period_days",
"periodMonths": "period_months",
"trafficValue": "traffic_value",
"trafficGb": "traffic_gb",
"serverUuids": "server_uuids",
"squadUuids": "squad_uuids",
"deviceLimit": "device_limit",
}
selection = values.get("selection")
if isinstance(selection, dict):
for alias, target in alias_map.items():
if alias in selection and target not in values:
values[target] = selection[alias]
for key in ("servers", "server_uuids", "squads", "squad_uuids"):
if key in selection and key not in values:
values[key] = selection[key]
if "devices" in selection and "devices" not in values:
values["devices"] = selection["devices"]
if "traffic" in selection and "traffic" not in values:
values["traffic"] = selection["traffic"]
for alias, target in alias_map.items():
if alias in values and target not in values:
values[target] = values[alias]
return values
class MiniAppSubscriptionPurchasePeriod(BaseModel):
id: Optional[str] = None
code: Optional[str] = None
period_days: Optional[int] = Field(None, alias="periodDays")
period_months: Optional[int] = Field(None, alias="periodMonths")
months: Optional[int] = None
label: Optional[str] = None
description: Optional[str] = None
note: Optional[str] = None
price_kopeks: Optional[int] = None
price_label: Optional[str] = None
original_price_kopeks: Optional[int] = None
original_price_label: Optional[str] = None
per_month_price_kopeks: Optional[int] = None
per_month_price_label: Optional[str] = None
discount_percent: Optional[int] = None
is_available: bool = True
promo_badges: List[str] = Field(default_factory=list)
class MiniAppSubscriptionPurchaseTrafficConfig(BaseModel):
selectable: bool = True
mode: str = "selectable"
options: List[MiniAppSubscriptionTrafficOption] = Field(default_factory=list)
default: Optional[int] = None
current: Optional[int] = None
hint: Optional[str] = None
class MiniAppSubscriptionPurchaseServersConfig(BaseModel):
selectable: bool = True
min: int = 0
max: int = 0
options: List[MiniAppSubscriptionServerOption] = Field(default_factory=list)
default: List[str] = Field(default_factory=list)
selected: List[str] = Field(default_factory=list)
hint: Optional[str] = None
class MiniAppSubscriptionPurchaseDevicesConfig(BaseModel):
min: int = 1
max: Optional[int] = None
step: int = 1
default: Optional[int] = None
current: Optional[int] = None
included: Optional[int] = None
price_kopeks: Optional[int] = None
price_label: Optional[str] = None
original_price_kopeks: Optional[int] = None
original_price_label: Optional[str] = None
hint: Optional[str] = None
class MiniAppSubscriptionPurchaseOptions(BaseModel):
currency: str = "RUB"
balance_kopeks: Optional[int] = None
balance_label: Optional[str] = None
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
periods: List[MiniAppSubscriptionPurchasePeriod] = Field(default_factory=list)
traffic: MiniAppSubscriptionPurchaseTrafficConfig = Field(default_factory=MiniAppSubscriptionPurchaseTrafficConfig)
servers: MiniAppSubscriptionPurchaseServersConfig = Field(default_factory=MiniAppSubscriptionPurchaseServersConfig)
devices: MiniAppSubscriptionPurchaseDevicesConfig = Field(default_factory=MiniAppSubscriptionPurchaseDevicesConfig)
selection: Dict[str, Any] = Field(default_factory=dict)
promo: Optional[Dict[str, Any]] = None
summary: Optional[Dict[str, Any]] = None
class MiniAppSubscriptionPurchaseOptionsRequest(BaseModel):
init_data: str = Field(..., alias="initData")
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
model_config = ConfigDict(populate_by_name=True)
@model_validator(mode="before")
@classmethod
def _normalize(cls, values: Any) -> Any:
return _merge_purchase_selection(values)
class MiniAppSubscriptionPurchaseOptionsResponse(BaseModel):
success: bool = True
data: MiniAppSubscriptionPurchaseOptions
class MiniAppSubscriptionPurchasePreviewRequest(BaseModel):
init_data: str = Field(..., alias="initData")
subscription_id: Optional[int] = Field(None, alias="subscriptionId")
selection: Optional[Dict[str, Any]] = None
period_id: Optional[str] = Field(None, alias="periodId")
period_days: Optional[int] = Field(None, alias="periodDays")
period_months: Optional[int] = Field(None, alias="periodMonths")
traffic_value: Optional[int] = Field(None, alias="trafficValue")
traffic_gb: Optional[int] = Field(None, alias="trafficGb")
traffic: Optional[int] = None
servers: Optional[List[str]] = None
server_uuids: Optional[List[str]] = Field(None, alias="serverUuids")
squads: Optional[List[str]] = None
squad_uuids: Optional[List[str]] = Field(None, alias="squadUuids")
devices: Optional[int] = None
device_limit: Optional[int] = Field(None, alias="deviceLimit")
model_config = ConfigDict(populate_by_name=True)
@model_validator(mode="before")
@classmethod
def _normalize(cls, values: Any) -> Any:
return _merge_purchase_selection(values)
class MiniAppSubscriptionPurchasePreviewItem(BaseModel):
label: str
value: str
highlight: bool = False
class MiniAppSubscriptionPurchasePreview(BaseModel):
total_price_kopeks: Optional[int] = Field(None, alias="totalPriceKopeks")
total_price_label: Optional[str] = Field(None, alias="totalPriceLabel")
original_price_kopeks: Optional[int] = Field(None, alias="originalPriceKopeks")
original_price_label: Optional[str] = Field(None, alias="originalPriceLabel")
per_month_price_kopeks: Optional[int] = Field(None, alias="perMonthPriceKopeks")
per_month_price_label: Optional[str] = Field(None, alias="perMonthPriceLabel")
discount_percent: Optional[int] = None
discount_label: Optional[str] = None
discount_lines: List[str] = Field(default_factory=list)
breakdown: List[MiniAppSubscriptionPurchasePreviewItem] = Field(default_factory=list)
balance_kopeks: Optional[int] = None
balance_label: Optional[str] = None
missing_amount_kopeks: Optional[int] = None
missing_amount_label: Optional[str] = None
can_purchase: bool = True
status_message: Optional[str] = None
class MiniAppSubscriptionPurchasePreviewResponse(BaseModel):
success: bool = True
preview: MiniAppSubscriptionPurchasePreview
class MiniAppSubscriptionPurchaseSubmitRequest(MiniAppSubscriptionPurchasePreviewRequest):
pass
class MiniAppSubscriptionPurchaseSubmitResponse(BaseModel):
success: bool = True
message: Optional[str] = None
balance_kopeks: Optional[int] = None
balance_label: Optional[str] = None

View File

@@ -4347,9 +4347,6 @@
'subscription_purchase.servers.empty': 'No servers available',
'subscription_purchase.servers.single': 'Included server: {name}',
'subscription_purchase.servers.limit': 'Select up to {count}',
'subscription_purchase.servers.range': 'Select {min}{max} servers',
'subscription_purchase.servers.minimum': 'Select at least {count} servers',
'subscription_purchase.servers.exact': 'Select {count} servers',
'subscription_purchase.servers.selected': 'Selected: {count}',
'subscription_purchase.devices.title': 'Devices',
'subscription_purchase.devices.subtitle': 'Simultaneous connections.',
@@ -4676,9 +4673,6 @@
'subscription_purchase.servers.empty': 'Нет доступных серверов',
'subscription_purchase.servers.single': 'Включён сервер: {name}',
'subscription_purchase.servers.limit': 'Можно выбрать до {count}',
'subscription_purchase.servers.range': 'Выберите от {min} до {max} серверов',
'subscription_purchase.servers.minimum': 'Нужно выбрать минимум {count} сервер(ов)',
'subscription_purchase.servers.exact': 'Нужно выбрать {count} сервер(ов)',
'subscription_purchase.servers.selected': 'Выбрано: {count}',
'subscription_purchase.devices.title': 'Устройства',
'subscription_purchase.devices.subtitle': 'Одновременные подключения.',
@@ -5804,17 +5798,14 @@
setLanguage(event.target.value, { persist: true });
});
function createError(title, message, status, code) {
function createError(title, message, status) {
const error = new Error(message || title);
if (title) {
error.title = title;
}
if (status !== undefined && status !== null) {
if (status) {
error.status = status;
}
if (code !== undefined && code !== null) {
error.code = code;
}
return error;
}
@@ -11079,21 +11070,6 @@
return formatTrafficLimit(numeric);
}
function formatPurchaseServersLimit(key, replacements, fallback = '') {
let template = t(key);
if (!template || template === key) {
template = fallback || '';
}
if (!template) {
return '';
}
let formatted = template;
Object.entries(replacements || {}).forEach(([token, value]) => {
formatted = formatted.split(`{${token}}`).join(String(value));
});
return formatted;
}
function normalizePurchaseServerOption(option, currency) {
if (!option) {
return null;
@@ -11423,40 +11399,22 @@
}
}
function extractPurchaseError(payload, status) {
const defaultMessage = t('subscription_purchase.error.default');
function extractPurchaseErrorMessage(payload, status) {
if (!payload || typeof payload !== 'object') {
const message = status === 401
return status === 401
? t('subscription_settings.error.unauthorized')
: defaultMessage;
return { message, code: null };
: t('subscription_purchase.error.default');
}
let code = null;
let message = defaultMessage;
if (typeof payload.detail === 'string') {
message = payload.detail;
} else if (payload.detail && typeof payload.detail === 'object') {
if (typeof payload.detail.message === 'string') {
message = payload.detail.message;
}
if (payload.detail.code !== undefined && payload.detail.code !== null) {
code = payload.detail.code;
}
} else if (typeof payload.message === 'string') {
message = payload.message;
return payload.detail;
}
if ((message == null || String(message).trim() === '') && typeof payload.error === 'string') {
message = payload.error;
if (payload.detail && typeof payload.detail === 'object' && typeof payload.detail.message === 'string') {
return payload.detail.message;
}
if (code == null && typeof payload.code !== 'undefined') {
code = payload.code;
if (typeof payload.message === 'string') {
return payload.message;
}
return { message: message || defaultMessage, code };
return t('subscription_purchase.error.default');
}
function ensureSubscriptionPurchaseData(options = {}) {
@@ -11492,8 +11450,8 @@
}).then(async response => {
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const { message, code } = extractPurchaseError(body, response.status);
throw createError('Subscription purchase error', message, response.status, code);
const message = extractPurchaseErrorMessage(body, response.status);
throw createError('Subscription purchase error', message, response.status);
}
const normalized = normalizeSubscriptionPurchasePayload(body);
@@ -11546,18 +11504,7 @@
selection.code = idString;
}
let periodDays = resolvePurchasePeriodDays(period);
if (periodDays === null) {
const parsedDays = coercePositiveInt(
period?.period_days
?? period?.periodDays
?? periodId,
null,
);
if (parsedDays !== null) {
periodDays = parsedDays;
}
}
const periodDays = resolvePurchasePeriodDays(period);
if (periodDays !== null) {
selection.period_days = periodDays;
selection.periodDays = periodDays;
@@ -11565,26 +11512,21 @@
selection.durationDays = periodDays;
}
let periodMonths = coercePositiveInt(
const periodMonths = coercePositiveInt(
period?.months
?? period?.period
?? period?.period_months
?? period?.periodMonths,
null,
);
if (periodMonths === null && periodDays !== null) {
periodMonths = Math.max(1, Math.round(periodDays / 30));
}
if (periodMonths !== null) {
selection.months = periodMonths;
selection.period_months = periodMonths;
selection.periodMonths = periodMonths;
}
const trafficRaw = subscriptionPurchaseSelections.trafficValue;
if (trafficRaw !== null && trafficRaw !== undefined) {
const numericTraffic = coercePositiveInt(trafficRaw, null);
const trafficValue = numericTraffic !== null ? numericTraffic : trafficRaw;
const trafficValue = subscriptionPurchaseSelections.trafficValue;
if (trafficValue !== null && trafficValue !== undefined) {
selection.traffic_value = trafficValue;
selection.traffic = trafficValue;
selection.traffic_gb = trafficValue;
@@ -12250,30 +12192,10 @@
if (metaElement) {
let metaText = '';
if (maxSelectable && minSelectable && maxSelectable === minSelectable) {
metaText = formatPurchaseServersLimit(
'subscription_purchase.servers.exact',
{ count: String(maxSelectable) },
`Select ${maxSelectable} servers`
);
} else if (maxSelectable && minSelectable && maxSelectable > minSelectable) {
metaText = formatPurchaseServersLimit(
'subscription_purchase.servers.range',
{ min: String(minSelectable), max: String(maxSelectable) },
`Select ${minSelectable}${maxSelectable} servers`
);
} else if (maxSelectable) {
metaText = formatPurchaseServersLimit(
'subscription_purchase.servers.limit',
{ count: String(maxSelectable) },
`Select up to ${maxSelectable}`
);
if (maxSelectable && maxSelectable !== minSelectable) {
metaText = t('subscription_purchase.servers.limit').replace('{count}', String(maxSelectable));
} else if (selectedCount) {
metaText = formatPurchaseServersLimit(
'subscription_purchase.servers.selected',
{ count: String(selectedCount) },
`Selected: ${selectedCount}`
);
metaText = t('subscription_purchase.servers.selected').replace('{count}', String(selectedCount));
}
metaElement.textContent = metaText;
}
@@ -12283,15 +12205,8 @@
if (config?.hint) {
hintParts.push(config.hint);
}
if (minSelectable > 0) {
const minimumHint = formatPurchaseServersLimit(
'subscription_purchase.servers.minimum',
{ count: String(minSelectable) },
`Select at least ${minSelectable}`
);
if (minimumHint && !hintParts.includes(minimumHint)) {
hintParts.push(minimumHint);
}
if (!config?.hint && minSelectable > 0) {
hintParts.push(t('subscription_purchase.servers.limit').replace('{count}', String(minSelectable)));
}
const hintText = hintParts.join(' ');
hintElement.textContent = hintText;
@@ -12550,8 +12465,8 @@
}).then(async response => {
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const { message, code } = extractPurchaseError(body, response.status);
throw createError('Subscription purchase preview error', message, response.status, code);
const message = extractPurchaseErrorMessage(body, response.status);
throw createError('Subscription purchase preview error', message, response.status);
}
const normalized = normalizeSubscriptionPurchasePreview(body);
subscriptionPurchasePreview = normalized;
@@ -12561,29 +12476,10 @@
renderSubscriptionPurchaseCard();
return normalized;
}).catch(error => {
subscriptionPurchasePreviewLoading = false;
subscriptionPurchasePreviewPromise = null;
const currentPeriodId = subscriptionPurchaseSelections.periodId;
const fallbackPeriod = Array.isArray(subscriptionPurchaseData?.periods)
? subscriptionPurchaseData.periods.find(Boolean)
: null;
const fallbackId = resolvePurchasePeriodId(fallbackPeriod);
if (error?.code === 'invalid_period' && fallbackId && String(fallbackId) !== String(currentPeriodId)) {
console.warn('Preview returned invalid period. Resetting selection to default.', error);
subscriptionPurchaseSelections.periodId = String(fallbackId);
if (fallbackPeriod) {
ensureSubscriptionPurchaseSelectionsValidForPeriod(fallbackPeriod);
}
subscriptionPurchasePreview = null;
subscriptionPurchasePreviewError = null;
renderSubscriptionPurchaseCard();
return updateSubscriptionPurchasePreview({ immediate: true, force: true });
}
subscriptionPurchasePreview = null;
subscriptionPurchasePreviewError = error;
subscriptionPurchasePreviewLoading = false;
subscriptionPurchasePreviewPromise = null;
console.warn('Failed to fetch subscription purchase preview:', error);
renderSubscriptionPurchaseCard();
throw error;
@@ -12881,8 +12777,8 @@
});
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const { message, code } = extractPurchaseError(body, response.status);
throw createError('Subscription purchase error', message, response.status, code);
const message = extractPurchaseErrorMessage(body, response.status);
throw createError('Subscription purchase error', message, response.status);
}
const successMessage = body?.message