Revert "Ensure mini app autopay shows all day options"

This commit is contained in:
Egor
2025-10-11 05:33:42 +03:00
committed by GitHub
parent 14e58421e4
commit e0edc6bcdc
3 changed files with 1 additions and 1123 deletions

View File

@@ -37,7 +37,6 @@ from app.database.crud.subscription import (
create_trial_subscription,
extend_subscription,
remove_subscription_servers,
update_subscription_autopay,
)
from app.database.crud.transaction import (
create_transaction,
@@ -151,9 +150,6 @@ from ..schemas.miniapp import (
MiniAppSubscriptionPurchaseResponse,
MiniAppSubscriptionTrialRequest,
MiniAppSubscriptionTrialResponse,
MiniAppSubscriptionAutopay,
MiniAppSubscriptionAutopayRequest,
MiniAppSubscriptionAutopayResponse,
MiniAppSubscriptionRenewalOptionsRequest,
MiniAppSubscriptionRenewalOptionsResponse,
MiniAppSubscriptionRenewalPeriod,
@@ -202,104 +198,6 @@ _PAYMENT_FAILURE_STATUSES = {
_PERIOD_ID_PATTERN = re.compile(r"(\d+)")
_AUTOPAY_DEFAULT_DAY_OPTIONS = (1, 3, 7, 14)
def _normalize_autopay_days(value: Optional[Any]) -> Optional[int]:
if value is None:
return None
try:
numeric = int(value)
except (TypeError, ValueError):
return None
return numeric if numeric > 0 else None
def _get_autopay_day_options(subscription: Optional[Subscription]) -> List[int]:
options: set[int] = set()
for candidate in _AUTOPAY_DEFAULT_DAY_OPTIONS:
normalized = _normalize_autopay_days(candidate)
if normalized is not None:
options.add(normalized)
default_setting = _normalize_autopay_days(
getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None)
)
if default_setting is not None:
options.add(default_setting)
if subscription is not None:
current = _normalize_autopay_days(
getattr(subscription, "autopay_days_before", None)
)
if current is not None:
options.add(current)
return sorted(options)
def _build_autopay_payload(
subscription: Optional[Subscription],
) -> Optional[MiniAppSubscriptionAutopay]:
if subscription is None:
return None
enabled = bool(getattr(subscription, "autopay_enabled", False))
days_before = _normalize_autopay_days(
getattr(subscription, "autopay_days_before", None)
)
options = _get_autopay_day_options(subscription)
default_days = days_before
if default_days is None:
default_days = _normalize_autopay_days(
getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None)
)
if default_days is None and options:
default_days = options[0]
autopay_kwargs: Dict[str, Any] = {
"enabled": enabled,
"autopay_enabled": enabled,
"days_before": days_before,
"autopay_days_before": days_before,
"default_days_before": default_days,
"autopay_days_options": options,
"days_options": options,
"options": options,
"available_days": options,
"availableDays": options,
"autopayEnabled": enabled,
"autopayDaysBefore": days_before,
"autopayDaysOptions": options,
"daysBefore": days_before,
"daysOptions": options,
"defaultDaysBefore": default_days,
}
return MiniAppSubscriptionAutopay(**autopay_kwargs)
def _autopay_response_extras(
enabled: bool,
days_before: Optional[int],
options: List[int],
autopay_payload: Optional[MiniAppSubscriptionAutopay],
) -> Dict[str, Any]:
extras: Dict[str, Any] = {
"autopayEnabled": enabled,
"autopayDaysBefore": days_before,
"autopayDaysOptions": options,
}
if days_before is not None:
extras["daysBefore"] = days_before
if options:
extras["daysOptions"] = options
if autopay_payload is not None:
extras["autopaySettings"] = autopay_payload
return extras
async def _get_usd_to_rub_rate() -> float:
try:
rate = await currency_converter.get_usd_to_rub_rate()
@@ -2455,24 +2353,6 @@ async def get_subscription_details(
device_limit_value = subscription.device_limit
autopay_enabled = bool(subscription.autopay_enabled)
autopay_payload = _build_autopay_payload(subscription)
autopay_days_before = (
getattr(autopay_payload, "autopay_days_before", None)
if autopay_payload
else None
)
autopay_days_options = (
list(getattr(autopay_payload, "autopay_days_options", []) or [])
if autopay_payload
else []
)
autopay_extras = _autopay_response_extras(
autopay_enabled,
autopay_days_before,
autopay_days_options,
autopay_payload,
)
devices_count, devices = await _load_devices_info(user)
response_user = MiniAppSubscriptionUser(
@@ -2561,10 +2441,6 @@ async def get_subscription_details(
else ("paid" if subscription else "none")
),
autopay_enabled=autopay_enabled,
autopay_days_before=autopay_days_before,
autopay_days_options=autopay_days_options,
autopay=autopay_payload,
autopay_settings=autopay_payload,
branding=settings.get_miniapp_branding(),
faq=faq_payload,
legal_documents=legal_documents_payload,
@@ -2574,121 +2450,6 @@ async def get_subscription_details(
trial_available=trial_available,
trial_duration_days=trial_duration_days,
trial_status="available" if trial_available else "unavailable",
**autopay_extras,
)
@router.post(
"/subscription/autopay",
response_model=MiniAppSubscriptionAutopayResponse,
)
async def update_subscription_autopay_endpoint(
payload: MiniAppSubscriptionAutopayRequest,
db: AsyncSession = Depends(get_db_session),
) -> MiniAppSubscriptionAutopayResponse:
user = await _authorize_miniapp_user(payload.init_data, db)
subscription = _ensure_paid_subscription(user)
_validate_subscription_id(payload.subscription_id, subscription)
target_enabled = (
bool(payload.enabled)
if payload.enabled is not None
else bool(subscription.autopay_enabled)
)
requested_days = payload.days_before
normalized_days = _normalize_autopay_days(requested_days)
current_days = _normalize_autopay_days(
getattr(subscription, "autopay_days_before", None)
)
if normalized_days is None:
normalized_days = current_days
options = _get_autopay_day_options(subscription)
default_day = _normalize_autopay_days(
getattr(settings, "DEFAULT_AUTOPAY_DAYS_BEFORE", None)
)
if default_day is None and options:
default_day = options[0]
if target_enabled and normalized_days is None:
if default_day is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail={
"code": "autopay_no_days",
"message": "Auto-pay day selection is temporarily unavailable",
},
)
normalized_days = default_day
if normalized_days is None:
normalized_days = default_day or (options[0] if options else 1)
if (
bool(subscription.autopay_enabled) == target_enabled
and current_days == normalized_days
):
autopay_payload = _build_autopay_payload(subscription)
autopay_days_before = (
getattr(autopay_payload, "autopay_days_before", None)
if autopay_payload
else None
)
autopay_days_options = (
list(getattr(autopay_payload, "autopay_days_options", []) or [])
if autopay_payload
else options
)
extras = _autopay_response_extras(
target_enabled,
autopay_days_before,
autopay_days_options,
autopay_payload,
)
return MiniAppSubscriptionAutopayResponse(
subscription_id=subscription.id,
autopay_enabled=target_enabled,
autopay_days_before=autopay_days_before,
autopay_days_options=autopay_days_options,
autopay=autopay_payload,
autopay_settings=autopay_payload,
**extras,
)
updated_subscription = await update_subscription_autopay(
db,
subscription,
target_enabled,
normalized_days,
)
autopay_payload = _build_autopay_payload(updated_subscription)
autopay_days_before = (
getattr(autopay_payload, "autopay_days_before", None)
if autopay_payload
else None
)
autopay_days_options = (
list(getattr(autopay_payload, "autopay_days_options", []) or [])
if autopay_payload
else _get_autopay_day_options(updated_subscription)
)
extras = _autopay_response_extras(
bool(updated_subscription.autopay_enabled),
autopay_days_before,
autopay_days_options,
autopay_payload,
)
return MiniAppSubscriptionAutopayResponse(
subscription_id=updated_subscription.id,
autopay_enabled=bool(updated_subscription.autopay_enabled),
autopay_days_before=autopay_days_before,
autopay_days_options=autopay_days_options,
autopay=autopay_payload,
autopay_settings=autopay_payload,
**extras,
)
@@ -3831,24 +3592,6 @@ async def get_subscription_renewal_options_endpoint(
if isinstance(final_total, int) and balance_kopeks < final_total:
missing_amount = final_total - balance_kopeks
renewal_autopay_payload = _build_autopay_payload(subscription)
renewal_autopay_days_before = (
getattr(renewal_autopay_payload, "autopay_days_before", None)
if renewal_autopay_payload
else None
)
renewal_autopay_days_options = (
list(getattr(renewal_autopay_payload, "autopay_days_options", []) or [])
if renewal_autopay_payload
else []
)
renewal_autopay_extras = _autopay_response_extras(
bool(subscription.autopay_enabled),
renewal_autopay_days_before,
renewal_autopay_days_options,
renewal_autopay_payload,
)
return MiniAppSubscriptionRenewalOptionsResponse(
subscription_id=subscription.id,
currency=currency,
@@ -3860,12 +3603,6 @@ async def get_subscription_renewal_options_endpoint(
default_period_id=default_period_id,
missing_amount_kopeks=missing_amount,
status_message=_build_renewal_status_message(user),
autopay_enabled=bool(subscription.autopay_enabled),
autopay_days_before=renewal_autopay_days_before,
autopay_days_options=renewal_autopay_days_options,
autopay=renewal_autopay_payload,
autopay_settings=renewal_autopay_payload,
**renewal_autopay_extras,
)

View File

@@ -134,20 +134,6 @@ class MiniAppPromoOfferClaimResponse(BaseModel):
code: Optional[str] = None
class MiniAppSubscriptionAutopay(BaseModel):
enabled: bool = False
autopay_enabled: Optional[bool] = None
autopay_enabled_at: Optional[datetime] = None
days_before: Optional[int] = None
autopay_days_before: Optional[int] = None
default_days_before: Optional[int] = None
autopay_days_options: List[int] = Field(default_factory=list)
days_options: List[int] = Field(default_factory=list)
options: List[int] = Field(default_factory=list)
model_config = ConfigDict(extra="allow")
class MiniAppSubscriptionRenewalPeriod(BaseModel):
id: str
days: Optional[int] = None
@@ -186,13 +172,8 @@ class MiniAppSubscriptionRenewalOptionsResponse(BaseModel):
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")
autopay_enabled: bool = False
autopay_days_before: Optional[int] = None
autopay_days_options: List[int] = Field(default_factory=list)
autopay: Optional[MiniAppSubscriptionAutopay] = None
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
model_config = ConfigDict(populate_by_name=True, extra="allow")
model_config = ConfigDict(populate_by_name=True)
class MiniAppSubscriptionRenewalRequest(BaseModel):
@@ -215,27 +196,6 @@ class MiniAppSubscriptionRenewalResponse(BaseModel):
model_config = ConfigDict(populate_by_name=True)
class MiniAppSubscriptionAutopayRequest(BaseModel):
init_data: str = Field(..., alias="initData")
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
enabled: Optional[bool] = None
days_before: Optional[int] = Field(default=None, alias="daysBefore")
model_config = ConfigDict(populate_by_name=True)
class MiniAppSubscriptionAutopayResponse(BaseModel):
success: bool = True
subscription_id: Optional[int] = Field(default=None, alias="subscriptionId")
autopay_enabled: bool = False
autopay_days_before: Optional[int] = None
autopay_days_options: List[int] = Field(default_factory=list)
autopay: Optional[MiniAppSubscriptionAutopay] = None
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
model_config = ConfigDict(populate_by_name=True, extra="allow")
class MiniAppPromoCode(BaseModel):
code: str
type: Optional[str] = None
@@ -451,10 +411,6 @@ class MiniAppSubscriptionResponse(BaseModel):
total_spent_label: Optional[str] = None
subscription_type: str
autopay_enabled: bool = False
autopay_days_before: Optional[int] = None
autopay_days_options: List[int] = Field(default_factory=list)
autopay: Optional[MiniAppSubscriptionAutopay] = None
autopay_settings: Optional[MiniAppSubscriptionAutopay] = None
branding: Optional[MiniAppBranding] = None
faq: Optional[MiniAppFaq] = None
legal_documents: Optional[MiniAppLegalDocuments] = None
@@ -465,8 +421,6 @@ class MiniAppSubscriptionResponse(BaseModel):
trial_duration_days: Optional[int] = None
trial_status: Optional[str] = None
model_config = ConfigDict(extra="allow")
class MiniAppSubscriptionServerOption(BaseModel):
uuid: str

View File

@@ -1524,149 +1524,6 @@
box-shadow: var(--shadow-sm);
}
.subscription-autopay-section {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 20px;
padding: 16px;
border-radius: var(--radius-lg);
background: var(--bg-primary);
border: 1px solid rgba(15, 23, 42, 0.08);
}
.subscription-autopay-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
}
.subscription-autopay-title {
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
}
.subscription-autopay-description {
margin-top: 4px;
font-size: 13px;
color: var(--text-secondary);
line-height: 1.4;
}
.subscription-autopay-status {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.subscription-autopay-status::before {
content: '';
width: 8px;
height: 8px;
border-radius: 50%;
background: currentColor;
opacity: 0.7;
}
.subscription-autopay-status.enabled {
color: var(--success);
}
.subscription-autopay-status.disabled {
color: var(--text-secondary);
}
.subscription-autopay-status.saving {
color: var(--primary);
}
.subscription-autopay-status.loading {
color: var(--text-secondary);
}
.subscription-autopay-toggle-group {
display: flex;
gap: 12px;
}
.subscription-autopay-toggle {
flex: 1;
padding: 14px;
border-radius: var(--radius);
border: 1px solid var(--border-color);
background: var(--bg-primary);
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.subscription-autopay-toggle:hover:not(:disabled) {
border-color: var(--primary);
box-shadow: var(--shadow-sm);
transform: translateY(-1px);
}
.subscription-autopay-toggle.active {
background: linear-gradient(135deg, var(--primary), rgba(var(--primary-rgb), 0.85));
color: var(--tg-theme-button-text-color);
border-color: transparent;
box-shadow: var(--shadow-md);
}
.subscription-autopay-toggle:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.subscription-autopay-days {
display: flex;
flex-direction: column;
gap: 12px;
}
.subscription-autopay-days-title {
font-size: 13px;
font-weight: 600;
color: var(--text-secondary);
}
.subscription-autopay-days-options {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
margin-right: -8px;
padding-right: 8px;
}
.subscription-autopay-days-options::-webkit-scrollbar {
display: none;
}
.subscription-autopay-days-options .subscription-settings-toggle {
min-width: 140px;
flex: 0 0 auto;
}
.subscription-autopay-hint {
font-size: 13px;
color: var(--text-secondary);
}
:root[data-theme="dark"] .subscription-autopay-section {
border-color: rgba(148, 163, 184, 0.2);
background: rgba(15, 23, 42, 0.6);
}
.subscription-renewal-summary-header {
display: flex;
justify-content: space-between;
@@ -4847,24 +4704,6 @@
<div class="subscription-renewal-price-note hidden" id="subscriptionRenewalPriceNote"></div>
<div class="subscription-renewal-balance-warning hidden" id="subscriptionRenewalBalanceWarning"></div>
</div>
<div class="subscription-autopay-section" id="subscriptionAutopaySection">
<div class="subscription-autopay-header">
<div>
<div class="subscription-autopay-title" data-i18n="subscription_autopay.title">Автоплатеж</div>
<div class="subscription-autopay-description" data-i18n="subscription_autopay.subtitle">Автоматически продлеваем подписку перед окончанием срока.</div>
</div>
<div class="subscription-autopay-status loading" id="subscriptionAutopayStatus">Loading…</div>
</div>
<div class="subscription-autopay-days hidden" id="subscriptionAutopayDays">
<div class="subscription-autopay-days-title" data-i18n="subscription_autopay.days.title">За сколько списывать</div>
<div class="subscription-autopay-days-options" id="subscriptionAutopayDaysOptions"></div>
</div>
<div class="subscription-autopay-hint hidden" id="subscriptionAutopayHint"></div>
<div class="subscription-autopay-toggle-group">
<button class="subscription-autopay-toggle" id="subscriptionAutopayEnable" type="button" data-i18n="subscription_autopay.action.enable">Включить</button>
<button class="subscription-autopay-toggle" id="subscriptionAutopayDisable" type="button" data-i18n="subscription_autopay.action.disable">Отключить</button>
</div>
</div>
<div class="subscription-renewal-actions">
<button class="btn btn-primary" id="subscriptionRenewalSubmit" type="button" data-i18n="subscription_renewal.submit">Продлить</button>
</div>
@@ -5746,24 +5585,6 @@
'trial.activation.error.already_active': 'You already have an active subscription.',
'autopay.enabled': 'Enabled',
'autopay.disabled': 'Disabled',
'subscription_autopay.title': 'Auto-pay',
'subscription_autopay.subtitle': 'Automatically renew your subscription before it expires.',
'subscription_autopay.status.enabled': 'Enabled',
'subscription_autopay.status.disabled': 'Disabled',
'subscription_autopay.status.loading': 'Loading…',
'subscription_autopay.action.enable': 'Enable',
'subscription_autopay.action.disable': 'Disable',
'subscription_autopay.days.title': 'Charge before expiry',
'subscription_autopay.day.same_day': 'On renewal day',
'subscription_autopay.day.before.one': '{count} day before',
'subscription_autopay.day.before.few': '{count} days before',
'subscription_autopay.day.before.many': '{count} days before',
'subscription_autopay.saving': 'Saving…',
'subscription_autopay.hint.disabled': 'Enable auto-pay to choose when to charge your balance.',
'subscription_autopay.hint.no_options': 'Auto-pay day selection is temporarily unavailable.',
'subscription_autopay.error.generic': 'Failed to update auto-pay settings. Please try again later.',
'subscription_autopay.error.unauthorized': 'Authorization failed. Please reopen the mini app from Telegram.',
'subscription_autopay.error.no_days': 'Select a charge day to enable auto-pay.',
'platform.ios': 'iOS',
'platform.android': 'Android',
'platform.pc': 'PC',
@@ -6141,24 +5962,6 @@
'trial.activation.error.already_active': 'У вас уже есть активная подписка.',
'autopay.enabled': 'Включен',
'autopay.disabled': 'Выключен',
'subscription_autopay.title': 'Автоплатеж',
'subscription_autopay.subtitle': 'Автоматически продлеваем подписку перед окончанием срока.',
'subscription_autopay.status.enabled': 'Включен',
'subscription_autopay.status.disabled': 'Выключен',
'subscription_autopay.status.loading': 'Загружаем…',
'subscription_autopay.action.enable': 'Включить',
'subscription_autopay.action.disable': 'Отключить',
'subscription_autopay.days.title': 'За сколько списывать',
'subscription_autopay.day.same_day': 'В день окончания',
'subscription_autopay.day.before.one': '{count} день до списания',
'subscription_autopay.day.before.few': '{count} дня до списания',
'subscription_autopay.day.before.many': '{count} дней до списания',
'subscription_autopay.saving': 'Сохраняем…',
'subscription_autopay.hint.disabled': 'Включите автоплатеж, чтобы выбрать день списания.',
'subscription_autopay.hint.no_options': 'Выбор дня списания временно недоступен.',
'subscription_autopay.error.generic': 'Не удалось обновить настройки автоплатежа. Попробуйте позже.',
'subscription_autopay.error.unauthorized': 'Ошибка авторизации. Откройте мини-приложение из Telegram и повторите попытку.',
'subscription_autopay.error.no_days': 'Выберите день списания, чтобы включить автоплатеж.',
'platform.ios': 'iOS',
'platform.android': 'Android',
'platform.pc': 'ПК',
@@ -6353,15 +6156,6 @@
periodId: null,
};
let subscriptionAutopayState = {
enabled: false,
daysBefore: null,
defaultDaysBefore: null,
options: [],
loading: true,
saving: false,
};
let trialActivationInProgress = false;
const PAYMENT_STATUS_INITIAL_DELAY_MS = 2000;
@@ -7394,31 +7188,6 @@
userData.subscriptionCryptoLink = userData.subscription_crypto_link || null;
userData.referral = userData.referral || null;
if (hasPaidSubscription()) {
subscriptionAutopayState.loading = true;
subscriptionAutopayState.saving = false;
subscriptionAutopayState.defaultDaysBefore = null;
subscriptionAutopayState.options = [];
ingestAutopayData(
payload,
payload?.autopay,
payload?.autopay_settings,
payload?.autopaySettings,
payload?.subscription_renewal,
payload?.subscriptionRenewal,
payload?.subscription_renewal?.autopay,
payload?.subscriptionRenewal?.autopay,
);
} else {
subscriptionAutopayState.enabled = false;
subscriptionAutopayState.daysBefore = null;
subscriptionAutopayState.defaultDaysBefore = null;
subscriptionAutopayState.options = [];
subscriptionAutopayState.loading = false;
subscriptionAutopayState.saving = false;
renderSubscriptionAutopay();
}
resetSubscriptionRenewalState(null);
prepareSubscriptionRenewalFromUserData();
@@ -8974,566 +8743,6 @@
}
}
function resolvePluralForm(count, language = preferredLanguage) {
const normalizedLang = (language || preferredLanguage || 'en').split('-')[0].toLowerCase();
if (normalizedLang === 'ru') {
const mod10 = count % 10;
const mod100 = count % 100;
if (mod10 === 1 && mod100 !== 11) {
return 'one';
}
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return 'few';
}
return 'many';
}
return count === 1 ? 'one' : 'many';
}
function formatAutopayDayLabel(days) {
const value = coercePositiveInt(days, null);
if (value === null) {
return '';
}
if (value === 0) {
const sameDayKey = 'subscription_autopay.day.same_day';
const sameDay = t(sameDayKey);
if (sameDay && sameDay !== sameDayKey) {
return sameDay;
}
return preferredLanguage === 'ru' ? 'В день окончания' : 'On renewal day';
}
const pluralKey = resolvePluralForm(value);
const key = `subscription_autopay.day.before.${pluralKey}`;
const template = t(key);
if (template && template !== key) {
return template.replace('{count}', String(value));
}
if (preferredLanguage === 'ru') {
if (pluralKey === 'one') {
return `${value} день до списания`;
}
if (pluralKey === 'few') {
return `${value} дня до списания`;
}
return `${value} дней до списания`;
}
return pluralKey === 'one'
? `${value} day before`
: `${value} days before`;
}
function renderSubscriptionAutopay() {
const section = document.getElementById('subscriptionAutopaySection');
if (!section) {
return;
}
const shouldShow = hasPaidSubscription();
section.classList.toggle('hidden', !shouldShow);
if (!shouldShow) {
return;
}
const statusElement = document.getElementById('subscriptionAutopayStatus');
const enableButton = document.getElementById('subscriptionAutopayEnable');
const disableButton = document.getElementById('subscriptionAutopayDisable');
const daysContainer = document.getElementById('subscriptionAutopayDays');
const optionsContainer = document.getElementById('subscriptionAutopayDaysOptions');
const hintElement = document.getElementById('subscriptionAutopayHint');
const enabled = Boolean(subscriptionAutopayState.enabled);
const saving = Boolean(subscriptionAutopayState.saving);
const loading = Boolean(subscriptionAutopayState.loading);
const options = Array.isArray(subscriptionAutopayState.options)
? subscriptionAutopayState.options.slice().sort((a, b) => a - b)
: [];
const hasOptions = options.length > 0;
if (statusElement) {
if (saving) {
const savingKey = 'subscription_autopay.saving';
const savingText = t(savingKey);
statusElement.textContent = savingText && savingText !== savingKey ? savingText : 'Saving…';
statusElement.className = 'subscription-autopay-status saving';
} else if (loading) {
const loadingKey = 'subscription_autopay.status.loading';
const loadingText = t(loadingKey);
statusElement.textContent = loadingText && loadingText !== loadingKey ? loadingText : 'Loading…';
statusElement.className = 'subscription-autopay-status loading';
} else {
const key = enabled
? 'subscription_autopay.status.enabled'
: 'subscription_autopay.status.disabled';
const label = t(key);
statusElement.textContent = label && label !== key
? label
: (enabled ? 'Enabled' : 'Disabled');
statusElement.className = `subscription-autopay-status ${enabled ? 'enabled' : 'disabled'}`;
}
}
if (enableButton) {
enableButton.disabled = saving || loading || enabled;
enableButton.classList.toggle('active', enabled && !loading);
}
if (disableButton) {
disableButton.disabled = saving || loading || !enabled;
disableButton.classList.toggle('active', !enabled && !loading);
}
if (daysContainer) {
daysContainer.classList.toggle('hidden', !enabled || !hasOptions);
}
if (hintElement) {
if (!enabled) {
const hintKey = 'subscription_autopay.hint.disabled';
const hint = t(hintKey);
hintElement.textContent = hint && hint !== hintKey
? hint
: (preferredLanguage === 'ru'
? 'Включите автоплатеж, чтобы выбрать день списания.'
: 'Enable auto-pay to choose when to charge your balance.');
hintElement.classList.remove('hidden');
} else if (enabled && !hasOptions && !loading) {
const hintKey = 'subscription_autopay.hint.no_options';
const hint = t(hintKey);
hintElement.textContent = hint && hint !== hintKey
? hint
: (preferredLanguage === 'ru'
? 'Выбор дня списания временно недоступен.'
: 'Auto-pay day selection is temporarily unavailable.');
hintElement.classList.remove('hidden');
} else {
hintElement.classList.add('hidden');
hintElement.textContent = '';
}
}
if (optionsContainer) {
optionsContainer.innerHTML = '';
if (enabled && hasOptions) {
options.forEach(value => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'subscription-settings-toggle';
button.dataset.autopayDays = String(value);
button.disabled = saving;
if (subscriptionAutopayState.daysBefore === value) {
button.classList.add('active');
}
const label = document.createElement('div');
label.className = 'subscription-settings-toggle-label';
const title = document.createElement('div');
title.className = 'subscription-settings-toggle-title';
title.textContent = formatAutopayDayLabel(value);
label.appendChild(title);
button.appendChild(label);
optionsContainer.appendChild(button);
});
}
}
}
function normalizeAutopayPayload(raw) {
if (!raw || typeof raw !== 'object') {
return null;
}
const enabledCandidate = raw.autopay_enabled
?? raw.enabled
?? raw.is_enabled
?? raw.active
?? null;
let enabledValue;
if (typeof enabledCandidate === 'boolean') {
enabledValue = enabledCandidate;
} else if (enabledCandidate != null) {
enabledValue = coerceBoolean(enabledCandidate, undefined);
}
const daysCandidate = raw.autopay_days_before
?? raw.days_before
?? raw.daysBefore
?? raw.days
?? raw.value
?? null;
const daysBefore = daysCandidate != null
? coercePositiveInt(daysCandidate, null)
: null;
const optionSet = new Set();
const optionSources = [
raw.autopay_days_options,
raw.autopayDaysOptions,
raw.days_options,
raw.daysOptions,
raw.available_days,
raw.availableDays,
];
optionSources.forEach(source => {
if (!source) {
return;
}
const normalized = Array.isArray(source)
? source
: (typeof source === 'object' ? Object.values(source) : [source]);
normalized.forEach(item => {
if (item == null) {
return;
}
if (typeof item === 'object') {
const candidate = item.days_before
?? item.daysBefore
?? item.value
?? item.days
?? item.amount
?? null;
const numeric = candidate != null ? coercePositiveInt(candidate, null) : null;
if (numeric !== null) {
optionSet.add(numeric);
}
return;
}
const numeric = coercePositiveInt(item, null);
if (numeric !== null) {
optionSet.add(numeric);
}
});
});
const options = Array.from(optionSet).sort((a, b) => a - b);
if (daysBefore !== null && !optionSet.has(daysBefore)) {
options.push(daysBefore);
options.sort((a, b) => a - b);
}
return {
enabled: typeof enabledValue === 'boolean' ? enabledValue : undefined,
daysBefore: daysBefore !== null ? daysBefore : null,
options,
};
}
const DEFAULT_AUTOPAY_DAY_OPTIONS = [1, 3, 7, 14];
function mergeAutopaySources(...sources) {
let hasData = false;
let enabledValue;
let daysValue = null;
const optionSet = new Set();
DEFAULT_AUTOPAY_DAY_OPTIONS.forEach(value => {
const numeric = coercePositiveInt(value, null);
if (numeric !== null) {
optionSet.add(numeric);
}
});
sources.forEach(source => {
const normalized = normalizeAutopayPayload(source);
if (!normalized) {
return;
}
hasData = true;
if (typeof normalized.enabled === 'boolean') {
enabledValue = normalized.enabled;
}
if (normalized.daysBefore !== null && normalized.daysBefore !== undefined) {
daysValue = normalized.daysBefore;
}
normalized.options.forEach(value => optionSet.add(value));
});
if (!hasData) {
return null;
}
if (daysValue !== null && daysValue !== undefined) {
optionSet.add(daysValue);
}
const options = Array.from(optionSet).sort((a, b) => a - b);
let resolvedDays = daysValue;
if ((resolvedDays === null || resolvedDays === undefined) && options.length) {
resolvedDays = options[0];
}
return {
enabled: enabledValue,
daysBefore: resolvedDays,
options,
};
}
function ingestAutopayData(...sources) {
const candidates = sources.filter(Boolean);
if (!candidates.length) {
subscriptionAutopayState.loading = false;
renderSubscriptionAutopay();
return;
}
const normalized = mergeAutopaySources(...candidates);
if (!normalized) {
subscriptionAutopayState.loading = false;
renderSubscriptionAutopay();
return;
}
if (typeof normalized.enabled === 'boolean') {
subscriptionAutopayState.enabled = normalized.enabled;
}
if (normalized.daysBefore !== null && normalized.daysBefore !== undefined) {
subscriptionAutopayState.daysBefore = normalized.daysBefore;
if (subscriptionAutopayState.defaultDaysBefore === null || subscriptionAutopayState.defaultDaysBefore === undefined) {
subscriptionAutopayState.defaultDaysBefore = normalized.daysBefore;
}
}
subscriptionAutopayState.options = Array.isArray(normalized.options)
? normalized.options.slice().sort((a, b) => a - b)
: [];
if ((subscriptionAutopayState.daysBefore === null || subscriptionAutopayState.daysBefore === undefined)
&& subscriptionAutopayState.options.length) {
subscriptionAutopayState.daysBefore = subscriptionAutopayState.options[0];
}
subscriptionAutopayState.loading = false;
renderSubscriptionAutopay();
}
function extractAutopayError(payload, status) {
if (status === 401) {
return t('subscription_autopay.error.unauthorized');
}
if (!payload || typeof payload !== 'object') {
return t('subscription_autopay.error.generic');
}
if (typeof payload.detail === 'string') {
return payload.detail;
}
if (payload.detail && typeof payload.detail === 'object') {
if (typeof payload.detail.message === 'string') {
return payload.detail.message;
}
if (typeof payload.detail.error === 'string') {
return payload.detail.error;
}
}
if (typeof payload.message === 'string') {
return payload.message;
}
if (typeof payload.error === 'string') {
return payload.error;
}
return t('subscription_autopay.error.generic');
}
function resolveAutopayErrorMessage(error, fallbackKey = 'subscription_autopay.error.generic') {
if (!error) {
return t(fallbackKey);
}
if (typeof error === 'string') {
return error;
}
if (typeof error.message === 'string' && error.message.trim()) {
return error.message;
}
if (error.detail) {
if (typeof error.detail === 'string' && error.detail.trim()) {
return error.detail;
}
if (typeof error.detail.message === 'string' && error.detail.message.trim()) {
return error.detail.message;
}
}
if (error.status === 401) {
return t('subscription_autopay.error.unauthorized');
}
return t(fallbackKey);
}
function handleAutopayError(error) {
const message = resolveAutopayErrorMessage(error);
const titleKey = 'subscription_autopay.title';
const title = t(titleKey);
showPopup(message, title && title !== titleKey ? title : 'Auto-pay');
}
async function submitAutopaySettingsChange(changes = {}) {
if (subscriptionAutopayState.saving || subscriptionAutopayState.loading) {
return;
}
if (!hasPaidSubscription()) {
handleAutopayError(createError('Auto-pay', t('subscription_autopay.error.generic')));
return;
}
const initData = tg.initData || '';
if (!initData) {
handleAutopayError(createError('Authorization Error', t('subscription_autopay.error.unauthorized')));
return;
}
const subscriptionId = userData?.subscription_id ?? userData?.subscriptionId ?? null;
if (!subscriptionId) {
handleAutopayError(createError('Auto-pay', t('subscription_autopay.error.generic')));
return;
}
const previousState = {
...subscriptionAutopayState,
options: [...(subscriptionAutopayState.options || [])],
};
const targetEnabled = typeof changes.enabled === 'boolean'
? changes.enabled
: subscriptionAutopayState.enabled;
let targetDays = changes.daysBefore;
if (targetDays === undefined || targetDays === null) {
targetDays = subscriptionAutopayState.daysBefore;
}
if (targetEnabled && (targetDays === undefined || targetDays === null)) {
if (subscriptionAutopayState.defaultDaysBefore !== null && subscriptionAutopayState.defaultDaysBefore !== undefined) {
targetDays = subscriptionAutopayState.defaultDaysBefore;
} else if (subscriptionAutopayState.options.length) {
targetDays = subscriptionAutopayState.options[0];
}
}
targetDays = targetDays !== undefined && targetDays !== null
? coercePositiveInt(targetDays, null)
: null;
if (targetEnabled && (targetDays === null || targetDays === undefined)) {
handleAutopayError(createError('Auto-pay', t('subscription_autopay.error.no_days')));
renderSubscriptionAutopay();
return;
}
subscriptionAutopayState.enabled = targetEnabled;
if (targetEnabled) {
subscriptionAutopayState.daysBefore = targetDays;
if (subscriptionAutopayState.defaultDaysBefore === null || subscriptionAutopayState.defaultDaysBefore === undefined) {
subscriptionAutopayState.defaultDaysBefore = targetDays;
}
}
subscriptionAutopayState.saving = true;
renderSubscriptionAutopay();
try {
const payload = {
initData,
subscription_id: subscriptionId,
subscriptionId,
enabled: targetEnabled,
days_before: targetDays,
daysBefore: targetDays,
};
const response = await fetch('/miniapp/subscription/autopay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
const body = await parseJsonSafe(response);
if (!response.ok || (body && body.success === false)) {
const message = extractAutopayError(body, response.status);
throw createError('Auto-pay', message, response.status);
}
subscriptionAutopayState.saving = false;
if (body && typeof body === 'object') {
ingestAutopayData(
body.autopay
|| body.data
|| body.subscription
|| body.settings
|| body,
);
} else {
renderSubscriptionAutopay();
}
await refreshSubscriptionData({ silent: true });
await ensureSubscriptionRenewalData({ force: true });
} catch (error) {
subscriptionAutopayState = {
...previousState,
options: [...previousState.options],
saving: false,
};
renderSubscriptionAutopay();
handleAutopayError(error);
}
}
function handleAutopayToggle(enabled) {
if (typeof enabled !== 'boolean') {
return;
}
if (subscriptionAutopayState.loading || subscriptionAutopayState.saving) {
return;
}
if (enabled === subscriptionAutopayState.enabled) {
return;
}
submitAutopaySettingsChange({ enabled });
}
function handleAutopayDaySelection(days) {
if (subscriptionAutopayState.loading || subscriptionAutopayState.saving) {
return;
}
if (!subscriptionAutopayState.enabled) {
return;
}
const value = coercePositiveInt(days, null);
if (value === null || value === undefined) {
return;
}
if (subscriptionAutopayState.daysBefore === value) {
return;
}
if (!subscriptionAutopayState.options.includes(value)) {
return;
}
submitAutopaySettingsChange({ daysBefore: value });
}
function setupSubscriptionAutopayEvents() {
document.getElementById('subscriptionAutopayEnable')?.addEventListener('click', () => {
handleAutopayToggle(true);
});
document.getElementById('subscriptionAutopayDisable')?.addEventListener('click', () => {
handleAutopayToggle(false);
});
document.getElementById('subscriptionAutopayDaysOptions')?.addEventListener('click', event => {
const target = event.target.closest('button[data-autopay-days]');
if (!target || target.disabled) {
return;
}
const value = coercePositiveInt(target.dataset.autopayDays, null);
if (value === null || value === undefined) {
return;
}
handleAutopayDaySelection(value);
});
}
function isSameSet(a, b) {
if (!(a instanceof Set) || !(b instanceof Set)) {
return false;
@@ -12816,16 +12025,6 @@
const promoOffer = normalizeRenewalPromoOffer(root.promo_offer ?? root.promoOffer);
const autopayData = mergeAutopaySources(
root.autopay,
root.autopay_settings,
root.autopaySettings,
root,
payload.autopay,
payload.autopay_settings,
payload.autopaySettings,
);
return {
subscriptionId,
currency,
@@ -12837,7 +12036,6 @@
promoOffer,
missingAmountKopeks,
statusMessage,
autopay: autopayData,
};
}
@@ -12855,9 +12053,6 @@
if (normalized && Array.isArray(normalized.periods) && normalized.periods.length) {
subscriptionRenewalData = normalized;
resetSubscriptionRenewalSelection(normalized);
if (normalized.autopay) {
ingestAutopayData(normalized.autopay);
}
return;
}
}
@@ -12940,9 +12135,6 @@
subscriptionRenewalError = null;
subscriptionRenewalLoading = false;
resetSubscriptionRenewalSelection(inlineNormalized);
if (inlineNormalized.autopay) {
ingestAutopayData(inlineNormalized.autopay);
}
renderSubscriptionRenewalCard();
return Promise.resolve(inlineNormalized);
}
@@ -12982,9 +12174,6 @@
subscriptionRenewalLoading = false;
subscriptionRenewalPromise = null;
resetSubscriptionRenewalSelection(normalized);
if (normalized.autopay) {
ingestAutopayData(normalized.autopay);
}
renderSubscriptionRenewalCard();
return normalized;
}).catch(error => {
@@ -13511,7 +12700,6 @@
renderSubscriptionRenewalMeta(subscriptionRenewalData);
renderSubscriptionRenewalOptions(subscriptionRenewalData);
renderSubscriptionRenewalSummary(subscriptionRenewalData);
renderSubscriptionAutopay();
}
async function confirmSubscriptionRenewal(option, data) {
@@ -13643,7 +12831,6 @@
}
function setupSubscriptionRenewalEvents() {
setupSubscriptionAutopayEvents();
document.getElementById('subscriptionRenewalRetry')?.addEventListener('click', () => {
ensureSubscriptionRenewalData({ force: true }).catch(error => {
console.warn('Failed to reload renewal options:', error);