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

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