feat(sbp-recurring): СБП-оформление подписки на форме покупки тарифа

Вторая CTA «Оформить с автооплатой СБП» под кнопкой покупки с баланса
(и в daily-ветке) — показывается по platega_recurrent_enabled из
purchase-options. POST /platega-recurrent/purchase → редирект в банк
(первое списание = подтверждение привязки), инвалидация подписочных и
sbp-запросов, переход в /subscriptions. Локали en/ru/zh/fa.
This commit is contained in:
Fringg
2026-07-24 19:07:23 +03:00
parent faf4c450b0
commit f448c14994
8 changed files with 90 additions and 4 deletions

View File

@@ -378,6 +378,22 @@ export const subscriptionApi = {
return response.data;
},
/**
* Оформление подписки на тариф через СБП-автопродление: первое списание =
* подтверждение привязки в банке. Для нового тарифа бэкенд создаёт
* неактивную заготовку, которую активирует первый чардж.
*/
purchaseWithSbpRecurring: async (
tariffId: number,
): Promise<{ status: string; redirect_url: string | null; subscription_id: number }> => {
const response = await apiClient.post(
'/cabinet/subscription/platega-recurrent/purchase',
{},
{ params: { tariff_id: tariffId } },
);
return response.data;
},
// ── Trial ───────────────────────────────────────────────────────────
getTrialInfo: async (): Promise<TrialInfo> => {

View File

@@ -6,6 +6,8 @@ import { subscriptionApi } from '../../../api/subscription';
import { getErrorMessage, getInsufficientBalanceError } from '../../../utils/subscriptionHelpers';
import { useCurrency } from '../../../hooks/useCurrency';
import { usePromoDiscount } from '../../../hooks/usePromoDiscount';
import { usePlatform } from '../../../platform';
import { openPaymentUrl } from '../../../utils/openPaymentUrl';
import InsufficientBalancePrompt from '../../InsufficientBalancePrompt';
import type { Tariff, TariffPeriod } from '../../../types';
@@ -31,6 +33,8 @@ export interface TariffPurchaseFormProps {
tariff: Tariff;
subscriptionId: number | undefined;
balanceKopeks: number | undefined;
/** СБП-оформление (Platega recurrent) доступно — показать вторую CTA. */
sbpPurchaseEnabled?: boolean;
onBack: () => void;
}
@@ -38,6 +42,7 @@ export function TariffPurchaseForm({
tariff,
subscriptionId,
balanceKopeks,
sbpPurchaseEnabled = false,
onBack,
}: TariffPurchaseFormProps) {
const { t } = useTranslation();
@@ -45,6 +50,7 @@ export function TariffPurchaseForm({
const queryClient = useQueryClient();
const { formatAmount, currencySymbol } = useCurrency();
const { applyPromoDiscount } = usePromoDiscount();
const { openLink, platform } = usePlatform();
const ref = useRef<HTMLDivElement>(null);
const formatPrice = (kopeks: number) =>
@@ -93,6 +99,49 @@ export function TariffPurchaseForm({
},
});
// СБП-оформление: первое списание = подтверждение привязки в банке; период
// на форме не участвует — списания идут по каденс-правилу тарифа.
const sbpPurchaseMutation = useMutation({
mutationFn: () => subscriptionApi.purchaseWithSbpRecurring(tariff.id),
onSuccess: (data) => {
if (data.redirect_url) {
openPaymentUrl(data.redirect_url, platform, openLink);
}
queryClient.invalidateQueries({ queryKey: ['subscription'] });
queryClient.invalidateQueries({ queryKey: ['purchase-options'] });
queryClient.invalidateQueries({ queryKey: ['subscriptions-list'] });
queryClient.invalidateQueries({ queryKey: ['sbp-recurring', data.subscription_id] });
navigate('/subscriptions', { replace: true });
},
});
const sbpPurchaseButton = sbpPurchaseEnabled && (
<>
<button
onClick={() => sbpPurchaseMutation.mutate()}
disabled={sbpPurchaseMutation.isPending || purchaseMutation.isPending}
className="mt-2 w-full rounded-xl border border-accent-500/40 bg-accent-500/10 py-3 text-sm font-medium text-accent-400 transition-colors hover:bg-accent-500/20 disabled:opacity-50"
>
{sbpPurchaseMutation.isPending ? (
<span className="flex items-center justify-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
{t('common.loading')}
</span>
) : (
t('subscription.sbpRecurring.purchaseButton')
)}
</button>
<div className="mt-1.5 text-center text-[11px] text-dark-500">
{t('subscription.sbpRecurring.purchaseHint')}
</div>
{sbpPurchaseMutation.isError && (
<div className="mt-2 text-center text-sm text-error-400">
{getErrorMessage(sbpPurchaseMutation.error)}
</div>
)}
</>
);
// Smooth scroll the form into view when first mounted.
useEffect(() => {
if (ref.current) {
@@ -190,6 +239,8 @@ export function TariffPurchaseForm({
)}
</button>
{sbpPurchaseButton}
{purchaseMutation.isError &&
!getInsufficientBalanceError(purchaseMutation.error) && (
<div className="mt-3 text-center text-sm text-error-400">
@@ -613,6 +664,8 @@ export function TariffPurchaseForm({
t('subscription.purchase')
)}
</button>
{sbpPurchaseButton}
</>
);
})()}

View File

@@ -697,7 +697,9 @@
"month": "monthly",
"year": "yearly"
},
"autopayHint": "Your subscription will be charged automatically via SBP, no card required"
"autopayHint": "Your subscription will be charged automatically via SBP, no card required",
"purchaseButton": "⚡ Subscribe with SBP auto-payment",
"purchaseHint": "First charge confirms the binding in your bank app; renewals are charged automatically per the tariff cadence."
},
"backToList": "Back to subscriptions",
"confirmDelete": "Delete subscription?",

View File

@@ -596,7 +596,9 @@
"month": "ماهانه",
"year": "سالانه"
},
"autopayHint": "اشتراک شما به‌صورت خودکار از طریق SBP بدون نیاز به کارت ذخیره‌شده شارژ می‌شود"
"autopayHint": "اشتراک شما به‌صورت خودکار از طریق SBP بدون نیاز به کارت ذخیره‌شده شارژ می‌شود",
"purchaseButton": "⚡ اشتراک با پرداخت خودکار SBP",
"purchaseHint": "اولین برداشت، اتصال را در اپ بانک تأیید می‌کند؛ تمدیدها به‌صورت خودکار برداشت می‌شوند."
},
"backToList": "بازگشت به فهرست اشتراک‌ها",
"confirmDelete": "اشتراک حذف شود؟",

View File

@@ -716,7 +716,9 @@
"month": "ежемесячно",
"year": "ежегодно"
},
"autopayHint": "Подписка будет автоматически продлеваться через СБП без сохранённой карты"
"autopayHint": "Подписка будет автоматически продлеваться через СБП без сохранённой карты",
"purchaseButton": "⚡ Оформить с автооплатой СБП",
"purchaseHint": "Первое списание подтверждает привязку в банке; продления списываются автоматически по каденсу тарифа."
},
"backToList": "К списку подписок",
"confirmDelete": "Удалить подписку?",

View File

@@ -596,7 +596,9 @@
"month": "每月",
"year": "每年"
},
"autopayHint": "您的订阅将通过 SBP 自动扣款,无需保存银行卡"
"autopayHint": "您的订阅将通过 SBP 自动扣款,无需保存银行卡",
"purchaseButton": "⚡ 通过SBP自动扣款订阅",
"purchaseHint": "首次扣款即在银行应用中确认绑定;续费将按资费周期自动扣款。"
},
"backToList": "返回订阅列表",
"confirmDelete": "删除订阅?",

View File

@@ -286,6 +286,12 @@ export default function SubscriptionPurchase() {
tariff={selectedTariff}
subscriptionId={subscriptionId}
balanceKopeks={purchaseOptions?.balance_kopeks}
sbpPurchaseEnabled={
isTariffsMode &&
purchaseOptions !== undefined &&
'platega_recurrent_enabled' in purchaseOptions &&
purchaseOptions.platega_recurrent_enabled === true
}
onBack={() => {
setShowTariffPurchase(false);
setSelectedTariff(null);

View File

@@ -370,6 +370,9 @@ export interface TariffsPurchaseOptions {
has_subscription?: boolean;
// Multi-tariff: all available tariffs already purchased
all_tariffs_purchased?: boolean;
// СБП-оформление (Platega recurrent): показывать кнопку «Оформить с
// автооплатой СБП» рядом с покупкой с баланса
platega_recurrent_enabled?: boolean;
}
export interface ClassicPurchaseOptions {