fix: support method query param fallback for external browser redirects

When payment providers redirect to external browser, sessionStorage is
unavailable. TopUpResult now reads method from URL query params and
polls via /latest endpoint as fallback.
This commit is contained in:
Fringg
2026-03-09 04:34:37 +03:00
parent 8897561fb2
commit 7ce5341e95
2 changed files with 58 additions and 15 deletions

View File

@@ -114,6 +114,14 @@ export const balanceApi = {
return response.data; return response.data;
}, },
// Get latest pending payment by method (fallback when sessionStorage unavailable)
getLatestPayment: async (method: string): Promise<PendingPayment> => {
const response = await apiClient.get<PendingPayment>(
`/cabinet/balance/pending-payments/${encodeURIComponent(method)}/latest`,
);
return response.data;
},
// Manually check payment status // Manually check payment status
checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => { checkPaymentStatus: async (method: string, paymentId: number): Promise<ManualCheckResponse> => {
const response = await apiClient.post<ManualCheckResponse>( const response = await apiClient.post<ManualCheckResponse>(

View File

@@ -226,6 +226,9 @@ export default function TopUpResult() {
// Load saved payment info from sessionStorage (once on mount) // Load saved payment info from sessionStorage (once on mount)
const [pendingInfo] = useState(() => loadTopUpPendingInfo()); const [pendingInfo] = useState(() => loadTopUpPendingInfo());
// Fallback: read method from query params (for external browser redirects where sessionStorage is unavailable)
const methodFromUrl = searchParams.get('method');
// Detect if user arrived via redirect with success param (no polling needed) // Detect if user arrived via redirect with success param (no polling needed)
const redirectStatus = searchParams.get('status') || searchParams.get('payment'); const redirectStatus = searchParams.get('status') || searchParams.get('payment');
const isRedirectSuccess = redirectStatus const isRedirectSuccess = redirectStatus
@@ -233,28 +236,30 @@ export default function TopUpResult() {
: searchParams.get('success') === 'true'; : searchParams.get('success') === 'true';
const isRedirectFailed = redirectStatus ? isFailedStatus(redirectStatus) : false; const isRedirectFailed = redirectStatus ? isFailedStatus(redirectStatus) : false;
// Determine if we can poll (need method + numeric payment_id) // Determine if we can poll by specific payment_id (need method + numeric payment_id)
const parsedPaymentId = pendingInfo?.payment_id ? parseInt(pendingInfo.payment_id, 10) : NaN; const parsedPaymentId = pendingInfo?.payment_id ? parseInt(pendingInfo.payment_id, 10) : NaN;
const canPoll = const canPollById =
!!(pendingInfo?.method_id && !isNaN(parsedPaymentId)) && !!(pendingInfo?.method_id && !isNaN(parsedPaymentId)) &&
!isRedirectSuccess && !isRedirectSuccess &&
!isRedirectFailed; !isRedirectFailed;
// Poll payment status // Fallback: poll by method via /latest endpoint when no sessionStorage data
const canPollByMethod =
!canPollById && !!methodFromUrl && !isRedirectSuccess && !isRedirectFailed;
// Poll payment status by specific ID (primary path — sessionStorage available)
const { data: paymentStatus, refetch } = useQuery({ const { data: paymentStatus, refetch } = useQuery({
queryKey: ['topup-status', pendingInfo?.method_id, parsedPaymentId], queryKey: ['topup-status', pendingInfo?.method_id, parsedPaymentId],
queryFn: () => balanceApi.getPendingPayment(pendingInfo!.method_id, parsedPaymentId), queryFn: () => balanceApi.getPendingPayment(pendingInfo!.method_id, parsedPaymentId),
enabled: canPoll && !pollTimedOut, enabled: canPollById && !pollTimedOut,
refetchInterval: (query) => { refetchInterval: (query) => {
const payment = query.state.data; const payment = query.state.data;
if (!payment) return POLL_INTERVAL_MS; if (!payment) return POLL_INTERVAL_MS;
// Stop polling if paid or failed
if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) { if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) {
return false; return false;
} }
// Check timeout
if (Date.now() - pollStart.current > MAX_POLL_MS) { if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true); setPollTimedOut(true);
return false; return false;
@@ -265,34 +270,64 @@ export default function TopUpResult() {
retry: 2, retry: 2,
}); });
// Poll payment status by method latest (fallback — external browser, no sessionStorage)
const { data: latestPayment, refetch: refetchLatest } = useQuery({
queryKey: ['topup-status-latest', methodFromUrl],
queryFn: () => balanceApi.getLatestPayment(methodFromUrl!),
enabled: canPollByMethod && !pollTimedOut,
refetchInterval: (query) => {
const payment = query.state.data;
if (!payment) return POLL_INTERVAL_MS;
if (payment.is_paid || isPaidStatus(payment.status) || isFailedStatus(payment.status)) {
return false;
}
if (Date.now() - pollStart.current > MAX_POLL_MS) {
setPollTimedOut(true);
return false;
}
return POLL_INTERVAL_MS;
},
retry: 2,
});
// Merge both polling sources
const effectivePayment = paymentStatus ?? latestPayment;
const handleRetryPoll = useCallback(() => { const handleRetryPoll = useCallback(() => {
pollStart.current = Date.now(); pollStart.current = Date.now();
setPollTimedOut(false); setPollTimedOut(false);
if (canPollById) {
refetch(); refetch();
}, [setPollTimedOut, refetch]); } else {
refetchLatest();
}
}, [canPollById, setPollTimedOut, refetch, refetchLatest]);
const handleGoBack = useCallback(() => { const handleGoBack = useCallback(() => {
clearTopUpPendingInfo(); clearTopUpPendingInfo();
navigate('/balance', { replace: true }); navigate('/balance', { replace: true });
}, [navigate]); }, [navigate]);
// Redirect to balance if no data at all // Redirect to balance if absolutely no data source available
useEffect(() => { useEffect(() => {
if (!pendingInfo && !redirectStatus) { if (!pendingInfo && !redirectStatus && !methodFromUrl) {
navigate('/balance', { replace: true }); navigate('/balance', { replace: true });
} }
}, [pendingInfo, redirectStatus, navigate]); }, [pendingInfo, redirectStatus, methodFromUrl, navigate]);
// Determine current visual state // Determine current visual state
const amountKopeks = paymentStatus?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null; const amountKopeks = effectivePayment?.amount_kopeks ?? pendingInfo?.amount_kopeks ?? null;
const resolvedPaid = const resolvedPaid =
isRedirectSuccess || isRedirectSuccess ||
paymentStatus?.is_paid || effectivePayment?.is_paid ||
(paymentStatus && isPaidStatus(paymentStatus.status)); (effectivePayment && isPaidStatus(effectivePayment.status));
const resolvedFailed = const resolvedFailed =
isRedirectFailed || (paymentStatus && isFailedStatus(paymentStatus.status)); isRedirectFailed || (effectivePayment && isFailedStatus(effectivePayment.status));
// Clean up sessionStorage and invalidate queries when payment resolves // Clean up sessionStorage and invalidate queries when payment resolves
useEffect(() => { useEffect(() => {