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,
|
PurchasePreview,
|
||||||
AppConfig,
|
AppConfig,
|
||||||
SbpRecurringInfo,
|
SbpRecurringInfo,
|
||||||
|
LavaRecurringInfo,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/** Helper: build query params with optional subscription_id */
|
/** Helper: build query params with optional subscription_id */
|
||||||
@@ -394,6 +395,34 @@ export const subscriptionApi = {
|
|||||||
return response.data;
|
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 ───────────────────────────────────────────────────────────
|
// ── Trial ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
getTrialInfo: async (): Promise<TrialInfo> => {
|
getTrialInfo: async (): Promise<TrialInfo> => {
|
||||||
|
|||||||
@@ -701,6 +701,31 @@
|
|||||||
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
|
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
|
||||||
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
|
"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",
|
"backToList": "Back to subscriptions",
|
||||||
"confirmDelete": "Delete subscription?",
|
"confirmDelete": "Delete subscription?",
|
||||||
"dailyAutoCharge": "Daily auto-charge",
|
"dailyAutoCharge": "Daily auto-charge",
|
||||||
|
|||||||
@@ -600,6 +600,31 @@
|
|||||||
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
|
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
|
||||||
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید میکند؛ تمدیدها بهصورت خودکار برداشت میشوند."
|
"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": "بازگشت به فهرست اشتراکها",
|
"backToList": "بازگشت به فهرست اشتراکها",
|
||||||
"confirmDelete": "اشتراک حذف شود؟",
|
"confirmDelete": "اشتراک حذف شود؟",
|
||||||
"cta": {
|
"cta": {
|
||||||
|
|||||||
@@ -720,6 +720,31 @@
|
|||||||
"purchaseButton": "⚡ Оформить с автооплатой СБП",
|
"purchaseButton": "⚡ Оформить с автооплатой СБП",
|
||||||
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
|
"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": "К списку подписок",
|
"backToList": "К списку подписок",
|
||||||
"confirmDelete": "Удалить подписку?",
|
"confirmDelete": "Удалить подписку?",
|
||||||
"dailyAutoCharge": "Ежедневное списание",
|
"dailyAutoCharge": "Ежедневное списание",
|
||||||
|
|||||||
@@ -600,6 +600,31 @@
|
|||||||
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
|
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
|
||||||
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
|
"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": "返回订阅列表",
|
"backToList": "返回订阅列表",
|
||||||
"confirmDelete": "删除订阅?",
|
"confirmDelete": "删除订阅?",
|
||||||
"cta": {
|
"cta": {
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ import {
|
|||||||
sbpUiState,
|
sbpUiState,
|
||||||
type SbpUiState,
|
type SbpUiState,
|
||||||
} from '../utils/sbpRecurring';
|
} from '../utils/sbpRecurring';
|
||||||
|
import {
|
||||||
|
isLavaFeatureDisabledError,
|
||||||
|
lavaPeriodLabelKey,
|
||||||
|
lavaUiState,
|
||||||
|
type LavaUiState,
|
||||||
|
} from '../utils/lavaRecurring';
|
||||||
import Twemoji from 'react-twemoji';
|
import Twemoji from 'react-twemoji';
|
||||||
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
import { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet';
|
||||||
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
|
import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet';
|
||||||
@@ -375,6 +381,78 @@ export default function Subscription() {
|
|||||||
cancelSbpMutation.mutate();
|
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({
|
const autopayMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) =>
|
mutationFn: (enabled: boolean) =>
|
||||||
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
|
||||||
@@ -1280,6 +1358,128 @@ export default function Subscription() {
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -661,6 +661,18 @@ export interface SbpRecurringInfo {
|
|||||||
redirect_url?: string | null;
|
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
|
// Ticket notifications types
|
||||||
export interface TicketNotification {
|
export interface TicketNotification {
|
||||||
id: number;
|
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