diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 4572a0e..bc2791e 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -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 => { + const response = await apiClient.get( + '/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 => { diff --git a/src/locales/en.json b/src/locales/en.json index 7aab2dc..76f3ca3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -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", diff --git a/src/locales/fa.json b/src/locales/fa.json index ef2970a..aa8a8ea 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -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": { diff --git a/src/locales/ru.json b/src/locales/ru.json index 0bcaacd..9696933 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -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": "Ежедневное списание", diff --git a/src/locales/zh.json b/src/locales/zh.json index 5c1fc81..6336f09 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -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": { diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 444095e..1528b21 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -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() { )} + + {/* ─── Автопродление Lava ─── + Независимый от Platega движок: сиблинг того же тоггла, те же + состояния. Период задан продуктом в кабинете Lava и приезжает + числом дней, поэтому подпись строится из charge_days. */} + {!subscription.is_trial && lavaUiStateValue !== 'hidden' && ( +
+
+
+
+ {t('subscription.lavaRecurring.title')} +
+ + {lavaUiStateValue === 'off' && ( +
+ {t('subscription.lavaRecurring.autopayHint')} +
+ )} + {lavaUiStateValue === 'pending' && ( +
+ {t('subscription.lavaRecurring.statusPending')} +
+ )} + {lavaUiStateValue === 'active' && lavaInfo && ( + <> +
+ {(() => { + 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, + }); + })()} +
+ {lavaInfo.next_charge_at && ( +
+ {t('subscription.lavaRecurring.nextCharge', { + date: new Date(lavaInfo.next_charge_at).toLocaleDateString( + uiLocale(), + { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }, + ), + })} +
+ )} + + )} + {lavaUiStateValue === 'past_due' && ( +
+ {t('subscription.lavaRecurring.statusPastDue')} +
+ )} +
+ +
+ {lavaUiStateValue === 'off' && ( + + )} + + {lavaUiStateValue === 'pending' && ( + <> + {lavaInfo?.redirect_url && ( + + )} + + + )} + + {(lavaUiStateValue === 'active' || lavaUiStateValue === 'past_due') && ( + + )} +
+
+
+ )} ); })() diff --git a/src/types/index.ts b/src/types/index.ts index 4012737..8fd2f7f 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; diff --git a/src/utils/lavaRecurring.test.ts b/src/utils/lavaRecurring.test.ts new file mode 100644 index 0000000..863df69 --- /dev/null +++ b/src/utils/lavaRecurring.test.ts @@ -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(); + }); +}); diff --git a/src/utils/lavaRecurring.ts b/src/utils/lavaRecurring.ts new file mode 100644 index 0000000..edb7dd2 --- /dev/null +++ b/src/utils/lavaRecurring.ts @@ -0,0 +1,77 @@ +import type { LavaRecurringInfo } from '../types'; + +type UnknownRecord = Record; + +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; + } +}