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'; + } +}