From 9093aec207bbc41e07433d55a90f9464c33a4b39 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Jul 2026 02:14:41 +0300 Subject: [PATCH 1/5] =?UTF-8?q?feat(subscription):=20API=20=D0=B8=20=D1=83?= =?UTF-8?q?=D1=82=D0=B8=D0=BB=D0=B8=D1=82=D1=8B=20=D0=A1=D0=91=D0=9F-?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=BE=D0=B4=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20Platega?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/adminUsers.ts | 10 +++ src/api/subscription.ts | 29 +++++++++ src/locales/en.json | 46 +++++++++++++- src/locales/fa.json | 46 +++++++++++++- src/locales/ru.json | 46 +++++++++++++- src/locales/zh.json | 46 +++++++++++++- src/types/index.ts | 9 +++ src/utils/sbpRecurring.test.ts | 109 +++++++++++++++++++++++++++++++++ src/utils/sbpRecurring.ts | 74 ++++++++++++++++++++++ 9 files changed, 407 insertions(+), 8 deletions(-) create mode 100644 src/utils/sbpRecurring.test.ts create mode 100644 src/utils/sbpRecurring.ts diff --git a/src/api/adminUsers.ts b/src/api/adminUsers.ts index 1bc20f2..ef47cda 100644 --- a/src/api/adminUsers.ts +++ b/src/api/adminUsers.ts @@ -21,6 +21,8 @@ export interface UserSubscriptionInfo { tariff_id: number | null; tariff_name: string | null; autopay_enabled: boolean; + sbp_recurring_status: string | null; + sbp_recurring_id: number | null; is_active: boolean; days_remaining: number; purchased_traffic_gb: number; @@ -508,6 +510,14 @@ export const adminUsersApi = { 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 updateStatus: async ( userId: number, diff --git a/src/api/subscription.ts b/src/api/subscription.ts index 86f744a..b15d44d 100644 --- a/src/api/subscription.ts +++ b/src/api/subscription.ts @@ -12,6 +12,7 @@ import type { PurchaseSelection, PurchasePreview, AppConfig, + SbpRecurringInfo, } from '../types'; /** Helper: build query params with optional subscription_id */ @@ -349,6 +350,34 @@ export const subscriptionApi = { return response.data; }, + // ── SBP recurring (Platega) ─────────────────────────────────────────── + + getSbpRecurring: async (subscriptionId?: number): Promise => { + const response = await apiClient.get( + '/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 ─────────────────────────────────────────────────────────── getTrialInfo: async (): Promise => { diff --git a/src/locales/en.json b/src/locales/en.json index 774cbb4..9847458 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -116,6 +116,18 @@ "insufficientTitle": "Insufficient funds", "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": { "bannedTitle": "Account blocked", "bannedMessage": "Your account has been blocked", @@ -663,6 +675,27 @@ }, "allTariffsPurchased": "All tariffs already purchased", "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", "confirmDelete": "Delete subscription?", "dailyAutoCharge": "Daily auto-charge", @@ -839,7 +872,10 @@ "pageTitle": "Saved Cards", "backToBalance": "Back", "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", "clickToOpenPayment": "Click the button below to open the payment page in a new tab", @@ -3539,7 +3575,13 @@ "deviceLimitUpdated": "Device limit updated", "invalidDays": "Please enter a valid number of days (greater than 0)", "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": { "current": "Current balance", diff --git a/src/locales/fa.json b/src/locales/fa.json index 518fe03..168b8ad 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -110,6 +110,18 @@ "insufficientTitle": "موجودی ناکافی", "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": { "bannedTitle": "حساب مسدود شد", "bannedMessage": "حساب شما مسدود شده است", @@ -562,6 +574,27 @@ }, "allTariffsPurchased": "همه تعرفه‌ها قبلاً خریداری شده‌اند", "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": "بازگشت به فهرست اشتراک‌ها", "confirmDelete": "اشتراک حذف شود؟", "cta": { @@ -727,7 +760,10 @@ "pageTitle": "کارت‌های ذخیره شده", "backToBalance": "بازگشت", "empty": "کارتی ذخیره نشده است. کارت به طور خودکار در شارژ بعدی ذخیره می‌شود.", - "loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید." + "loadError": "خطا در بارگذاری کارت‌ها. لطفاً بعداً دوباره امتحان کنید.", + "sbpSection": "پرداخت خودکار SBP", + "sbpBinding": "اتصال SBP", + "sbpUnlink": "غیرفعال کردن پرداخت خودکار SBP" }, "paymentSuccess": { "title": "پرداخت موفق", @@ -3010,7 +3046,13 @@ "deviceLimitUpdated": "محدودیت دستگاه به‌روز شد", "invalidDays": "لطفاً تعداد روز معتبر وارد کنید (بیشتر از ۰)", "backToList": "بازگشت به فهرست اشتراک‌ها", - "createNew": "ایجاد جدید" + "createNew": "ایجاد جدید", + "sbpTitle": "پرداخت خودکار SBP", + "sbpCancel": "غیرفعال کردن", + "sbpCancelled": "پرداخت خودکار SBP غیرفعال شد", + "sbpStatus_PENDING": "در انتظار", + "sbpStatus_ACTIVE": "فعال", + "sbpStatus_PAST_DUE": "پرداخت ناموفق" }, "balance": { "current": "موجودی فعلی", diff --git a/src/locales/ru.json b/src/locales/ru.json index 5424442..30ee69f 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -119,6 +119,18 @@ "insufficientTitle": "Недостаточно средств", "insufficientMessage": "Для продления нужно {{required}} {{currency}}, на балансе {{balance}} {{currency}}" }, + "sbpRecurring": { + "confirmedTitle": "Платёж по СБП подтверждён", + "confirmedMessage": "Списано {{amount}} {{currency}}", + "failedTitle": "Ошибка платежа по СБП", + "failedMessage": "Не удалось списать платёж по СБП", + "pastDueTitle": "Просрочен платёж по СБП", + "pastDueMessage": "Платёж по СБП просрочен. Проверьте приложение банка.", + "cancelledTitle": "Автоплатёж по СБП отключён", + "cancelledMessage": "Автоплатёж по СБП был отключён", + "activatedTitle": "Автоплатёж по СБП подключён", + "activatedMessage": "Автоплатёж по СБП успешно подключён" + }, "account": { "bannedTitle": "Аккаунт заблокирован", "bannedMessage": "Ваш аккаунт был заблокирован", @@ -682,6 +694,27 @@ }, "allTariffsPurchased": "Все тарифы уже куплены", "autopay": "Автопродление", + "sbpRecurring": { + "title": "Автоплатёж по СБП", + "connect": "Подключить", + "confirmInBank": "Подтвердите в приложении банка", + "cancel": "Отключить", + "confirmCancel": "Отключить автоплатёж по СБП? Подписка больше не будет продлеваться автоматически через СБП.", + "cancelled": "Автоплатёж по СБП отключён", + "enableError": "Не удалось подключить автоплатёж по СБП", + "statusPending": "Ожидает подтверждения в приложении банка", + "statusActive": "Активен", + "statusPastDue": "Платёж не прошёл — попробуйте снова", + "nextCharge": "следующее списание {{date}}", + "amountPerInterval": "{{amount}} ₽ {{interval}}", + "interval": { + "day": "ежедневно", + "week": "еженедельно", + "month": "ежемесячно", + "year": "ежегодно" + }, + "autopayHint": "Подписка будет автоматически продлеваться через СБП без сохранённой карты" + }, "backToList": "К списку подписок", "confirmDelete": "Удалить подписку?", "dailyAutoCharge": "Ежедневное списание", @@ -858,7 +891,10 @@ "pageTitle": "Сохранённые карты", "backToBalance": "Назад", "empty": "Нет привязанных карт. Карта будет сохранена автоматически при следующем пополнении.", - "loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже." + "loadError": "Не удалось загрузить сохранённые карты. Попробуйте позже.", + "sbpSection": "Автоплатёж по СБП", + "sbpBinding": "Привязка СБП", + "sbpUnlink": "Отключить автоплатёж по СБП" }, "paymentReady": "Ссылка на оплату готова", "clickToOpenPayment": "Нажмите кнопку ниже, чтобы открыть страницу оплаты в новой вкладке", @@ -3936,7 +3972,13 @@ "deviceLimitUpdated": "Лимит устройств обновлён", "invalidDays": "Введите корректное количество дней (больше 0)", "backToList": "К списку подписок", - "createNew": "Создать новую" + "createNew": "Создать новую", + "sbpTitle": "Автоплатёж по СБП", + "sbpCancel": "Отключить", + "sbpCancelled": "Автоплатёж по СБП отключён", + "sbpStatus_PENDING": "Ожидает подтверждения", + "sbpStatus_ACTIVE": "Активен", + "sbpStatus_PAST_DUE": "Платёж не прошёл" }, "balance": { "current": "Текущий баланс", diff --git a/src/locales/zh.json b/src/locales/zh.json index d358bb2..c9894ce 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -110,6 +110,18 @@ "insufficientTitle": "余额不足", "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": { "bannedTitle": "账户已封禁", "bannedMessage": "您的账户已被封禁", @@ -562,6 +574,27 @@ }, "allTariffsPurchased": "所有套餐已购买", "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": "返回订阅列表", "confirmDelete": "删除订阅?", "cta": { @@ -727,7 +760,10 @@ "pageTitle": "已保存的银行卡", "backToBalance": "返回", "empty": "暂无已保存的银行卡。银行卡将在下次充值时自动保存。", - "loadError": "加载银行卡失败,请稍后重试。" + "loadError": "加载银行卡失败,请稍后重试。", + "sbpSection": "SBP 自动扣款", + "sbpBinding": "SBP 绑定", + "sbpUnlink": "关闭 SBP 自动扣款" }, "paymentSuccess": { "title": "支付成功", @@ -3009,7 +3045,13 @@ "deviceLimitUpdated": "设备限制已更新", "invalidDays": "请输入有效天数(大于0)", "backToList": "返回订阅列表", - "createNew": "新建" + "createNew": "新建", + "sbpTitle": "SBP 自动扣款", + "sbpCancel": "关闭", + "sbpCancelled": "SBP 自动扣款已关闭", + "sbpStatus_PENDING": "待确认", + "sbpStatus_ACTIVE": "已启用", + "sbpStatus_PAST_DUE": "扣款失败" }, "balance": { "current": "当前余额", diff --git a/src/types/index.ts b/src/types/index.ts index 6f10bb7..aa3c1be 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -649,6 +649,15 @@ export interface SavedCardsResponse { 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 export interface TicketNotification { id: number; diff --git a/src/utils/sbpRecurring.test.ts b/src/utils/sbpRecurring.test.ts new file mode 100644 index 0000000..8566f93 --- /dev/null +++ b/src/utils/sbpRecurring.test.ts @@ -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 => ({ + 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'); + }); +}); diff --git a/src/utils/sbpRecurring.ts b/src/utils/sbpRecurring.ts new file mode 100644 index 0000000..3b4f8a9 --- /dev/null +++ b/src/utils/sbpRecurring.ts @@ -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; + +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'; + } +} From 29a36837c8ad1310cce8fafa3e28f041c69a8d37 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Jul 2026 02:31:20 +0300 Subject: [PATCH 2/5] =?UTF-8?q?feat(subscription):=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=20=D0=A1=D0=91=D0=9F-=D0=B0=D0=B2=D1=82=D0=BE=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B4=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BD=D0=B0?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D0=B5=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B4=D0=BF=D0=B8=D1=81=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/Subscription.tsx | 189 ++++++++++++++++++++++++++++++++++++- 1 file changed, 188 insertions(+), 1 deletion(-) diff --git a/src/pages/Subscription.tsx b/src/pages/Subscription.tsx index 21cba8b..2d62a4f 100644 --- a/src/pages/Subscription.tsx +++ b/src/pages/Subscription.tsx @@ -28,13 +28,21 @@ import { DownloadIcon, TrashIcon, } from '../components/icons'; -import { useHaptic } from '../platform'; +import { useHaptic, usePlatform } from '../platform'; import { resolveConnectionUrlForUi } from '../utils/connectionLink'; import { getErrorMessage, getInsufficientBalanceError, getFlagEmoji, } 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 { DeviceTopupSheet } from '../components/subscription/sheets/DeviceTopupSheet'; import { DeviceReductionSheet } from '../components/subscription/sheets/DeviceReductionSheet'; @@ -194,6 +202,8 @@ export default function Subscription() { const { isDark } = useTheme(); const g = getGlassColors(isDark); const haptic = useHaptic(); + const { openLink, platform } = usePlatform(); + const { showToast } = useToast(); const [copied, setCopied] = useState(false); const [showDeleteSheet, setShowDeleteSheet] = useState(false); const destructiveConfirm = useDestructiveConfirm(); @@ -290,12 +300,80 @@ export default function Subscription() { 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({ mutationFn: (enabled: boolean) => subscriptionApi.updateAutopay(enabled, undefined, subscriptionId), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['subscription', subscriptionId] }); 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() { )} + + {/* ─── 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' && ( +
+
+ {t('subscription.sbpRecurring.title')} +
+ + {sbpUiStateValue === 'off' && ( + <> +
+ {t('subscription.sbpRecurring.autopayHint')} +
+ + + )} + + {sbpUiStateValue === 'pending' && ( + <> +
+ {t('subscription.sbpRecurring.statusPending')} +
+ {sbpInfo?.redirect_url && ( + + )} + + + )} + + {sbpUiStateValue === 'active' && sbpInfo && ( + <> +
+ {t('subscription.sbpRecurring.amountPerInterval', { + amount: formatAmount((sbpInfo.amount_kopeks ?? 0) / 100), + interval: t(sbpIntervalLabelKey(sbpInfo.interval)), + })} +
+ {sbpInfo.next_charge_at && ( +
+ {t('subscription.sbpRecurring.nextCharge', { + date: new Date(sbpInfo.next_charge_at).toLocaleDateString(uiLocale(), { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }), + })} +
+ )} + + + )} + + {sbpUiStateValue === 'past_due' && ( + <> +
+ {t('subscription.sbpRecurring.statusPastDue')} +
+ + + )} +
+ )} ); })() From d938ac2dd9d9f9e27142ed578e17cc7d76ca4531 Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Jul 2026 06:11:25 +0300 Subject: [PATCH 3/5] =?UTF-8?q?feat(balance):=20=D0=A1=D0=91=D0=9F-=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=D0=B2=D1=8F=D0=B7=D0=BA=D0=B8=20=D0=B2=20=D1=81?= =?UTF-8?q?=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=91=D0=BD=D0=BD=D1=8B=D1=85=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=82=D0=BE=D0=B4=D0=B0=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SavedCards.tsx | 140 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/src/pages/SavedCards.tsx b/src/pages/SavedCards.tsx index 6501df0..81fec61 100644 --- a/src/pages/SavedCards.tsx +++ b/src/pages/SavedCards.tsx @@ -1,13 +1,15 @@ import { uiLocale } from '@/utils/uiLocale'; 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 { useNavigate } from 'react-router'; import { motion } from 'framer-motion'; import { balanceApi } from '../api/balance'; +import { subscriptionApi } from '../api/subscription'; import { useToast } from '../components/Toast'; import { useDestructiveConfirm } from '../platform/hooks/useNativeDialog'; +import type { SbpRecurringInfo, SubscriptionListItem } from '../types'; import { Card } from '@/components/data-display/Card'; 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() { const { t } = useTranslation(); 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((acc, sub, index) => { + const info = sbpQueries[index]?.data; + if (info && info.status !== 'none') { + acc.push({ sub, info }); + } + return acc; + }, []); + + const [unlinkingSubId, setUnlinkingSubId] = useState(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 ( // key: remount the container when loading resolves — stagger orchestration // runs once on mount, so cards arriving from the API later would otherwise @@ -184,6 +266,62 @@ export default function SavedCards() { ) : 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 && ( + + +

+ {t('balance.savedCards.sbpSection')} +

+
+ {sbpBindings.map(({ sub, info }) => { + const statusKey = sbpStatusLabelKey(info.status); + return ( +
+
+ 🔁 +
+
+ {sub.tariff_name || `#${sub.id}`} +
+
+ {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', + }), + })}` + : ''} +
+
+
+ +
+ ); + })} +
+
+
+ )} ); } From e8409a9007e25c685ceaa4b6a88c54237b02303a Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Jul 2026 06:42:39 +0300 Subject: [PATCH 4/5] =?UTF-8?q?feat(ws):=20=D1=83=D0=B2=D0=B5=D0=B4=D0=BE?= =?UTF-8?q?=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=BE=20=D1=81=D0=BE?= =?UTF-8?q?=D0=B1=D1=8B=D1=82=D0=B8=D1=8F=D1=85=20=D0=A1=D0=91=D0=9F-?= =?UTF-8?q?=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=BE=D0=B4=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/WebSocketNotifications.tsx | 127 +++++++++++++++++++++- src/providers/WebSocketContext.ts | 3 + 2 files changed, 129 insertions(+), 1 deletion(-) diff --git a/src/components/WebSocketNotifications.tsx b/src/components/WebSocketNotifications.tsx index f8b38ea..31e0ab1 100644 --- a/src/components/WebSocketNotifications.tsx +++ b/src/components/WebSocketNotifications.tsx @@ -7,7 +7,7 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router'; import { useTranslation } from 'react-i18next'; import { useQueryClient } from '@tanstack/react-query'; -import { useWebSocket, WSMessage } from '../hooks/useWebSocket'; +import { useWebSocket, type WSMessage } from '../hooks/useWebSocket'; import { useToast } from './Toast'; import { useAuthStore } from '../store/auth'; import { useCurrency } from '../hooks/useCurrency'; @@ -296,6 +296,131 @@ export default function WebSocketNotifications() { 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: 🔁, + 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: 🔁, + 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: , + 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: ⚠️, + 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: 🔕, + 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 if (type === 'account.banned') { showToast({ diff --git a/src/providers/WebSocketContext.ts b/src/providers/WebSocketContext.ts index 6c4cbb1..8a23823 100644 --- a/src/providers/WebSocketContext.ts +++ b/src/providers/WebSocketContext.ts @@ -21,6 +21,9 @@ export interface WSMessage { new_expires_at?: string; tariff_name?: string; days_left?: number; + // SBP recurring events + status?: string; + next_charge_at?: string | null; // Device purchase events devices_added?: number; new_device_limit?: number; From 332849393c00fa4f7e143af2b293a2775c0a4e8e Mon Sep 17 00:00:00 2001 From: Fringg Date: Fri, 24 Jul 2026 06:45:00 +0300 Subject: [PATCH 5/5] =?UTF-8?q?feat(admin):=20=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D1=83=D1=81=20=D0=B8=20=D0=BE=D1=82=D0=BC=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=A1=D0=91=D0=9F-=D0=B0=D0=B2=D1=82=D0=BE=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=B4=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D0=BA=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B5=20=D1=8E=D0=B7=D0=B5=D1=80?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/userDetail/SubscriptionTab.tsx | 45 +++++++++++++++++++ src/pages/AdminUserDetail.tsx | 17 ++++++- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/components/admin/userDetail/SubscriptionTab.tsx b/src/components/admin/userDetail/SubscriptionTab.tsx index d633dc8..a8b8d8b 100644 --- a/src/components/admin/userDetail/SubscriptionTab.tsx +++ b/src/components/admin/userDetail/SubscriptionTab.tsx @@ -130,6 +130,7 @@ export interface SubscriptionTabProps { onAddTraffic: (gb: number) => Promise; onRemoveTraffic: (purchaseId: number) => Promise; onResetDevices: () => Promise; + onCancelSbpRecurring: () => Promise; onDeleteDevice: (hwid: string) => Promise; onRenameDevice: (hwid: string) => Promise; onLoadDevices: () => Promise; @@ -193,6 +194,7 @@ export function SubscriptionTab(props: SubscriptionTabProps) { onAddTraffic, onRemoveTraffic, onResetDevices, + onCancelSbpRecurring, onDeleteDevice, onRenameDevice, onLoadDevices, @@ -226,6 +228,14 @@ export function SubscriptionTab(props: SubscriptionTabProps) { {sub.tariff_name || `#${sub.id}`} + {sub.sbp_recurring_status && ( + + SBP + + )} @@ -380,6 +390,41 @@ export function SubscriptionTab(props: SubscriptionTabProps) { + {/* SBP (Platega) recurring auto-payment — status comes from the admin + detail response; cancel is idempotent on the backend. */} + {selectedSub.sbp_recurring_status && ( +
+
+
+
+ {t('admin.users.detail.subscription.sbpTitle')} +
+
+ {t( + `admin.users.detail.subscription.sbpStatus_${selectedSub.sbp_recurring_status}`, + selectedSub.sbp_recurring_status, + )} +
+
+ {hasPermission('users:subscription') && ( + + )} +
+
+ )} + {/* Traffic Packages */} {selectedSub.traffic_purchases && selectedSub.traffic_purchases.length > 0 && (
diff --git a/src/pages/AdminUserDetail.tsx b/src/pages/AdminUserDetail.tsx index ba63740..db7abb9 100644 --- a/src/pages/AdminUserDetail.tsx +++ b/src/pages/AdminUserDetail.tsx @@ -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 () => { if (!userId) return; setActionLoading(true); @@ -713,7 +727,7 @@ export default function AdminUserDetail() { const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB']; 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) => { @@ -859,6 +873,7 @@ export default function AdminUserDetail() {