feat(subscription): API и утилиты СБП-автопродления Platega

This commit is contained in:
Fringg
2026-07-24 02:14:41 +03:00
parent f04675b249
commit 9093aec207
9 changed files with 407 additions and 8 deletions

View File

@@ -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,

View File

@@ -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<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 ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => {

View File

@@ -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",

View File

@@ -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": "موجودی فعلی",

View File

@@ -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": "Текущий баланс",

View File

@@ -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": "当前余额",

View File

@@ -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;

View File

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

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

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