mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
feat(lava): автопродление Lava на странице подписки
Парная фронтовая часть к эндпоинтам /cabinet/subscription/lava-recurrent (бот: 0dea2182). Блок — сиблинг Platega-блока с той же семантикой состояний (off/pending/active/past_due), поллинг раз в 8с пока привязка PENDING. Отличие от Platega в подписи: у Lava период задан продуктом в кабинете провайдера и приезжает числом дней, поэтому канонические значения (30/90/365 и т.д.) показываются словами, а произвольные — как «раз в N дн.». utils/lavaRecurring.ts: строгое распознавание 403 'Lava recurrent disabled' (другие 403-guard'ы прятать нельзя), деградация неизвестного статуса в off. Локали ru/en/zh/fa (+25 строк каждая, плейсхолдеры сверены). 7 тестов утилиты.
This commit is contained in:
@@ -13,6 +13,7 @@ import type {
|
||||
PurchasePreview,
|
||||
AppConfig,
|
||||
SbpRecurringInfo,
|
||||
LavaRecurringInfo,
|
||||
} from '../types';
|
||||
|
||||
/** Helper: build query params with optional subscription_id */
|
||||
@@ -394,6 +395,34 @@ export const subscriptionApi = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ── Recurring (Lava) ──────────────────────────────────────────────────
|
||||
|
||||
getLavaRecurring: async (subscriptionId?: number): Promise<LavaRecurringInfo> => {
|
||||
const response = await apiClient.get<LavaRecurringInfo>(
|
||||
'/cabinet/subscription/lava-recurrent',
|
||||
withSubId(subscriptionId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
enableLavaRecurring: async (
|
||||
subscriptionId?: number,
|
||||
): Promise<{ status: string; redirect_url: string | null }> => {
|
||||
const response = await apiClient.post(
|
||||
'/cabinet/subscription/lava-recurrent/enable',
|
||||
...bodyWithSubId({}, subscriptionId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
cancelLavaRecurring: async (subscriptionId?: number): Promise<{ status: string }> => {
|
||||
const response = await apiClient.post(
|
||||
'/cabinet/subscription/lava-recurrent/cancel',
|
||||
...bodyWithSubId({}, subscriptionId),
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// ── Trial ───────────────────────────────────────────────────────────
|
||||
|
||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||
|
||||
@@ -701,6 +701,31 @@
|
||||
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
|
||||
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "Lava auto-renewal",
|
||||
"connect": "Connect",
|
||||
"payFirst": "Pay the first invoice",
|
||||
"cancel": "Disable",
|
||||
"confirmCancel": "Disable Lava auto-renewal? The subscription will no longer renew automatically.",
|
||||
"cancelled": "Lava auto-renewal disabled",
|
||||
"enableError": "Could not enable Lava auto-renewal",
|
||||
"cancelError": "Could not disable Lava auto-renewal",
|
||||
"statusPending": "Waiting for the first invoice to be paid",
|
||||
"statusActive": "Active",
|
||||
"statusPastDue": "Payment failed — please try again",
|
||||
"nextCharge": "next charge on {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "{{amount}} ₽ every {{days}} days",
|
||||
"period": {
|
||||
"day": "daily",
|
||||
"week": "weekly",
|
||||
"month": "monthly",
|
||||
"quarter": "quarterly",
|
||||
"halfYear": "every six months",
|
||||
"year": "yearly"
|
||||
},
|
||||
"autopayHint": "The subscription will renew automatically via Lava"
|
||||
},
|
||||
"backToList": "Back to subscriptions",
|
||||
"confirmDelete": "Delete subscription?",
|
||||
"dailyAutoCharge": "Daily auto-charge",
|
||||
|
||||
@@ -600,6 +600,31 @@
|
||||
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
|
||||
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید میکند؛ تمدیدها بهصورت خودکار برداشت میشوند."
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "تمدید خودکار Lava",
|
||||
"connect": "اتصال",
|
||||
"payFirst": "پرداخت صورتحساب اول",
|
||||
"cancel": "غیرفعالسازی",
|
||||
"confirmCancel": "تمدید خودکار Lava غیرفعال شود؟ اشتراک دیگر بهطور خودکار تمدید نمیشود.",
|
||||
"cancelled": "تمدید خودکار Lava غیرفعال شد",
|
||||
"enableError": "فعالسازی تمدید خودکار Lava ناموفق بود",
|
||||
"cancelError": "غیرفعالسازی تمدید خودکار Lava ناموفق بود",
|
||||
"statusPending": "در انتظار پرداخت صورتحساب اول",
|
||||
"statusActive": "فعال",
|
||||
"statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید",
|
||||
"nextCharge": "برداشت بعدی {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "هر {{days}} روز {{amount}} ₽",
|
||||
"period": {
|
||||
"day": "روزانه",
|
||||
"week": "هفتگی",
|
||||
"month": "ماهانه",
|
||||
"quarter": "هر سه ماه",
|
||||
"halfYear": "هر شش ماه",
|
||||
"year": "سالانه"
|
||||
},
|
||||
"autopayHint": "اشتراک بهطور خودکار از طریق Lava تمدید میشود"
|
||||
},
|
||||
"backToList": "بازگشت به فهرست اشتراکها",
|
||||
"confirmDelete": "اشتراک حذف شود؟",
|
||||
"cta": {
|
||||
|
||||
@@ -720,6 +720,31 @@
|
||||
"purchaseButton": "⚡ Оформить с автооплатой СБП",
|
||||
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "Автопродление Lava",
|
||||
"connect": "Подключить",
|
||||
"payFirst": "Оплатить первый счёт",
|
||||
"cancel": "Отключить",
|
||||
"confirmCancel": "Отключить автопродление Lava? Подписка больше не будет продлеваться автоматически.",
|
||||
"cancelled": "Автопродление Lava отключено",
|
||||
"enableError": "Не удалось подключить автопродление Lava",
|
||||
"cancelError": "Не удалось отменить автопродление Lava",
|
||||
"statusPending": "Ожидает оплаты первого счёта",
|
||||
"statusActive": "Активно",
|
||||
"statusPastDue": "Платёж не прошёл — попробуйте снова",
|
||||
"nextCharge": "следующее списание {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "{{amount}} ₽ раз в {{days}} дн.",
|
||||
"period": {
|
||||
"day": "ежедневно",
|
||||
"week": "еженедельно",
|
||||
"month": "ежемесячно",
|
||||
"quarter": "раз в квартал",
|
||||
"halfYear": "раз в полгода",
|
||||
"year": "ежегодно"
|
||||
},
|
||||
"autopayHint": "Подписка будет автоматически продлеваться через Lava"
|
||||
},
|
||||
"backToList": "К списку подписок",
|
||||
"confirmDelete": "Удалить подписку?",
|
||||
"dailyAutoCharge": "Ежедневное списание",
|
||||
|
||||
@@ -600,6 +600,31 @@
|
||||
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
|
||||
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
|
||||
},
|
||||
"lavaRecurring": {
|
||||
"title": "Lava 自动续订",
|
||||
"connect": "连接",
|
||||
"payFirst": "支付首期账单",
|
||||
"cancel": "关闭",
|
||||
"confirmCancel": "要关闭 Lava 自动续订吗?订阅将不再自动续订。",
|
||||
"cancelled": "已关闭 Lava 自动续订",
|
||||
"enableError": "无法开启 Lava 自动续订",
|
||||
"cancelError": "无法关闭 Lava 自动续订",
|
||||
"statusPending": "等待支付首期账单",
|
||||
"statusActive": "已启用",
|
||||
"statusPastDue": "扣款失败 — 请重试",
|
||||
"nextCharge": "下次扣款 {{date}}",
|
||||
"amountPerPeriod": "{{amount}} ₽ {{period}}",
|
||||
"amountPerDays": "每 {{days}} 天 {{amount}} ₽",
|
||||
"period": {
|
||||
"day": "每天",
|
||||
"week": "每周",
|
||||
"month": "每月",
|
||||
"quarter": "每季度",
|
||||
"halfYear": "每半年",
|
||||
"year": "每年"
|
||||
},
|
||||
"autopayHint": "订阅将通过 Lava 自动续订"
|
||||
},
|
||||
"backToList": "返回订阅列表",
|
||||
"confirmDelete": "删除订阅?",
|
||||
"cta": {
|
||||
|
||||
@@ -43,6 +43,12 @@ import {
|
||||
sbpUiState,
|
||||
type SbpUiState,
|
||||
} from '../utils/sbpRecurring';
|
||||
import {
|
||||
isLavaFeatureDisabledError,
|
||||
lavaPeriodLabelKey,
|
||||
lavaUiState,
|
||||
type LavaUiState,
|
||||
} from '../utils/lavaRecurring';
|
||||
import Twemoji from 'react-twemoji';
|
||||
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
||||
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
|
||||
@@ -375,6 +381,78 @@ export default function Subscription() {
|
||||
cancelSbpMutation.mutate();
|
||||
};
|
||||
|
||||
// Автопродление Lava — независимый от Platega движок с той же семантикой
|
||||
// состояний. Поллинг раз в 8с, пока привязка PENDING (ждём оплату первого
|
||||
// счёта), чтобы UI сам перешёл в active/past_due.
|
||||
const lavaQuery = useQuery({
|
||||
queryKey: ['lava-recurring', subscriptionId],
|
||||
queryFn: () => subscriptionApi.getLavaRecurring(subscriptionId),
|
||||
enabled: !!subscription && !subscription.is_trial,
|
||||
retry: false,
|
||||
refetchInterval: (query) => (query.state.data?.status === 'PENDING' ? 8000 : false),
|
||||
});
|
||||
const lavaInfo = lavaQuery.data;
|
||||
const lavaFeatureDisabled = isLavaFeatureDisabledError(lavaQuery.error);
|
||||
const lavaUiStateValue: LavaUiState =
|
||||
lavaInfo !== undefined || lavaFeatureDisabled
|
||||
? lavaUiState(lavaInfo, lavaFeatureDisabled)
|
||||
: 'hidden';
|
||||
|
||||
const enableLavaMutation = useMutation({
|
||||
mutationFn: () => subscriptionApi.enableLavaRecurring(subscriptionId),
|
||||
onSuccess: (data) => {
|
||||
if (data.redirect_url) {
|
||||
openPaymentUrl(data.redirect_url, platform, openLink);
|
||||
}
|
||||
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
|
||||
// Бэкенд снимает autopay_enabled при включении рекуррента провайдера.
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
|
||||
?.detail;
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: typeof detail === 'string' ? detail : t('subscription.lavaRecurring.enableError'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const cancelLavaMutation = useMutation({
|
||||
mutationFn: () => subscriptionApi.cancelLavaRecurring(subscriptionId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['lava-recurring', subscriptionId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
|
||||
showToast({
|
||||
type: 'success',
|
||||
title: t('subscription.lavaRecurring.cancelled'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
const detail = (error as { response?: { data?: { detail?: unknown } } })?.response?.data
|
||||
?.detail;
|
||||
showToast({
|
||||
type: 'error',
|
||||
title: typeof detail === 'string' ? detail : t('subscription.lavaRecurring.cancelError'),
|
||||
message: '',
|
||||
duration: 3000,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleCancelLava = async () => {
|
||||
const confirmed = await destructiveConfirm(
|
||||
t('subscription.lavaRecurring.confirmCancel'),
|
||||
t('subscription.lavaRecurring.cancel'),
|
||||
);
|
||||
if (!confirmed) return;
|
||||
cancelLavaMutation.mutate();
|
||||
};
|
||||
|
||||
const autopayMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) =>
|
||||
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
||||
@@ -1280,6 +1358,128 @@ export default function Subscription() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Автопродление Lava ───
|
||||
Независимый от Platega движок: сиблинг того же тоггла, те же
|
||||
состояния. Период задан продуктом в кабинете Lava и приезжает
|
||||
числом дней, поэтому подпись строится из charge_days. */}
|
||||
{!subscription.is_trial && lavaUiStateValue !== 'hidden' && (
|
||||
<div
|
||||
className="mt-3 rounded-[14px] p-3.5"
|
||||
style={{
|
||||
background: g.innerBg,
|
||||
border: `1px solid ${g.innerBorder}`,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold text-dark-50">
|
||||
{t('subscription.lavaRecurring.title')}
|
||||
</div>
|
||||
|
||||
{lavaUiStateValue === 'off' && (
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('subscription.lavaRecurring.autopayHint')}
|
||||
</div>
|
||||
)}
|
||||
{lavaUiStateValue === 'pending' && (
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('subscription.lavaRecurring.statusPending')}
|
||||
</div>
|
||||
)}
|
||||
{lavaUiStateValue === 'active' && lavaInfo && (
|
||||
<>
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{(() => {
|
||||
const periodKey = lavaPeriodLabelKey(lavaInfo.charge_days);
|
||||
const amount = formatAmount((lavaInfo.amount_kopeks ?? 0) / 100);
|
||||
return periodKey
|
||||
? t('subscription.lavaRecurring.amountPerPeriod', {
|
||||
amount,
|
||||
period: t(periodKey),
|
||||
})
|
||||
: t('subscription.lavaRecurring.amountPerDays', {
|
||||
amount,
|
||||
days: lavaInfo.charge_days ?? 0,
|
||||
});
|
||||
})()}
|
||||
</div>
|
||||
{lavaInfo.next_charge_at && (
|
||||
<div className="mt-0.5 text-[11px] text-dark-50/30">
|
||||
{t('subscription.lavaRecurring.nextCharge', {
|
||||
date: new Date(lavaInfo.next_charge_at).toLocaleDateString(
|
||||
uiLocale(),
|
||||
{
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
},
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{lavaUiStateValue === 'past_due' && (
|
||||
<div className="mt-0.5 text-[11px] font-medium text-warning-400">
|
||||
{t('subscription.lavaRecurring.statusPastDue')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 flex-col gap-2 sm:items-end">
|
||||
{lavaUiStateValue === 'off' && (
|
||||
<button
|
||||
onClick={() => enableLavaMutation.mutate()}
|
||||
disabled={enableLavaMutation.isPending}
|
||||
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{enableLavaMutation.isPending ? (
|
||||
<span className="mx-auto block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
|
||||
) : (
|
||||
t('subscription.lavaRecurring.connect')
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{lavaUiStateValue === 'pending' && (
|
||||
<>
|
||||
{lavaInfo?.redirect_url && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (lavaInfo.redirect_url) {
|
||||
openPaymentUrl(lavaInfo.redirect_url, platform, openLink);
|
||||
}
|
||||
}}
|
||||
className="w-full whitespace-nowrap rounded-xl bg-accent-500 px-5 py-2.5 text-sm font-medium text-on-accent transition-opacity sm:w-auto"
|
||||
>
|
||||
{t('subscription.lavaRecurring.payFirst')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCancelLava}
|
||||
disabled={cancelLavaMutation.isPending}
|
||||
className="text-[11px] font-medium transition-colors disabled:opacity-50 sm:text-right"
|
||||
style={{ color: 'rgb(var(--color-critical-500))' }}
|
||||
>
|
||||
{t('subscription.lavaRecurring.cancel')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(lavaUiStateValue === 'active' || lavaUiStateValue === 'past_due') && (
|
||||
<button
|
||||
onClick={handleCancelLava}
|
||||
disabled={cancelLavaMutation.isPending}
|
||||
className="w-full whitespace-nowrap rounded-xl border border-error-500/30 bg-error-500/10 px-5 py-2.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50 sm:w-auto"
|
||||
>
|
||||
{t('subscription.lavaRecurring.cancel')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
|
||||
@@ -661,6 +661,18 @@ export interface SbpRecurringInfo {
|
||||
redirect_url?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Автопродление Lava. В отличие от Platega период задан продуктом в кабинете
|
||||
* Lava и приезжает числом дней (charge_days), а не enum-интервалом.
|
||||
*/
|
||||
export interface LavaRecurringInfo {
|
||||
status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE'
|
||||
charge_days?: number;
|
||||
amount_kopeks?: number;
|
||||
next_charge_at?: string | null;
|
||||
redirect_url?: string | null;
|
||||
}
|
||||
|
||||
// Ticket notifications types
|
||||
export interface TicketNotification {
|
||||
id: number;
|
||||
|
||||
56
src/utils/lavaRecurring.test.ts
Normal file
56
src/utils/lavaRecurring.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isLavaFeatureDisabledError, lavaPeriodLabelKey, lavaUiState } from './lavaRecurring';
|
||||
|
||||
describe('isLavaFeatureDisabledError', () => {
|
||||
it('detects exactly the disabled-feature 403', () => {
|
||||
expect(
|
||||
isLavaFeatureDisabledError({
|
||||
response: { status: 403, data: { detail: 'Lava recurrent disabled' } },
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores other 403s and malformed errors', () => {
|
||||
// Другие guard'ы отдают тот же статус с иным detail — их прятать нельзя
|
||||
expect(
|
||||
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: 'Blacklisted' } } }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isLavaFeatureDisabledError({ response: { status: 403, data: { detail: { code: 'x' } } } }),
|
||||
).toBe(false);
|
||||
expect(isLavaFeatureDisabledError({ response: { status: 500, data: {} } })).toBe(false);
|
||||
expect(isLavaFeatureDisabledError({ response: { status: 403 } })).toBe(false);
|
||||
expect(isLavaFeatureDisabledError(null)).toBe(false);
|
||||
expect(isLavaFeatureDisabledError('boom')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lavaUiState', () => {
|
||||
it('hides the block when the feature is disabled', () => {
|
||||
expect(lavaUiState({ status: 'ACTIVE' }, true)).toBe('hidden');
|
||||
});
|
||||
|
||||
it('maps backend statuses to UI states', () => {
|
||||
expect(lavaUiState({ status: 'PENDING' }, false)).toBe('pending');
|
||||
expect(lavaUiState({ status: 'ACTIVE' }, false)).toBe('active');
|
||||
expect(lavaUiState({ status: 'PAST_DUE' }, false)).toBe('past_due');
|
||||
expect(lavaUiState({ status: 'none' }, false)).toBe('off');
|
||||
});
|
||||
|
||||
it('degrades unknown or missing state to off instead of hanging', () => {
|
||||
expect(lavaUiState({ status: 'SOMETHING_NEW' }, false)).toBe('off');
|
||||
expect(lavaUiState(undefined, false)).toBe('off');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lavaPeriodLabelKey', () => {
|
||||
it('labels canonical periods', () => {
|
||||
expect(lavaPeriodLabelKey(30)).toBe('subscription.lavaRecurring.period.month');
|
||||
expect(lavaPeriodLabelKey(365)).toBe('subscription.lavaRecurring.period.year');
|
||||
});
|
||||
|
||||
it('returns null for non-canonical periods so the caller falls back to "N days"', () => {
|
||||
expect(lavaPeriodLabelKey(45)).toBeNull();
|
||||
expect(lavaPeriodLabelKey(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
77
src/utils/lavaRecurring.ts
Normal file
77
src/utils/lavaRecurring.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { LavaRecurringInfo } from '../types';
|
||||
|
||||
type UnknownRecord = Record<string, unknown>;
|
||||
|
||||
function isRecord(value: unknown): value is UnknownRecord {
|
||||
return typeof value === 'object' && value !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* true, если ошибка — это ровно "автопродление Lava выключено на бэке"
|
||||
* (403 с detail==='Lava recurrent disabled'). Сравнение строгое: тот же
|
||||
* 403-статус используют другие guard'ы с detail-объектом, и на любой форме
|
||||
* detail (строка/объект/отсутствует) функция не должна падать.
|
||||
*/
|
||||
export function isLavaFeatureDisabledError(error: unknown): boolean {
|
||||
if (!isRecord(error)) return false;
|
||||
|
||||
const response = error.response;
|
||||
if (!isRecord(response)) return false;
|
||||
if (response.status !== 403) return false;
|
||||
|
||||
const data = response.data;
|
||||
if (!isRecord(data)) return false;
|
||||
|
||||
return data.detail === 'Lava recurrent disabled';
|
||||
}
|
||||
|
||||
export type LavaUiState = 'hidden' | 'off' | 'pending' | 'active' | 'past_due';
|
||||
|
||||
/**
|
||||
* Сводит статус автопродления Lava к состоянию UI. Бэк отдаёт из GET только
|
||||
* 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' (CANCELLED/FAILED терминальны и
|
||||
* не возвращаются), но любой нераспознанный статус деградирует в 'off', а не
|
||||
* подвешивает интерфейс.
|
||||
*/
|
||||
export function lavaUiState(
|
||||
info: LavaRecurringInfo | undefined,
|
||||
featureDisabled: boolean,
|
||||
): LavaUiState {
|
||||
if (featureDisabled) return 'hidden';
|
||||
if (!info) return 'off';
|
||||
|
||||
switch (info.status) {
|
||||
case 'PENDING':
|
||||
return 'pending';
|
||||
case 'ACTIVE':
|
||||
return 'active';
|
||||
case 'PAST_DUE':
|
||||
return 'past_due';
|
||||
default:
|
||||
return 'off';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Локализационный ключ периодичности списания. У Lava период задан продуктом
|
||||
* в кабинете провайдера и приезжает числом дней, поэтому точные подписи есть
|
||||
* только для канонических значений; остальное показывается как "N дней".
|
||||
*/
|
||||
export function lavaPeriodLabelKey(chargeDays: number | undefined): string | null {
|
||||
switch (chargeDays) {
|
||||
case 1:
|
||||
return 'subscription.lavaRecurring.period.day';
|
||||
case 7:
|
||||
return 'subscription.lavaRecurring.period.week';
|
||||
case 30:
|
||||
return 'subscription.lavaRecurring.period.month';
|
||||
case 90:
|
||||
return 'subscription.lavaRecurring.period.quarter';
|
||||
case 180:
|
||||
return 'subscription.lavaRecurring.period.halfYear';
|
||||
case 365:
|
||||
return 'subscription.lavaRecurring.period.year';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user