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:
Fringg
2026-07-29 16:34:43 +03:00
parent 62aaa5d7c9
commit 2dce539da2
9 changed files with 474 additions and 0 deletions

View File

@@ -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> => {

View File

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

View File

@@ -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": {

View File

@@ -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": "Ежедневное списание",

View File

@@ -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": {

View File

@@ -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>
); );
})() })()

View File

@@ -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;

View 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();
});
});

View 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;
}
}