Merge branch 'feat/platega-sbp-recurring' into dev: СБП-автопродление Platega в кабинете

Тоггл/статус/отмена на странице подписки (включая суточные тарифы),
СБП-привязки в сохранённых методах, WS-уведомления о событиях списаний,
статус и отмена автооплаты в админ-карточке юзера. Работает только при
включённом PLATEGA_RECURRENT_ENABLED на бэкенде (403 = блок скрыт).
This commit is contained in:
Fringg
2026-07-24 06:45:39 +03:00
15 changed files with 924 additions and 12 deletions

View File

@@ -21,6 +21,8 @@ export interface UserSubscriptionInfo {
tariff_id: number | null; tariff_id: number | null;
tariff_name: string | null; tariff_name: string | null;
autopay_enabled: boolean; autopay_enabled: boolean;
sbp_recurring_status: string | null;
sbp_recurring_id: number | null;
is_active: boolean; is_active: boolean;
days_remaining: number; days_remaining: number;
purchased_traffic_gb: number; purchased_traffic_gb: number;
@@ -508,6 +510,14 @@ export const adminUsersApi = {
return response.data; return response.data;
}, },
// Cancel a user's SBP (Platega) recurring auto-payment
cancelSbpRecurring: async (userId: number, subId: number): Promise<{ status: string }> => {
const response = await apiClient.post(
`/cabinet/admin/users/${userId}/subscriptions/${subId}/cancel-sbp-recurring`,
);
return response.data;
},
// Update status // Update status
updateStatus: async ( updateStatus: async (
userId: number, userId: number,

View File

@@ -12,6 +12,7 @@ import type {
PurchaseSelection, PurchaseSelection,
PurchasePreview, PurchasePreview,
AppConfig, AppConfig,
SbpRecurringInfo,
} from '../types'; } from '../types';
/** Helper: build query params with optional subscription_id */ /** Helper: build query params with optional subscription_id */
@@ -349,6 +350,34 @@ export const subscriptionApi = {
return response.data; return response.data;
}, },
// ── SBP recurring (Platega) ───────────────────────────────────────────
getSbpRecurring: async (subscriptionId?: number): Promise<SbpRecurringInfo> => {
const response = await apiClient.get<SbpRecurringInfo>(
'/cabinet/subscription/platega-recurrent',
withSubId(subscriptionId),
);
return response.data;
},
enableSbpRecurring: async (
subscriptionId?: number,
): Promise<{ status: string; redirect_url: string | null }> => {
const response = await apiClient.post(
'/cabinet/subscription/platega-recurrent/enable',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
cancelSbpRecurring: async (subscriptionId?: number): Promise<{ status: string }> => {
const response = await apiClient.post(
'/cabinet/subscription/platega-recurrent/cancel',
...bodyWithSubId({}, subscriptionId),
);
return response.data;
},
// ── Trial ─────────────────────────────────────────────────────────── // ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => { getTrialInfo: async (): Promise<TrialInfo> => {

View File

@@ -7,7 +7,7 @@ import { useCallback } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; import { useWebSocket, type WSMessage } from '../hooks/useWebSocket';
import { useToast } from './Toast'; import { useToast } from './Toast';
import { useAuthStore } from '../store/auth'; import { useAuthStore } from '../store/auth';
import { useCurrency } from '../hooks/useCurrency'; import { useCurrency } from '../hooks/useCurrency';
@@ -296,6 +296,131 @@ export default function WebSocketNotifications() {
return; return;
} }
// SBP recurring events
if (type === 'sbp_recurring.confirmed') {
const amount = message.amount_rubles ?? (message.amount_kopeks ?? 0) / 100;
showToast({
type: 'success',
title: t('wsNotifications.sbpRecurring.confirmedTitle', 'SBP payment confirmed'),
message: t(
'wsNotifications.sbpRecurring.confirmedMessage',
'Payment of {{amount}} {{currency}} was charged successfully',
{
amount: formatAmount(amount),
currency: currencySymbol,
},
),
icon: <span className="text-lg">🔁</span>,
onClick: () => navigate('/subscriptions'),
duration: 8000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
refreshUser();
return;
}
if (type === 'sbp_recurring.activated') {
showToast({
type: 'success',
title: t('wsNotifications.sbpRecurring.activatedTitle', 'SBP auto-payment connected'),
message: t(
'wsNotifications.sbpRecurring.activatedMessage',
'SBP auto-payment has been successfully connected',
),
icon: <span className="text-lg">🔁</span>,
onClick: () => navigate('/subscriptions'),
duration: 8000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
refreshUser();
return;
}
if (type === 'sbp_recurring.failed') {
showToast({
type: 'error',
title: t('wsNotifications.sbpRecurring.failedTitle', 'SBP payment failed'),
message: t(
'wsNotifications.sbpRecurring.failedMessage',
'Failed to charge your SBP auto-payment',
),
icon: <span className="text-lg"></span>,
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
return;
}
if (type === 'sbp_recurring.past_due') {
showToast({
type: 'warning',
title: t('wsNotifications.sbpRecurring.pastDueTitle', 'SBP payment overdue'),
message: t(
'wsNotifications.sbpRecurring.pastDueMessage',
'Your SBP auto-payment is overdue. Please check your banking app.',
),
icon: <span className="text-lg"></span>,
onClick: () => navigate('/subscriptions'),
duration: 10000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
return;
}
if (type === 'sbp_recurring.cancelled') {
showToast({
type: 'warning',
title: t('wsNotifications.sbpRecurring.cancelledTitle', 'SBP auto-payment disabled'),
message: t(
'wsNotifications.sbpRecurring.cancelledMessage',
'SBP auto-payment has been disabled',
),
icon: <span className="text-lg">🔕</span>,
onClick: () => navigate('/subscriptions'),
duration: 8000,
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'subscription',
});
queryClient.invalidateQueries({
predicate: (query) =>
Array.isArray(query.queryKey) && query.queryKey[0] === 'sbp-recurring',
});
return;
}
// Account events // Account events
if (type === 'account.banned') { if (type === 'account.banned') {
showToast({ showToast({

View File

@@ -130,6 +130,7 @@ export interface SubscriptionTabProps {
onAddTraffic: (gb: number) => Promise<void>; onAddTraffic: (gb: number) => Promise<void>;
onRemoveTraffic: (purchaseId: number) => Promise<void>; onRemoveTraffic: (purchaseId: number) => Promise<void>;
onResetDevices: () => Promise<void>; onResetDevices: () => Promise<void>;
onCancelSbpRecurring: () => Promise<void>;
onDeleteDevice: (hwid: string) => Promise<void>; onDeleteDevice: (hwid: string) => Promise<void>;
onRenameDevice: (hwid: string) => Promise<void>; onRenameDevice: (hwid: string) => Promise<void>;
onLoadDevices: () => Promise<void>; onLoadDevices: () => Promise<void>;
@@ -193,6 +194,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
onAddTraffic, onAddTraffic,
onRemoveTraffic, onRemoveTraffic,
onResetDevices, onResetDevices,
onCancelSbpRecurring,
onDeleteDevice, onDeleteDevice,
onRenameDevice, onRenameDevice,
onLoadDevices, onLoadDevices,
@@ -226,6 +228,14 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
{sub.tariff_name || `#${sub.id}`} {sub.tariff_name || `#${sub.id}`}
</span> </span>
<StatusBadge status={sub.status} /> <StatusBadge status={sub.status} />
{sub.sbp_recurring_status && (
<span
title={t('admin.users.detail.subscription.sbpTitle')}
className="rounded-full bg-accent-500/15 px-2 py-0.5 text-[10px] font-medium text-accent-400"
>
SBP
</span>
)}
</div> </div>
<ChevronRightIcon className="h-4 w-4 text-dark-500" /> <ChevronRightIcon className="h-4 w-4 text-dark-500" />
</div> </div>
@@ -380,6 +390,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) {
</div> </div>
</div> </div>
{/* SBP (Platega) recurring auto-payment — status comes from the admin
detail response; cancel is idempotent on the backend. */}
{selectedSub.sbp_recurring_status && (
<div className="rounded-xl bg-dark-800/50 p-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-medium text-dark-200">
{t('admin.users.detail.subscription.sbpTitle')}
</div>
<div className="mt-0.5 text-xs text-dark-400">
{t(
`admin.users.detail.subscription.sbpStatus_${selectedSub.sbp_recurring_status}`,
selectedSub.sbp_recurring_status,
)}
</div>
</div>
{hasPermission('users:subscription') && (
<button
onClick={() => onInlineConfirm('cancelSbpRecurring', onCancelSbpRecurring)}
disabled={actionLoading}
className={`rounded-lg px-3 py-2 text-sm font-medium transition-all disabled:opacity-50 ${
confirmingAction === 'cancelSbpRecurring'
? 'bg-warning-500 text-white'
: 'bg-warning-500/15 text-warning-400 hover:bg-warning-500/25'
}`}
>
{confirmingAction === 'cancelSbpRecurring'
? t('admin.users.detail.actions.areYouSure')
: t('admin.users.detail.subscription.sbpCancel')}
</button>
)}
</div>
</div>
)}
{/* Traffic Packages */} {/* Traffic Packages */}
{selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && ( {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
<div className="rounded-xl bg-dark-800/50 p-4"> <div className="rounded-xl bg-dark-800/50 p-4">

View File

@@ -116,6 +116,18 @@
"insufficientTitle": "Insufficient funds", "insufficientTitle": "Insufficient funds",
"insufficientMessage": "Need {{required}} {{currency}} for renewal, but balance is {{balance}} {{currency}}" "insufficientMessage": "Need {{required}} {{currency}} for renewal, but balance is {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "SBP payment confirmed",
"confirmedMessage": "Payment of {{amount}} {{currency}} was charged successfully",
"failedTitle": "SBP payment failed",
"failedMessage": "Failed to charge your SBP auto-payment",
"pastDueTitle": "SBP payment overdue",
"pastDueMessage": "Your SBP auto-payment is overdue. Please check your banking app.",
"cancelledTitle": "SBP auto-payment disabled",
"cancelledMessage": "SBP auto-payment has been disabled",
"activatedTitle": "SBP auto-payment connected",
"activatedMessage": "SBP auto-payment has been successfully connected"
},
"account": { "account": {
"bannedTitle": "Account blocked", "bannedTitle": "Account blocked",
"bannedMessage": "Your account has been blocked", "bannedMessage": "Your account has been blocked",
@@ -663,6 +675,27 @@
}, },
"allTariffsPurchased": "All tariffs already purchased", "allTariffsPurchased": "All tariffs already purchased",
"autopay": "Auto-renew", "autopay": "Auto-renew",
"sbpRecurring": {
"title": "SBP Auto-payment",
"connect": "Connect",
"confirmInBank": "Confirm in your banking app",
"cancel": "Disable",
"confirmCancel": "Disable SBP auto-payment? Your subscription will no longer renew automatically via SBP.",
"cancelled": "SBP auto-payment disabled",
"enableError": "Failed to connect SBP auto-payment",
"statusPending": "Waiting for confirmation in your banking app",
"statusActive": "Active",
"statusPastDue": "Payment failed — please retry",
"nextCharge": "next charge {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "daily",
"week": "weekly",
"month": "monthly",
"year": "yearly"
},
"autopayHint": "Your subscription will be charged automatically via SBP, no card required"
},
"backToList": "Back to subscriptions", "backToList": "Back to subscriptions",
"confirmDelete": "Delete subscription?", "confirmDelete": "Delete subscription?",
"dailyAutoCharge": "Daily auto-charge", "dailyAutoCharge": "Daily auto-charge",
@@ -839,7 +872,10 @@
"pageTitle": "Saved Cards", "pageTitle": "Saved Cards",
"backToBalance": "Back", "backToBalance": "Back",
"empty": "No saved cards. A card will be saved automatically on your next top-up.", "empty": "No saved cards. A card will be saved automatically on your next top-up.",
"loadError": "Failed to load saved cards. Please try again later." "loadError": "Failed to load saved cards. Please try again later.",
"sbpSection": "SBP Auto-payment",
"sbpBinding": "SBP binding",
"sbpUnlink": "Disable SBP auto-payment"
}, },
"paymentReady": "Payment link is ready", "paymentReady": "Payment link is ready",
"clickToOpenPayment": "Click the button below to open the payment page in a new tab", "clickToOpenPayment": "Click the button below to open the payment page in a new tab",
@@ -3539,7 +3575,13 @@
"deviceLimitUpdated": "Device limit updated", "deviceLimitUpdated": "Device limit updated",
"invalidDays": "Please enter a valid number of days (greater than 0)", "invalidDays": "Please enter a valid number of days (greater than 0)",
"backToList": "Back to subscriptions", "backToList": "Back to subscriptions",
"createNew": "Create new" "createNew": "Create new",
"sbpTitle": "SBP Auto-payment",
"sbpCancel": "Disable",
"sbpCancelled": "SBP auto-payment disabled",
"sbpStatus_PENDING": "Pending",
"sbpStatus_ACTIVE": "Active",
"sbpStatus_PAST_DUE": "Payment failed"
}, },
"balance": { "balance": {
"current": "Current balance", "current": "Current balance",

View File

@@ -110,6 +110,18 @@
"insufficientTitle": "موجودی ناکافی", "insufficientTitle": "موجودی ناکافی",
"insufficientMessage": "برای تمدید {{required}} {{currency}} لازم است، موجودی فعلی {{balance}} {{currency}}" "insufficientMessage": "برای تمدید {{required}} {{currency}} لازم است، موجودی فعلی {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "پرداخت SBP تأیید شد",
"confirmedMessage": "مبلغ {{amount}} {{currency}} با موفقیت برداشت شد",
"failedTitle": "پرداخت SBP ناموفق بود",
"failedMessage": "برداشت پرداخت خودکار SBP ناموفق بود",
"pastDueTitle": "پرداخت SBP معوق است",
"pastDueMessage": "پرداخت خودکار SBP شما معوق است. لطفاً اپلیکیشن بانک را بررسی کنید.",
"cancelledTitle": "پرداخت خودکار SBP غیرفعال شد",
"cancelledMessage": "پرداخت خودکار SBP غیرفعال شده است",
"activatedTitle": "پرداخت خودکار SBP متصل شد",
"activatedMessage": "پرداخت خودکار SBP با موفقیت متصل شد"
},
"account": { "account": {
"bannedTitle": "حساب مسدود شد", "bannedTitle": "حساب مسدود شد",
"bannedMessage": "حساب شما مسدود شده است", "bannedMessage": "حساب شما مسدود شده است",
@@ -562,6 +574,27 @@
}, },
"allTariffsPurchased": "همه تعرفه‌ها قبلاً خریداری شده‌اند", "allTariffsPurchased": "همه تعرفه‌ها قبلاً خریداری شده‌اند",
"autopay": "تمدید خودکار", "autopay": "تمدید خودکار",
"sbpRecurring": {
"title": "پرداخت خودکار SBP",
"connect": "اتصال",
"confirmInBank": "در اپلیکیشن بانک تأیید کنید",
"cancel": "غیرفعال کردن",
"confirmCancel": "پرداخت خودکار SBP غیرفعال شود؟ اشتراک شما دیگر به‌صورت خودکار از طریق SBP تمدید نخواهد شد.",
"cancelled": "پرداخت خودکار SBP غیرفعال شد",
"enableError": "اتصال پرداخت خودکار SBP ناموفق بود",
"statusPending": "در انتظار تأیید در اپلیکیشن بانک",
"statusActive": "فعال",
"statusPastDue": "پرداخت ناموفق بود — دوباره تلاش کنید",
"nextCharge": "برداشت بعدی {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "روزانه",
"week": "هفتگی",
"month": "ماهانه",
"year": "سالانه"
},
"autopayHint": "اشتراک شما به‌صورت خودکار از طریق SBP بدون نیاز به کارت ذخیره‌شده شارژ می‌شود"
},
"backToList": "بازگشت به فهرست اشتراک‌ها", "backToList": "بازگشت به فهرست اشتراک‌ها",
"confirmDelete": "اشتراک حذف شود؟", "confirmDelete": "اشتراک حذف شود؟",
"cta": { "cta": {
@@ -727,7 +760,10 @@
"pageTitle": "کارت‌های ذخیره شده", "pageTitle": "کارت‌های ذخیره شده",
"backToBalance": "بازگشت", "backToBalance": "بازگشت",
"empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود.", "empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود.",
"loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید." "loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید.",
"sbpSection": "پرداخت خودکار SBP",
"sbpBinding": "اتصال SBP",
"sbpUnlink": "غیرفعال کردن پرداخت خودکار SBP"
}, },
"paymentSuccess": { "paymentSuccess": {
"title": "پرداخت موفق", "title": "پرداخت موفق",
@@ -3010,7 +3046,13 @@
"deviceLimitUpdated": "محدودیت دستگاه به‌روز شد", "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد",
"invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)", "invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)",
"backToList": "بازگشت به فهرست اشتراک‌ها", "backToList": "بازگشت به فهرست اشتراک‌ها",
"createNew": "ایجاد جدید" "createNew": "ایجاد جدید",
"sbpTitle": "پرداخت خودکار SBP",
"sbpCancel": "غیرفعال کردن",
"sbpCancelled": "پرداخت خودکار SBP غیرفعال شد",
"sbpStatus_PENDING": "در انتظار",
"sbpStatus_ACTIVE": "فعال",
"sbpStatus_PAST_DUE": "پرداخت ناموفق"
}, },
"balance": { "balance": {
"current": "موجودی فعلی", "current": "موجودی فعلی",

View File

@@ -119,6 +119,18 @@
"insufficientTitle": "Недостаточно средств", "insufficientTitle": "Недостаточно средств",
"insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}" "insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "Платёж по СБП подтверждён",
"confirmedMessage": "Списано {{amount}} {{currency}}",
"failedTitle": "Ошибка платежа по СБП",
"failedMessage": "Не удалось списать платёж по СБП",
"pastDueTitle": "Просрочен платёж по СБП",
"pastDueMessage": "Платёж по СБП просрочен. Проверьте приложение банка.",
"cancelledTitle": "Автоплатёж по СБП отключён",
"cancelledMessage": "Автоплатёж по СБП был отключён",
"activatedTitle": "Автоплатёж по СБП подключён",
"activatedMessage": "Автоплатёж по СБП успешно подключён"
},
"account": { "account": {
"bannedTitle": "Аккаунт заблокирован", "bannedTitle": "Аккаунт заблокирован",
"bannedMessage": "Ваш аккаунт был заблокирован", "bannedMessage": "Ваш аккаунт был заблокирован",
@@ -682,6 +694,27 @@
}, },
"allTariffsPurchased": "Все тарифы уже куплены", "allTariffsPurchased": "Все тарифы уже куплены",
"autopay": "Автопродление", "autopay": "Автопродление",
"sbpRecurring": {
"title": "Автоплатёж по СБП",
"connect": "Подключить",
"confirmInBank": "Подтвердите в приложении банка",
"cancel": "Отключить",
"confirmCancel": "Отключить автоплатёж по СБП? Подписка больше не будет продлеваться автоматически через СБП.",
"cancelled": "Автоплатёж по СБП отключён",
"enableError": "Не удалось подключить автоплатёж по СБП",
"statusPending": "Ожидает подтверждения в приложении банка",
"statusActive": "Активен",
"statusPastDue": "Платёж не прошёл — попробуйте снова",
"nextCharge": "следующее списание {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "ежедневно",
"week": "еженедельно",
"month": "ежемесячно",
"year": "ежегодно"
},
"autopayHint": "Подписка будет автоматически продлеваться через СБП без сохранённой карты"
},
"backToList": "К списку подписок", "backToList": "К списку подписок",
"confirmDelete": "Удалить подписку?", "confirmDelete": "Удалить подписку?",
"dailyAutoCharge": "Ежедневное списание", "dailyAutoCharge": "Ежедневное списание",
@@ -858,7 +891,10 @@
"pageTitle": "Сохранённые карты", "pageTitle": "Сохранённые карты",
"backToBalance": "Назад", "backToBalance": "Назад",
"empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.", "empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.",
"loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже." "loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже.",
"sbpSection": "Автоплатёж по СБП",
"sbpBinding": "Привязка СБП",
"sbpUnlink": "Отключить автоплатёж по СБП"
}, },
"paymentReady": "Ссылка на оплату готова", "paymentReady": "Ссылка на оплату готова",
"clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке", "clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке",
@@ -3936,7 +3972,13 @@
"deviceLimitUpdated": "Лимит устройств обновлён", "deviceLimitUpdated": "Лимит устройств обновлён",
"invalidDays": "Введите корректное количество дней (больше 0)", "invalidDays": "Введите корректное количество дней (больше 0)",
"backToList": "К списку подписок", "backToList": "К списку подписок",
"createNew": "Создать новую" "createNew": "Создать новую",
"sbpTitle": "Автоплатёж по СБП",
"sbpCancel": "Отключить",
"sbpCancelled": "Автоплатёж по СБП отключён",
"sbpStatus_PENDING": "Ожидает подтверждения",
"sbpStatus_ACTIVE": "Активен",
"sbpStatus_PAST_DUE": "Платёж не прошёл"
}, },
"balance": { "balance": {
"current": "Текущий баланс", "current": "Текущий баланс",

View File

@@ -110,6 +110,18 @@
"insufficientTitle": "余额不足", "insufficientTitle": "余额不足",
"insufficientMessage": "续期需要 {{required}} {{currency}},当前余额 {{balance}} {{currency}}" "insufficientMessage": "续期需要 {{required}} {{currency}},当前余额 {{balance}} {{currency}}"
}, },
"sbpRecurring": {
"confirmedTitle": "SBP 扣款已确认",
"confirmedMessage": "已成功扣款 {{amount}} {{currency}}",
"failedTitle": "SBP 扣款失败",
"failedMessage": "SBP 自动扣款失败",
"pastDueTitle": "SBP 扣款已逾期",
"pastDueMessage": "您的 SBP 自动扣款已逾期,请检查银行应用。",
"cancelledTitle": "SBP 自动扣款已关闭",
"cancelledMessage": "SBP 自动扣款已被关闭",
"activatedTitle": "SBP 自动扣款已开通",
"activatedMessage": "SBP 自动扣款已成功开通"
},
"account": { "account": {
"bannedTitle": "账户已封禁", "bannedTitle": "账户已封禁",
"bannedMessage": "您的账户已被封禁", "bannedMessage": "您的账户已被封禁",
@@ -562,6 +574,27 @@
}, },
"allTariffsPurchased": "所有套餐已购买", "allTariffsPurchased": "所有套餐已购买",
"autopay": "自动续订", "autopay": "自动续订",
"sbpRecurring": {
"title": "SBP 自动扣款",
"connect": "开通",
"confirmInBank": "请在银行应用中确认",
"cancel": "关闭",
"confirmCancel": "关闭 SBP 自动扣款?您的订阅将不再通过 SBP 自动续订。",
"cancelled": "SBP 自动扣款已关闭",
"enableError": "开通 SBP 自动扣款失败",
"statusPending": "等待银行应用确认",
"statusActive": "已启用",
"statusPastDue": "扣款失败 — 请重试",
"nextCharge": "下次扣款 {{date}}",
"amountPerInterval": "{{amount}} ₽ {{interval}}",
"interval": {
"day": "每天",
"week": "每周",
"month": "每月",
"year": "每年"
},
"autopayHint": "您的订阅将通过 SBP 自动扣款,无需保存银行卡"
},
"backToList": "返回订阅列表", "backToList": "返回订阅列表",
"confirmDelete": "删除订阅?", "confirmDelete": "删除订阅?",
"cta": { "cta": {
@@ -727,7 +760,10 @@
"pageTitle": "已保存的银行卡", "pageTitle": "已保存的银行卡",
"backToBalance": "返回", "backToBalance": "返回",
"empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。", "empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。",
"loadError": "加载银行卡失败,请稍后重试。" "loadError": "加载银行卡失败,请稍后重试。",
"sbpSection": "SBP 自动扣款",
"sbpBinding": "SBP 绑定",
"sbpUnlink": "关闭 SBP 自动扣款"
}, },
"paymentSuccess": { "paymentSuccess": {
"title": "支付成功", "title": "支付成功",
@@ -3009,7 +3045,13 @@
"deviceLimitUpdated": "设备限制已更新", "deviceLimitUpdated": "设备限制已更新",
"invalidDays": "请输入有效天数大于0", "invalidDays": "请输入有效天数大于0",
"backToList": "返回订阅列表", "backToList": "返回订阅列表",
"createNew": "新建" "createNew": "新建",
"sbpTitle": "SBP 自动扣款",
"sbpCancel": "关闭",
"sbpCancelled": "SBP 自动扣款已关闭",
"sbpStatus_PENDING": "待确认",
"sbpStatus_ACTIVE": "已启用",
"sbpStatus_PAST_DUE": "扣款失败"
}, },
"balance": { "balance": {
"current": "当前余额", "current": "当前余额",

View File

@@ -648,6 +648,20 @@ export default function AdminUserDetail() {
} }
}; };
const handleCancelSbpRecurring = async () => {
if (!userId || !selectedSub?.sbp_recurring_id) return;
setActionLoading(true);
try {
await adminUsersApi.cancelSbpRecurring(userId, selectedSub.id);
notify.success(t('admin.users.detail.subscription.sbpCancelled'), t('common.success'));
await loadUser();
} catch {
notify.error(t('admin.users.userActions.error'), t('common.error'));
} finally {
setActionLoading(false);
}
};
const handleDisableUser = async () => { const handleDisableUser = async () => {
if (!userId) return; if (!userId) return;
setActionLoading(true); setActionLoading(true);
@@ -713,7 +727,7 @@ export default function AdminUserDetail() {
const k = 1024; const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`;
}; };
const copyToClipboard = async (text: string) => { const copyToClipboard = async (text: string) => {
@@ -859,6 +873,7 @@ export default function AdminUserDetail() {
<SubscriptionTab <SubscriptionTab
userSubscriptions={userSubscriptions} userSubscriptions={userSubscriptions}
selectedSub={selectedSub} selectedSub={selectedSub}
onCancelSbpRecurring={handleCancelSbpRecurring}
activeSubscriptionId={activeSubscriptionId} activeSubscriptionId={activeSubscriptionId}
onActiveSubscriptionChange={setActiveSubscriptionId} onActiveSubscriptionChange={setActiveSubscriptionId}
subscriptionDetailView={subscriptionDetailView} subscriptionDetailView={subscriptionDetailView}

View File

@@ -1,13 +1,15 @@
import { uiLocale } from '@/utils/uiLocale'; import { uiLocale } from '@/utils/uiLocale';
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueries, useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { balanceApi } from '../api/balance'; import { balanceApi } from '../api/balance';
import { subscriptionApi } from '../api/subscription';
import { useToast } from '../components/Toast'; import { useToast } from '../components/Toast';
import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog';
import type { SbpRecurringInfo, SubscriptionListItem } from '../types';
import { Card } from '@/components/data-display/Card'; import { Card } from '@/components/data-display/Card';
import { Button } from '@/components/primitives/Button'; import { Button } from '@/components/primitives/Button';
@@ -24,6 +26,25 @@ function formatCardDate(dateStr: string): string {
} }
} }
/** Human-readable locale key for an SBP binding status (mirrors Subscription.tsx). */
function sbpStatusLabelKey(status: string): string | null {
switch (status) {
case 'PENDING':
return 'subscription.sbpRecurring.statusPending';
case 'ACTIVE':
return 'subscription.sbpRecurring.statusActive';
case 'PAST_DUE':
return 'subscription.sbpRecurring.statusPastDue';
default:
return null;
}
}
interface SbpBinding {
sub: SubscriptionListItem;
info: SbpRecurringInfo;
}
export default function SavedCards() { export default function SavedCards() {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -73,6 +94,67 @@ export default function SavedCards() {
} }
}; };
// ── SBP (Platega) recurring bindings ────────────────────────────────
// Same query-key convention as the Subscription page (['sbp-recurring', subId])
// so the caches are shared and don't refetch redundantly when navigating
// between the two pages.
const { data: subscriptionsData } = useQuery({
queryKey: ['subscriptions-list'],
queryFn: subscriptionApi.getSubscriptions,
});
const nonTrialSubs = (subscriptionsData?.subscriptions ?? []).filter((sub) => !sub.is_trial);
const sbpQueries = useQueries({
queries: nonTrialSubs.map((sub) => ({
queryKey: ['sbp-recurring', sub.id],
queryFn: () => subscriptionApi.getSbpRecurring(sub.id),
retry: false,
})),
});
// No section at all when nothing is bound: either the feature is off
// (every query 403s) or none of the subscriptions has an active binding.
const sbpBindings: SbpBinding[] = nonTrialSubs.reduce<SbpBinding[]>((acc, sub, index) => {
const info = sbpQueries[index]?.data;
if (info && info.status !== 'none') {
acc.push({ sub, info });
}
return acc;
}, []);
const [unlinkingSubId, setUnlinkingSubId] = useState<number | null>(null);
const confirmUnlinkSbp = useDestructiveConfirm();
const handleUnlinkSbp = async (subId: number) => {
if (unlinkingSubId !== null) return;
const confirmed = await confirmUnlinkSbp(
t('subscription.sbpRecurring.confirmCancel'),
t('subscription.sbpRecurring.cancel'),
);
if (!confirmed) return;
setUnlinkingSubId(subId);
try {
await subscriptionApi.cancelSbpRecurring(subId);
await queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subId] });
showToast({
type: 'success',
title: t('subscription.sbpRecurring.cancelled'),
message: '',
duration: 3000,
});
} catch (error) {
console.error('Failed to unlink SBP binding:', error);
showToast({
type: 'error',
title: t('common.error'),
message: '',
duration: 3000,
});
} finally {
setUnlinkingSubId(null);
}
};
return ( return (
// key: remount the container when loading resolves — stagger orchestration // key: remount the container when loading resolves — stagger orchestration
// runs once on mount, so cards arriving from the API later would otherwise // runs once on mount, so cards arriving from the API later would otherwise
@@ -184,6 +266,62 @@ export default function SavedCards() {
</Card> </Card>
</motion.div> </motion.div>
) : null} ) : null}
{/* SBP (Platega) recurring bindings — convenience mirror of the
per-subscription block on the Subscription page. Rendered only
when at least one non-trial subscription has an active binding;
hidden entirely when the feature is off or nothing is bound. */}
{sbpBindings.length > 0 && (
<motion.div variants={staggerItem}>
<Card>
<h2 className="mb-3 text-sm font-semibold text-dark-100">
{t('balance.savedCards.sbpSection')}
</h2>
<div className="space-y-3">
{sbpBindings.map(({ sub, info }) => {
const statusKey = sbpStatusLabelKey(info.status);
return (
<div
key={sub.id}
className="flex items-center justify-between rounded-linear border border-dark-700/30 bg-dark-800/30 p-4"
>
<div className="flex items-center gap-3">
<span className="text-xl">🔁</span>
<div>
<div className="font-medium text-dark-100">
{sub.tariff_name || `#${sub.id}`}
</div>
<div className="text-xs text-dark-500">
{t('balance.savedCards.sbpBinding')}
{statusKey ? ` · ${t(statusKey)}` : ''}
{info.next_charge_at
? ` · ${t('subscription.sbpRecurring.nextCharge', {
date: new Date(info.next_charge_at).toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
})}`
: ''}
</div>
</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleUnlinkSbp(sub.id)}
loading={unlinkingSubId === sub.id}
className="text-error-400 hover:text-error-300"
>
{t('balance.savedCards.sbpUnlink')}
</Button>
</div>
);
})}
</div>
</Card>
</motion.div>
)}
</motion.div> </motion.div>
); );
} }

View File

@@ -28,13 +28,21 @@ import {
DownloadIcon, DownloadIcon,
TrashIcon, TrashIcon,
} from '../components/icons'; } from '../components/icons';
import { useHaptic } from '../platform'; import { useHaptic, usePlatform } from '../platform';
import { resolveConnectionUrlForUi } from '../utils/connectionLink'; import { resolveConnectionUrlForUi } from '../utils/connectionLink';
import { import {
getErrorMessage, getErrorMessage,
getInsufficientBalanceError, getInsufficientBalanceError,
getFlagEmoji, getFlagEmoji,
} from '../utils/subscriptionHelpers'; } from '../utils/subscriptionHelpers';
import { openPaymentUrl } from '../utils/openPaymentUrl';
import { useToast } from '../components/Toast';
import {
isSbpFeatureDisabledError,
sbpIntervalLabelKey,
sbpUiState,
type SbpUiState,
} from '../utils/sbpRecurring';
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';
@@ -194,6 +202,8 @@ export default function Subscription() {
const { isDark } = useTheme(); const { isDark } = useTheme();
const g = getGlassColors(isDark); const g = getGlassColors(isDark);
const haptic = useHaptic(); const haptic = useHaptic();
const { openLink, platform } = usePlatform();
const { showToast } = useToast();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [showDeleteSheet, setShowDeleteSheet] = useState(false); const [showDeleteSheet, setShowDeleteSheet] = useState(false);
const destructiveConfirm = useDestructiveConfirm(); const destructiveConfirm = useDestructiveConfirm();
@@ -290,12 +300,80 @@ export default function Subscription() {
const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs'; const isTariffsMode = purchaseOptions?.sales_mode === 'tariffs';
// SBP (Platega) recurring auto-payment status. Polls every 8s while a
// payment is PENDING (waiting for bank-app confirmation) so the UI flips
// to 'active'/'past_due' without a manual refresh; stops polling otherwise.
const sbpQuery = useQuery({
queryKey: ['sbp-recurring', subscriptionId],
queryFn: () => subscriptionApi.getSbpRecurring(subscriptionId),
enabled: !!subscription && !subscription.is_trial,
retry: false,
refetchInterval: (query) => (query.state.data?.status === 'PENDING' ? 8000 : false),
});
const sbpInfo = sbpQuery.data;
// 403 with a specific detail means the feature itself is disabled on the
// backend — distinct from "not resolved yet" or "other error", both of
// which must fail quiet (render nothing) rather than flash the 'off' state.
const sbpFeatureDisabled = isSbpFeatureDisabledError(sbpQuery.error);
const sbpUiStateValue: SbpUiState =
sbpInfo !== undefined || sbpFeatureDisabled
? sbpUiState(sbpInfo, sbpFeatureDisabled)
: 'hidden';
const enableSbpMutation = useMutation({
mutationFn: () => subscriptionApi.enableSbpRecurring(subscriptionId),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] });
// Backend flips autopay_enabled off when SBP auto-pay is 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.sbpRecurring.enableError'),
message: '',
duration: 3000,
});
},
});
const cancelSbpMutation = useMutation({
mutationFn: () => subscriptionApi.cancelSbpRecurring(subscriptionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
showToast({
type: 'success',
title: t('subscription.sbpRecurring.cancelled'),
message: '',
duration: 3000,
});
},
});
const handleCancelSbp = async () => {
const confirmed = await destructiveConfirm(
t('subscription.sbpRecurring.confirmCancel'),
t('subscription.sbpRecurring.cancel'),
);
if (!confirmed) return;
cancelSbpMutation.mutate();
};
const autopayMutation = useMutation({ const autopayMutation = useMutation({
mutationFn: (enabled: boolean) => mutationFn: (enabled: boolean) =>
subscriptionApi.updateAutopay(enabled, undefined, subscriptionId), subscriptionApi.updateAutopay(enabled, undefined, subscriptionId),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] }); queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
// Enabling balance-autopay cancels SBP auto-pay server-side — refresh so
// the SBP block doesn't keep showing a now-stale 'active'/'pending' state.
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', subscriptionId] });
}, },
}); });
@@ -1076,6 +1154,115 @@ export default function Subscription() {
</button> </button>
</div> </div>
)} )}
{/* ─── SBP Recurring Auto-payment ───
Sibling of the autopay toggle above, guarded ONLY by
is_trial + uiState — daily-tariff subscriptions must see
this block too (backend supports a day-interval charge). */}
{!subscription.is_trial && sbpUiStateValue !== 'hidden' && (
<div
className="rounded-[14px] p-3.5"
style={{
background: g.innerBg,
border: `1px solid ${g.innerBorder}`,
}}
>
<div className="text-sm font-semibold text-dark-50">
{t('subscription.sbpRecurring.title')}
</div>
{sbpUiStateValue === 'off' && (
<>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.autopayHint')}
</div>
<button
onClick={() => enableSbpMutation.mutate()}
disabled={enableSbpMutation.isPending}
className="mt-3 w-full rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-medium text-on-accent transition-opacity disabled:opacity-50"
>
{enableSbpMutation.isPending ? (
<span className="mx-auto block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
) : (
t('subscription.sbpRecurring.connect')
)}
</button>
</>
)}
{sbpUiStateValue === 'pending' && (
<>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.statusPending')}
</div>
{sbpInfo?.redirect_url && (
<button
onClick={() => {
if (sbpInfo.redirect_url) {
openPaymentUrl(sbpInfo.redirect_url, platform, openLink);
}
}}
className="mt-3 w-full rounded-xl bg-accent-500 px-4 py-2.5 text-sm font-medium text-on-accent transition-opacity"
>
{t('subscription.sbpRecurring.confirmInBank')}
</button>
)}
<button
onClick={handleCancelSbp}
disabled={cancelSbpMutation.isPending}
className="mt-2 text-[11px] font-medium transition-colors disabled:opacity-50"
style={{ color: 'rgb(var(--color-critical-500))' }}
>
{t('subscription.sbpRecurring.cancel')}
</button>
</>
)}
{sbpUiStateValue === 'active' && sbpInfo && (
<>
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.amountPerInterval', {
amount: formatAmount((sbpInfo.amount_kopeks ?? 0) / 100),
interval: t(sbpIntervalLabelKey(sbpInfo.interval)),
})}
</div>
{sbpInfo.next_charge_at && (
<div className="mt-0.5 text-[11px] text-dark-50/30">
{t('subscription.sbpRecurring.nextCharge', {
date: new Date(sbpInfo.next_charge_at).toLocaleDateString(uiLocale(), {
day: '2-digit',
month: '2-digit',
year: 'numeric',
}),
})}
</div>
)}
<button
onClick={handleCancelSbp}
disabled={cancelSbpMutation.isPending}
className="mt-3 w-full rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
>
{t('subscription.sbpRecurring.cancel')}
</button>
</>
)}
{sbpUiStateValue === 'past_due' && (
<>
<div className="mt-0.5 text-[11px] font-medium text-warning-400">
{t('subscription.sbpRecurring.statusPastDue')}
</div>
<button
onClick={handleCancelSbp}
disabled={cancelSbpMutation.isPending}
className="mt-3 w-full rounded-xl border border-error-500/30 bg-error-500/10 px-4 py-2.5 text-sm font-medium text-error-400 transition-colors hover:bg-error-500/20 disabled:opacity-50"
>
{t('subscription.sbpRecurring.cancel')}
</button>
</>
)}
</div>
)}
</div> </div>
); );
})() })()

View File

@@ -21,6 +21,9 @@ export interface WSMessage {
new_expires_at?: string; new_expires_at?: string;
tariff_name?: string; tariff_name?: string;
days_left?: number; days_left?: number;
// SBP recurring events
status?: string;
next_charge_at?: string | null;
// Device purchase events // Device purchase events
devices_added?: number; devices_added?: number;
new_device_limit?: number; new_device_limit?: number;

View File

@@ -649,6 +649,15 @@ export interface SavedCardsResponse {
recurrent_enabled: boolean; recurrent_enabled: boolean;
} }
// Platega SBP recurring auto-payment status for a subscription
export interface SbpRecurringInfo {
status: string; // 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE'
interval?: number; // 1=day,2=week,3=month,4=year
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,109 @@
import { describe, expect, it } from 'vitest';
import type { SbpRecurringInfo } from '../types';
import { isSbpFeatureDisabledError, sbpIntervalLabelKey, sbpUiState } from './sbpRecurring';
/**
* Утилиты для СБП-автопродления (Platega recurrent). Бэк отдаёт GET-статусы
* только 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' (CANCELLED/FAILED — это
* терминальные состояния, до которых GET не доживает: запись либо удаляется,
* либо переоткрывается новым PENDING). UI на неизвестный статус должен
* деградировать в 'off', а не падать.
*/
describe('sbpIntervalLabelKey', () => {
it.each([
[1, 'subscription.sbpRecurring.interval.day'],
[2, 'subscription.sbpRecurring.interval.week'],
[3, 'subscription.sbpRecurring.interval.month'],
[4, 'subscription.sbpRecurring.interval.year'],
[undefined, 'subscription.sbpRecurring.interval.month'],
[99, 'subscription.sbpRecurring.interval.month'],
])('interval=%s -> %s', (interval, expected) => {
expect(sbpIntervalLabelKey(interval as number | undefined)).toBe(expected);
});
});
describe('isSbpFeatureDisabledError', () => {
it('403 + detail ровно "Platega recurrent disabled" -> true', () => {
const error = {
response: { status: 403, data: { detail: 'Platega recurrent disabled' } },
};
expect(isSbpFeatureDisabledError(error)).toBe(true);
});
it('неверный статус (404) -> false', () => {
const error = {
response: { status: 404, data: { detail: 'Platega recurrent disabled' } },
};
expect(isSbpFeatureDisabledError(error)).toBe(false);
});
it('неверный текст detail -> false', () => {
const error = {
response: { status: 403, data: { detail: 'Something else' } },
};
expect(isSbpFeatureDisabledError(error)).toBe(false);
});
it('detail — объект (другая форма 403), не должно падать -> false', () => {
const error = {
response: { status: 403, data: { detail: { code: 'blacklisted', message: 'x' } } },
};
expect(isSbpFeatureDisabledError(error)).toBe(false);
});
it('null error -> false, без исключений', () => {
expect(isSbpFeatureDisabledError(null)).toBe(false);
});
it('произвольный не-axios объект -> false, без исключений', () => {
expect(isSbpFeatureDisabledError({ foo: 'bar' })).toBe(false);
});
it('undefined -> false', () => {
expect(isSbpFeatureDisabledError(undefined)).toBe(false);
});
it('строка вместо объекта ошибки -> false', () => {
expect(isSbpFeatureDisabledError('plain string error')).toBe(false);
});
it('response без data -> false, без исключений', () => {
expect(isSbpFeatureDisabledError({ response: { status: 403 } })).toBe(false);
});
});
describe('sbpUiState', () => {
const info = (overrides: Partial<SbpRecurringInfo>): SbpRecurringInfo => ({
status: 'none',
...overrides,
});
it('фича отключена -> "hidden", даже если данные есть', () => {
expect(sbpUiState(info({ status: 'ACTIVE' }), true)).toBe('hidden');
});
it('info === undefined -> "off"', () => {
expect(sbpUiState(undefined, false)).toBe('off');
});
it('status \'none\' -> "off"', () => {
expect(sbpUiState(info({ status: 'none' }), false)).toBe('off');
});
it('status \'PENDING\' -> "pending"', () => {
expect(sbpUiState(info({ status: 'PENDING' }), false)).toBe('pending');
});
it('status \'ACTIVE\' -> "active"', () => {
expect(sbpUiState(info({ status: 'ACTIVE' }), false)).toBe('active');
});
it('status \'PAST_DUE\' -> "past_due"', () => {
expect(sbpUiState(info({ status: 'PAST_DUE' }), false)).toBe('past_due');
});
it('неизвестный статус \'CANCELLED\' -> "off"', () => {
expect(sbpUiState(info({ status: 'CANCELLED' }), false)).toBe('off');
});
});

74
src/utils/sbpRecurring.ts Normal file
View File

@@ -0,0 +1,74 @@
import type { SbpRecurringInfo } from '../types';
/**
* Локализационный ключ для интервала списания СБП-автоплатежа.
* Бэк присылает 1=день, 2=неделя, 3=месяц, 4=год; неизвестное/отсутствующее
* значение деградирует в "месяц" — самый частый и безопасный дефолт.
*/
export function sbpIntervalLabelKey(interval: number | undefined): string {
switch (interval) {
case 1:
return 'subscription.sbpRecurring.interval.day';
case 2:
return 'subscription.sbpRecurring.interval.week';
case 3:
return 'subscription.sbpRecurring.interval.month';
case 4:
return 'subscription.sbpRecurring.interval.year';
default:
return 'subscription.sbpRecurring.interval.month';
}
}
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === 'object' && value !== null;
}
/**
* true, если ошибка — это ровно "фича СБП-автоплатежа выключена на бэке"
* (403 с detail==='Platega recurrent disabled'). На бэке этот же 403-статус
* используют и другие guard'ы с detail-объектом (см. isBlacklistedError и
* соседей в api/client.ts) — поэтому сравнение строгое, без падения на любой
* форме detail (строка/объект/отсутствует).
*/
export function isSbpFeatureDisabledError(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 === 'Platega recurrent disabled';
}
export type SbpUiState = 'hidden' | 'off' | 'pending' | 'active' | 'past_due';
/**
* Сводит статус СБП-автоплатежа к состоянию UI. Бэк отдаёт из GET только
* 'none' | 'PENDING' | 'ACTIVE' | 'PAST_DUE' — CANCELLED/FAILED никогда не
* возвращаются (терминальные состояния), но на всякий случай любой
* нераспознанный статус тоже деградирует в 'off', а не падает/подвисает.
*/
export function sbpUiState(
info: SbpRecurringInfo | undefined,
featureDisabled: boolean,
): SbpUiState {
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';
}
}