mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
Merge branch 'feat/platega-sbp-recurring' into dev: СБП-автопродление Platega в кабинете
Тоггл/статус/отмена на странице подписки (включая суточные тарифы), СБП-привязки в сохранённых методах, WS-уведомления о событиях списаний, статус и отмена автооплаты в админ-карточке юзера. Работает только при включённом PLATEGA_RECURRENT_ENABLED на бэкенде (403 = блок скрыт).
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "موجودی فعلی",
|
||||||
|
|||||||
@@ -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": "Текущий баланс",
|
||||||
|
|||||||
@@ -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": "当前余额",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})()
|
})()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
109
src/utils/sbpRecurring.test.ts
Normal file
109
src/utils/sbpRecurring.test.ts
Normal 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
74
src/utils/sbpRecurring.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user