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