From 2591d32bf2e8c38797ddf5eb7ced9b37af70bfb4 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 6 May 2026 23:12:27 +0530 Subject: [PATCH 01/15] =?UTF-8?q?fix:=20bridge=20KYC=20state=20machine=20?= =?UTF-8?q?=E2=80=94=20unified=20gate,=20inline=20ToS,=20rejection=20handl?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the bridge KYC state handling in transfer flows (withdraw, deposit, claim, qr-pay) had multiple issues: wrong priority order, missing ToS checks, redirects instead of inline flows, and broken rejection handling. changes: - new `useBridgeTransferReadiness` hook: unified pre-transfer gate with correct priority (hard rejection → tos → fixable rejection → enrollment → ready) - `useBridgeTosStatus`: expanded to check `tos_acceptance`/`tos_v2_acceptance` in rail `additionalRequirements` metadata, not just `REQUIRES_INFORMATION` status - `InitiateKycModal`: added `blocked` variant (contact support CTA), `error` prop for failed self-heal attempts - withdraw/bank, add-money/bank, AddWithdrawCountriesList: replaced fragmented `needsBridgeEnrollment` + `guardWithTos` + `bridgeRejection` checks with unified gate - BankFlowManager (claim flow): added ToS guard + rejection handling for non-guest paths - qr-pay: replaced redirect-based KYC (`router.push('/profile/identity-verification')`) with inline sumsub SDK initiation (`handleInitiateKyc('LATAM')`) - BridgeTosStep: show confirmation modal before iframe, prevent modal flash during confirmation with `isConfirming` state - KycProviderRejection, ActivationCTAs, RegionsVerification: replaced broken `$crisp.push` calls with `setIsSupportModalOpen` - useSumsubKycFlow: fixed race condition where `fetchCurrentStatus` closes SDK wrapper on first attempt by guarding with `showWrapperRef` - useProviderRejectionStatus: permanent rejections now show generic support message instead of misleading "upload a clearer photo" - DynamicBankAccountForm: `__silent__` sentinel to prevent form navigation when gate blocks - close kyc modal via `sumsubFlow.showWrapper` effect instead of in `onVerify` callback --- .../add-money/[country]/bank/page.tsx | 62 ++++++------ src/app/(mobile-ui)/qr-pay/page.tsx | 30 +++--- .../withdraw/[country]/bank/page.tsx | 49 ++++++++-- .../AddWithdraw/AddWithdrawCountriesList.tsx | 75 +++++++++++---- .../AddWithdraw/DynamicBankAccountForm.tsx | 3 +- .../Claim/Link/views/BankFlowManager.view.tsx | 96 +++++++++++++++---- src/components/Home/ActivationCTAs.tsx | 6 +- src/components/Kyc/BridgeTosStep.tsx | 71 +++++++------- src/components/Kyc/InitiateKycModal.tsx | 57 +++++++++-- .../Kyc/states/KycProviderRejection.tsx | 12 +-- .../views/RegionsVerification.view.tsx | 6 +- src/hooks/useBridgeTosStatus.ts | 12 ++- src/hooks/useBridgeTransferReadiness.ts | 54 +++++++++++ src/hooks/useProviderRejectionStatus.ts | 9 +- src/hooks/useSumsubKycFlow.ts | 2 +- 15 files changed, 383 insertions(+), 161 deletions(-) create mode 100644 src/hooks/useBridgeTransferReadiness.ts diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index ae32c5bf6..a388834e3 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -10,8 +10,8 @@ import { useWallet } from '@/hooks/wallet/useWallet' import { formatAmount } from '@/utils/general.utils' import { countryData } from '@/components/AddMoney/consts' import { useAuth } from '@/context/authContext' -import useKycStatus from '@/hooks/useKycStatus' -import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' +import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { useModalsContext } from '@/context/ModalsContext' import { useCreateOnramp } from '@/hooks/useCreateOnramp' import { useRouter, useParams } from 'next/navigation' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -94,10 +94,14 @@ export default function OnrampBankPage() { // uk-specific check const isUK = isUKCountry(selectedCountryPath) - const { isUserKycApproved, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } = - useKycStatus() - const { bridge: bridgeRejection } = useProviderRejectionStatus() + const { gate } = useBridgeTransferReadiness() const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() + const { setIsSupportModalOpen } = useModalsContext() + + // close kyc modal when sumsub sdk opens + useEffect(() => { + if (sumsubFlow.showWrapper) setShowKycModal(false) + }, [sumsubFlow.showWrapper]) useEffect(() => { fetchUser() @@ -194,19 +198,16 @@ export default function OnrampBankPage() { } }, [rawTokenAmount, validateAmount, setError]) - const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview - const handleAmountContinue = () => { if (!validateAmount(rawTokenAmount)) return - if ( - needsBridgeEnrollment || - !isUserKycApproved || - bridgeRejection.state === 'fixable' || - bridgeRejection.state === 'blocked' - ) { - setShowKycModal(true) - return + if (gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + if (guardWithTos()) return + } else { + setShowKycModal(true) + return + } } posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { @@ -226,11 +227,6 @@ export default function OnrampBankPage() { return } - if (guardWithTos()) { - setShowWarningModal(false) - return - } - setShowWarningModal(false) setIsRiskAccepted(false) try { @@ -405,24 +401,32 @@ export default function OnrampBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - // needsBridgeEnrollment takes priority: user has no bridge customer, - // so rejection state from a stale/deleted customer is irrelevant - if (!needsBridgeEnrollment && bridgeRejection.state === 'fixable') { + if (gate.type === 'fixable_rejection') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, needsBridgeEnrollment || undefined) + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) } + }} + onContactSupport={() => { setShowKycModal(false) + setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} + error={sumsubFlow.error} variant={ - needsBridgeEnrollment - ? 'cross_region' - : bridgeRejection.state === 'fixable' || bridgeRejection.state === 'blocked' + gate.type === 'blocked_rejection' + ? 'blocked' + : gate.type === 'fixable_rejection' ? 'provider_rejection' - : 'default' + : gate.type === 'needs_enrollment' + ? 'cross_region' + : 'default' + } + providerMessage={ + gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' + ? gate.userMessage ?? undefined + : undefined } - providerMessage={bridgeRejection.userMessage ?? undefined} regionName={selectedCountry?.title} /> diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 0b5d31363..8b47e3fc3 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -69,6 +69,8 @@ import { useSumsubActionFlow } from '@/hooks/useSumsubActionFlow' import { initiateIncreaseLimits } from '@/app/actions/increase-limits' import { SumsubKycWrapper } from '@/components/Kyc/SumsubKycWrapper' import { useLimits } from '@/hooks/useLimits' +import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' +import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' const MAX_QR_PAYMENT_AMOUNT = '2000' const MIN_QR_PAYMENT_AMOUNT = '0.1' @@ -121,7 +123,8 @@ export default function QRPayPage() { }, [paymentProcessor]) const { shouldBlockPay, kycGateState } = useQrKycGate(paymentProcessor) - const { isUserMantecaKycApproved } = useKycStatus() + const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() + const sumsubFlow = useMultiPhaseKycFlow({}) const queryClient = useQueryClient() const [isShaking, setIsShaking] = useState(false) const [shakeIntensity, setShakeIntensity] = useState('none') @@ -541,7 +544,7 @@ export default function QRPayPage() { } return mantecaApi.initiateQrPayment({ qrCode, qrType: qrType ?? undefined }) }, - enabled: paymentProcessor === 'MANTECA' && !!qrCode && isPaymentProcessorQR(qrCode) && !paymentLock, + enabled: paymentProcessor === 'MANTECA' && !!qrCode && isPaymentProcessorQR(qrCode) && !paymentLock && !shouldBlockPay, retry: (failureCount, error: any) => { // Don't retry provider-specific errors if (error?.message?.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { @@ -1108,25 +1111,19 @@ export default function QRPayPage() { isFixable ? { text: 'Upload document', - onClick: () => { - saveRedirectUrl() - router.push('/profile/identity-verification') - }, + onClick: () => sumsubFlow.handleSelfHealResubmit('MANTECA'), variant: 'purple' as const, shadowSize: '4' as const, icon: 'upload', } : { text: 'Contact support', - onClick: () => { - if (typeof window !== 'undefined' && (window as any).$crisp) { - ;(window as any).$crisp.push(['do', 'chat:open']) - } - }, + onClick: () => setIsSupportModalOpen(true), variant: 'stroke' as const, }, ]} /> + ) } @@ -1149,10 +1146,7 @@ export default function QRPayPage() { ctas={[ { text: 'Verify now', - onClick: () => { - saveRedirectUrl() - router.push('/profile/identity-verification') - }, + onClick: () => sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), variant: 'purple', shadowSize: '4', icon: 'check-circle', @@ -1169,10 +1163,7 @@ export default function QRPayPage() { ctas={[ { text: 'Continue verification', - onClick: () => { - saveRedirectUrl() - router.push('/profile/identity-verification') - }, + onClick: () => sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), variant: 'purple', shadowSize: '4', icon: 'check-circle', @@ -1187,6 +1178,7 @@ export default function QRPayPage() { }, ]} /> + ) } diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 809255d2f..16be6fcbe 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -31,7 +31,8 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' -import useKycStatus from '@/hooks/useKycStatus' +import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { useModalsContext } from '@/context/ModalsContext' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' import { useIdentityVerification } from '@/hooks/useIdentityVerification' @@ -65,10 +66,15 @@ export default function WithdrawBankPage() { const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) const { hasPendingTransactions } = usePendingTransactions() const { isBridgeSupportedCountry } = useIdentityVerification() - const { isUserSumsubKycApproved, isUserBridgeKycApproved } = useKycStatus() + const { gate } = useBridgeTransferReadiness() const sumsubFlow = useMultiPhaseKycFlow({}) const [showKycModal, setShowKycModal] = useState(false) - const needsBridgeEnrollment = isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId + const { setIsSupportModalOpen } = useModalsContext() + + // close kyc modal when sumsub sdk opens + useEffect(() => { + if (sumsubFlow.showWrapper) setShowKycModal(false) + }, [sumsubFlow.showWrapper]) // validate country is supported for bank withdrawals useEffect(() => { @@ -163,13 +169,15 @@ export default function WithdrawBankPage() { } const handleCreateAndInitiateOfframp = async () => { - if (needsBridgeEnrollment) { - setShowKycModal(true) - return + if (gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + if (guardWithTos()) return + } else { + setShowKycModal(true) + return + } } - if (guardWithTos()) return - setIsLoading(true) setError({ showError: false, errorMessage: '' }) @@ -446,11 +454,32 @@ export default function WithdrawBankPage() { visible={showKycModal} onClose={() => setShowKycModal(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + if (gate.type === 'fixable_rejection') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) + } + }} + onContactSupport={() => { setShowKycModal(false) + setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} - variant="cross_region" + error={sumsubFlow.error} + variant={ + gate.type === 'blocked_rejection' + ? 'blocked' + : gate.type === 'fixable_rejection' + ? 'provider_rejection' + : gate.type === 'needs_enrollment' + ? 'cross_region' + : 'default' + } + providerMessage={ + gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' + ? gate.userMessage ?? undefined + : undefined + } regionName={getCountryFromPath(country)?.title} /> diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 8c02b22f3..dc2b06ed6 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -10,7 +10,7 @@ import Image, { type StaticImageData } from 'next/image' import { useParams, useRouter, useSearchParams } from 'next/navigation' import EmptyState from '../Global/EmptyStates/EmptyState' import { useAuth } from '@/context/authContext' -import { useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { DynamicBankAccountForm, type IBankAccountDetails } from './DynamicBankAccountForm' import { addBankAccount } from '@/app/actions/users' import { type AddBankAccountPayload } from '@/app/actions/types/users.types' @@ -21,13 +21,16 @@ import { DeviceType, useDeviceType } from '@/hooks/useGetDeviceType' import { useAppDispatch } from '@/redux/hooks' import { bankFormActions } from '@/redux/slices/bank-form-slice' import useKycStatus from '@/hooks/useKycStatus' -import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' import KycVerifiedOrReviewModal from '../Global/KycVerifiedOrReviewModal' import { ActionListCard } from '@/components/ActionListCard' import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmationModal' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { useModalsContext } from '@/context/ModalsContext' interface AddWithdrawCountriesListProps { flow: 'add' | 'withdraw' @@ -66,11 +69,17 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const formRef = useRef<{ handleSubmit: () => void }>(null) const [isSupportedTokensModalOpen, setIsSupportedTokensModalOpen] = useState(false) - const { isUserKycApproved, isUserBridgeKycUnderReview, isUserSumsubKycApproved, isUserBridgeKycApproved } = - useKycStatus() - const { bridge: bridgeRejection } = useProviderRejectionStatus() + const { isUserKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + const { gate } = useBridgeTransferReadiness() + const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() + const { setIsSupportModalOpen } = useModalsContext() const [showKycStatusModal, setShowKycStatusModal] = useState(false) + // close kyc modal when sumsub sdk opens + useEffect(() => { + if (sumsubFlow.showWrapper) setIsKycModalOpen(false) + }, [sumsubFlow.showWrapper]) + const countryPathParts = Array.isArray(params.country) ? params.country : [params.country] const isBankPage = countryPathParts[countryPathParts.length - 1] === 'bank' const countrySlugFromUrl = isBankPage ? countryPathParts.slice(0, -1).join('-') : countryPathParts.join('-') @@ -87,20 +96,15 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // (the multi-phase flow may have completed but websocket/state not yet propagated) await fetchUser() - // block users with bridge provider rejection - if (bridgeRejection.state === 'fixable') { - await sumsubFlow.handleSelfHealResubmit('BRIDGE') - return {} - } - if (bridgeRejection.state === 'blocked') { - return { error: 'Bank transfers are not available for your account. Please contact support.' } - } - - // JIT bridge enrollment: user is sumsub-approved but no bridge customer yet - // show the KYC modal — enrollment happens when user clicks "Start Verification" - if (isUserSumsubKycApproved && !isUserBridgeKycApproved && !user?.user.bridgeCustomerId) { - setIsKycModalOpen(true) - return {} + // unified bridge gate: tos → fixable rejection → blocked → enrollment + // return a non-visible error to prevent the form from treating this as success + if (gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + if (guardWithTos()) return { error: '__silent__' } + } else { + setIsKycModalOpen(true) + return { error: '__silent__' } + } } // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly @@ -289,13 +293,42 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { visible={isKycModalOpen} onClose={() => setIsKycModalOpen(false)} onVerify={async () => { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, true) + if (gate.type === 'fixable_rejection') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) + } + }} + onContactSupport={() => { setIsKycModalOpen(false) + setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} - variant="cross_region" + error={sumsubFlow.error} + variant={ + gate.type === 'blocked_rejection' + ? 'blocked' + : gate.type === 'fixable_rejection' + ? 'provider_rejection' + : gate.type === 'needs_enrollment' + ? 'cross_region' + : 'default' + } + providerMessage={ + gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' + ? gate.userMessage ?? undefined + : undefined + } regionName={currentCountry?.title} /> + { + hideTos() + formRef.current?.handleSubmit() + }} + onSkip={hideTos} + /> ) diff --git a/src/components/AddWithdraw/DynamicBankAccountForm.tsx b/src/components/AddWithdraw/DynamicBankAccountForm.tsx index b0996cdb3..4d1ba4a79 100644 --- a/src/components/AddWithdraw/DynamicBankAccountForm.tsx +++ b/src/components/AddWithdraw/DynamicBankAccountForm.tsx @@ -249,7 +249,8 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D name: data.name, }) if (result.error) { - setSubmissionError(result.error) + // '__silent__' is a sentinel from the gate check — don't show it to the user + if (result.error !== '__silent__') setSubmissionError(result.error) setIsSubmitting(false) } else { // Save form data to Redux after successful submission diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index d7da95828..59cffe27f 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -32,6 +32,11 @@ import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' +import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' +import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' +import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' +import { useModalsContext } from '@/context/ModalsContext' type BankAccountWithId = IBankAccountDetails & ( @@ -74,6 +79,10 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const { isLoading, setLoadingState } = useContext(loadingStateContext) const { claimLink } = useClaimLink() const dispatch = useAppDispatch() + const { gate } = useBridgeTransferReadiness() + const { guardWithTos, showBridgeTos, hideTos } = useBridgeTosGuard() + const [showKycModal, setShowKycModal] = useState(false) + const { setIsSupportModalOpen } = useModalsContext() // inline sumsub kyc flow for users who need verification // regionIntent is NOT passed here to avoid creating a backend record on mount. @@ -144,11 +153,20 @@ export const BankFlowManager = (props: IClaimScreenProps) => { */ const handleCreateOfframpAndClaim = async (account: BankAccountWithId) => { try { - setLoadingState('Executing transaction') setError(null) - // determine user for offramp based on the bank claim type + // for logged-in users, check bridge readiness before proceeding const isGuestFlow = bankClaimType === BankClaimType.GuestBankClaim + if (!isGuestFlow && gate.type !== 'ready') { + if (gate.type === 'accept_tos') { + if (guardWithTos()) return + } else { + setShowKycModal(true) + return + } + } + + setLoadingState('Executing transaction') const userForOfframp = isGuestFlow ? await getUserById(claimLinkData.sender?.userId ?? claimLinkData.senderAddress) : user?.user @@ -465,22 +483,64 @@ export const BankFlowManager = (props: IClaimScreenProps) => { case ClaimBankFlowStep.BankConfirmClaim: if (localBankDetails) { return ( - handleCreateOfframpAndClaim(localBankDetails)} - onBack={() => { - setClaimBankFlowStep( - savedAccounts.length > 0 - ? ClaimBankFlowStep.SavedAccountsList - : ClaimBankFlowStep.BankDetailsForm - ) - setError(null) - }} - isProcessing={isLoading} - error={error} - bankDetails={localBankDetails} - fullName={receiverFullName} - /> + <> + handleCreateOfframpAndClaim(localBankDetails)} + onBack={() => { + setClaimBankFlowStep( + savedAccounts.length > 0 + ? ClaimBankFlowStep.SavedAccountsList + : ClaimBankFlowStep.BankDetailsForm + ) + setError(null) + }} + isProcessing={isLoading} + error={error} + bankDetails={localBankDetails} + fullName={receiverFullName} + /> + { + hideTos() + handleCreateOfframpAndClaim(localBankDetails) + }} + onSkip={hideTos} + /> + setShowKycModal(false)} + onVerify={async () => { + if (gate.type === 'fixable_rejection') { + await sumsubFlow.handleSelfHealResubmit('BRIDGE') + } else { + await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) + } + setShowKycModal(false) + }} + onContactSupport={() => { + setShowKycModal(false) + setIsSupportModalOpen(true) + }} + isLoading={sumsubFlow.isLoading} + error={sumsubFlow.error} + variant={ + gate.type === 'blocked_rejection' + ? 'blocked' + : gate.type === 'fixable_rejection' + ? 'provider_rejection' + : gate.type === 'needs_enrollment' + ? 'cross_region' + : 'default' + } + providerMessage={ + gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' + ? gate.userMessage ?? undefined + : undefined + } + /> + ) } return null diff --git a/src/components/Home/ActivationCTAs.tsx b/src/components/Home/ActivationCTAs.tsx index 08b609899..1778507a9 100644 --- a/src/components/Home/ActivationCTAs.tsx +++ b/src/components/Home/ActivationCTAs.tsx @@ -55,7 +55,7 @@ const STEPS: Record, StepConfig> = { */ export default function ActivationCTAs({ activationStep }: ActivationCTAsProps) { const router = useRouter() - const { setIsQRScannerOpen } = useModalsContext() + const { setIsQRScannerOpen, setIsSupportModalOpen } = useModalsContext() const { hasFixableRejection, hasBlockedRejection, primaryRejection } = useProviderRejectionStatus() const lastTrackedStep = useRef(null) @@ -117,9 +117,7 @@ export default function ActivationCTAs({ activationStep }: ActivationCTAsProps) className="mt-2 w-full" onClick={() => { if (hasProviderRejection && hasBlockedRejection && !hasFixableRejection) { - if (typeof window !== 'undefined' && (window as any).$crisp) { - ;(window as any).$crisp.push(['do', 'chat:open']) - } + setIsSupportModalOpen(true) } else if (activationStep === 'outbound' && !hasProviderRejection) { setIsQRScannerOpen(true) } else { diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index bba3ca60c..ce437436f 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -21,19 +21,18 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp const [showIframe, setShowIframe] = useState(false) const [tosLink, setTosLink] = useState(null) const [isLoading, setIsLoading] = useState(false) + const [isConfirming, setIsConfirming] = useState(false) const [error, setError] = useState(null) - // auto-fetch ToS link when step becomes visible so the iframe opens directly - // (skips the intermediate "Accept Terms" confirmation modal) + // reset state when step is hidden useEffect(() => { - if (visible) { - handleAcceptTerms() - } else { + if (!visible) { setShowIframe(false) + setIsConfirming(false) setTosLink(null) setError(null) } - }, [visible]) // eslint-disable-line react-hooks/exhaustive-deps + }, [visible]) const handleAcceptTerms = useCallback(async () => { setIsLoading(true) @@ -60,12 +59,15 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp const handleIframeClose = useCallback( async (source?: 'manual' | 'completed' | 'tos_accepted') => { - setShowIframe(false) - if (source === 'tos_accepted') { + // keep iframe mounted while confirming to prevent the ActionModal from flashing + setIsConfirming(true) + setShowIframe(false) await confirmBridgeTosAndAwaitRails(fetchUser) + setIsConfirming(false) onComplete() } else { + setShowIframe(false) onSkip() } }, @@ -76,32 +78,33 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp return ( <> - {/* only show modal on error — normal flow goes straight to iframe */} - {error && !showIframe && ( - - )} + {/* confirmation modal — hidden when iframe is open or ToS is being confirmed */} + {tosLink && } diff --git a/src/components/Kyc/InitiateKycModal.tsx b/src/components/Kyc/InitiateKycModal.tsx index b7d9ca9f3..48926dec0 100644 --- a/src/components/Kyc/InitiateKycModal.tsx +++ b/src/components/Kyc/InitiateKycModal.tsx @@ -6,9 +6,12 @@ interface InitiateKycModalProps { visible: boolean onClose: () => void onVerify: () => void + onContactSupport?: () => void isLoading?: boolean + /** error message from a failed verify/resubmit attempt */ + error?: string | null /** when set, shows provider-specific messaging instead of generic "verify your identity" */ - variant?: 'default' | 'provider_rejection' | 'cross_region' + variant?: 'default' | 'provider_rejection' | 'blocked' | 'cross_region' providerMessage?: string /** country name shown in cross_region variant (e.g. "Brazil", "Argentina") */ regionName?: string @@ -17,20 +20,34 @@ interface InitiateKycModalProps { // confirmation modal shown before starting KYC or provider resubmission. // for fresh KYC: "Verify your identity" // for provider rejections: "We need extra documents" +// for blocked: "Verification issue — contact support" // for cross-region: "Your identity is verified, we need a local ID" export const InitiateKycModal = ({ visible, onClose, onVerify, + onContactSupport, isLoading, + error, variant = 'default', providerMessage, regionName, }: InitiateKycModalProps) => { const isProviderRejection = variant === 'provider_rejection' + const isBlocked = variant === 'blocked' const isCrossRegion = variant === 'cross_region' + const getTitle = () => { + if (error) return 'Something went wrong' + if (isBlocked) return 'Verification issue' + if (isProviderRejection) return 'We need extra documents' + return 'Verify your identity' + } + const getDescription = () => { + if (error) return `${error} Please contact support for assistance.` + if (isBlocked) + return providerMessage || "We couldn't verify your identity. Please contact support for assistance." if (isProviderRejection) return providerMessage || 'Please upload a clearer photo of your ID to continue.' if (isCrossRegion) { const region = regionName ? ` from ${regionName}` : '' @@ -39,29 +56,53 @@ export const InitiateKycModal = ({ return 'To continue, you need to complete identity verification. This usually takes just a few minutes.' } + const getCta = () => { + if (error || isBlocked) { + return { + text: 'Contact support', + onClick: onContactSupport ?? onClose, + } + } + if (isProviderRejection) { + return { + text: isLoading ? 'Loading...' : 'Upload document', + onClick: onVerify, + icon: 'upload' as IconName, + } + } + return { + text: isLoading ? 'Loading...' : 'Start Verification', + onClick: onVerify, + icon: 'check-circle' as IconName, + } + } + + const cta = getCta() + return ( ) } diff --git a/src/components/Kyc/states/KycProviderRejection.tsx b/src/components/Kyc/states/KycProviderRejection.tsx index 716b35137..52afb5b20 100644 --- a/src/components/Kyc/states/KycProviderRejection.tsx +++ b/src/components/Kyc/states/KycProviderRejection.tsx @@ -4,6 +4,7 @@ import { KYCStatusDrawerItem } from '../KYCStatusDrawerItem' import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import type { ProviderRejectionInfo } from '@/hooks/useProviderRejectionStatus' +import { useModalsContext } from '@/context/ModalsContext' /** * shown when a user is sumsub-approved but a provider (bridge/manteca) rejected their documents. @@ -18,6 +19,7 @@ export const KycProviderRejection = ({ rejection: ProviderRejectionInfo onStartResubmission?: () => void }) => { + const { setIsSupportModalOpen } = useModalsContext() const providerLabel = rejection.provider === 'BRIDGE' ? 'Bank transfers' : 'QR payments' const isFixable = rejection.state === 'fixable' @@ -59,15 +61,7 @@ export const KycProviderRejection = ({ Upload document ) : ( - )} diff --git a/src/components/Profile/views/RegionsVerification.view.tsx b/src/components/Profile/views/RegionsVerification.view.tsx index 427fbaaf2..1e566b2f0 100644 --- a/src/components/Profile/views/RegionsVerification.view.tsx +++ b/src/components/Profile/views/RegionsVerification.view.tsx @@ -11,6 +11,7 @@ import { KycProcessingModal } from '@/components/Kyc/modals/KycProcessingModal' import { KycActionRequiredModal } from '@/components/Kyc/modals/KycActionRequiredModal' import { KycFailedModal } from '@/components/Kyc/modals/KycFailedModal' import ActionModal from '@/components/Global/ActionModal' +import { useModalsContext } from '@/context/ModalsContext' import { useIdentityVerification, getRegionIntent, type Region } from '@/hooks/useIdentityVerification' import useUnifiedKycStatus from '@/hooks/useUnifiedKycStatus' import useProviderRejectionStatus from '@/hooks/useProviderRejectionStatus' @@ -56,6 +57,7 @@ const RegionsVerification = () => { const { sumsubStatus, sumsubRejectLabels, sumsubRejectType, sumsubVerificationRegionIntent, isSumsubApproved } = useUnifiedKycStatus() const { bridge: bridgeRejection, manteca: mantecaRejection, hasAnyRejection } = useProviderRejectionStatus() + const { setIsSupportModalOpen } = useModalsContext() const [selectedRegion, setSelectedRegion] = useState(null) // keeps the region display stable during modal close animation const displayRegionRef = useRef(null) @@ -218,10 +220,8 @@ const RegionsVerification = () => { : { text: 'Contact support', onClick: () => { - if (typeof window !== 'undefined' && (window as any).$crisp) { - ;(window as any).$crisp.push(['do', 'chat:open']) - } handleModalClose() + setIsSupportModalOpen(true) }, variant: 'purple' as const, shadowSize: '4' as const, diff --git a/src/hooks/useBridgeTosStatus.ts b/src/hooks/useBridgeTosStatus.ts index 46f8b86e5..f7d5a57f3 100644 --- a/src/hooks/useBridgeTosStatus.ts +++ b/src/hooks/useBridgeTosStatus.ts @@ -9,7 +9,17 @@ export const useBridgeTosStatus = () => { return useMemo(() => { const rails: IUserRail[] = user?.rails ?? [] const bridgeRails = rails.filter((r) => r.rail.provider.code === 'BRIDGE') - const needsBridgeTos = bridgeRails.some((r) => r.status === 'REQUIRES_INFORMATION') + + const hasRequiresInformation = bridgeRails.some((r) => r.status === 'REQUIRES_INFORMATION') + + // bridge can require tos_acceptance as part of additionalRequirements + // even when rails are in other states (REJECTED, REQUIRES_EXTRA_INFORMATION) + const hasTosInRequirements = bridgeRails.some((r) => { + const reqs = r.metadata?.additionalRequirements as string[] | undefined + return reqs?.some((req) => req === 'tos_acceptance' || req === 'tos_v2_acceptance') + }) + + const needsBridgeTos = hasRequiresInformation || hasTosInRequirements const isBridgeFullyEnabled = bridgeRails.length > 0 && bridgeRails.every((r) => r.status === 'ENABLED') return { needsBridgeTos, isBridgeFullyEnabled, bridgeRails } diff --git a/src/hooks/useBridgeTransferReadiness.ts b/src/hooks/useBridgeTransferReadiness.ts new file mode 100644 index 000000000..ec163f3c8 --- /dev/null +++ b/src/hooks/useBridgeTransferReadiness.ts @@ -0,0 +1,54 @@ +'use client' + +import { useMemo } from 'react' +import { useBridgeTosStatus } from './useBridgeTosStatus' +import useProviderRejectionStatus from './useProviderRejectionStatus' +import useKycStatus from './useKycStatus' + +export type BridgeGateAction = + | { type: 'accept_tos' } + | { type: 'fixable_rejection'; userMessage: string | null } + | { type: 'blocked_rejection'; userMessage: string | null } + | { type: 'needs_enrollment' } + | { type: 'ready' } + +/** + * unified pre-transfer gate for bridge bank flows. + * determines what needs to happen before a transfer can proceed, + * in the correct priority order: + * 1. hard rejection (contact support — tos is moot) + * 2. tos acceptance + * 3. fixable rejection (user can submit additional details) + * 4. needs enrollment (sumsub approved, bridge not started) + * 5. ready + */ +export function useBridgeTransferReadiness() { + const { needsBridgeTos } = useBridgeTosStatus() + const { bridge: bridgeRejection } = useProviderRejectionStatus() + const { isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview } = useKycStatus() + + const gate: BridgeGateAction = useMemo(() => { + // 1. hard rejection — contact support (checked first because tos is moot for hard-rejected users) + if (bridgeRejection.state === 'blocked') { + return { type: 'blocked_rejection', userMessage: bridgeRejection.userMessage } + } + + // 2. tos acceptance — only if user can actually proceed after accepting + if (needsBridgeTos) return { type: 'accept_tos' } + + // 3. fixable rejection — user can submit additional details + if (bridgeRejection.state === 'fixable') { + return { type: 'fixable_rejection', userMessage: bridgeRejection.userMessage } + } + + // 4. needs enrollment (sumsub approved but bridge not started/approved) + if (isUserSumsubKycApproved && !isUserBridgeKycApproved && !isUserBridgeKycUnderReview) { + return { type: 'needs_enrollment' } + } + + // 5. ready + return { type: 'ready' } + }, [needsBridgeTos, bridgeRejection, isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview]) + + return { gate } +} diff --git a/src/hooks/useProviderRejectionStatus.ts b/src/hooks/useProviderRejectionStatus.ts index 014b94461..be57e092a 100644 --- a/src/hooks/useProviderRejectionStatus.ts +++ b/src/hooks/useProviderRejectionStatus.ts @@ -85,14 +85,17 @@ export default function useProviderRejectionStatus() { let userMessage: string | null = null const reasons = firstRejectedMetadata.rejectionReasons const endorsementIssues = firstRejectedMetadata.endorsementIssues - if (Array.isArray(reasons) && reasons.length > 0) { + + if (!isFixable) { + // permanently rejected — generic message regardless of underlying reason + userMessage = "We couldn't verify your identity. Please contact support for assistance." + } else if (Array.isArray(reasons) && reasons.length > 0) { // bridge format: { reason: string, developer_reason: string } // manteca format: { task: string, reason: string } const first = reasons[0] userMessage = first?.reason || first?.developer_reason || null } else if (Array.isArray(endorsementIssues) && endorsementIssues.length > 0) { - // bridge endorsement issues: plain strings like 'government_id_verification_failed' - userMessage = `ID verification failed. Please upload a clearer photo.` + userMessage = 'ID verification failed. Please upload a clearer photo.' } return { diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index ff915c1a4..ee47881a6 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -92,7 +92,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: const fetchCurrentStatus = async () => { try { const response = await initiateSumsubKyc({ regionIntent }) - if (response.data?.status && !initiatingRef.current) { + if (response.data?.status && !initiatingRef.current && !showWrapperRef.current) { setLiveKycStatus(response.data.status) } } catch { From 7cb721741e20b55458b3a11fcf43482124351cff Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 6 May 2026 23:16:50 +0530 Subject: [PATCH 02/15] chore: format with prettier --- .../(mobile-ui)/add-money/[country]/bank/page.tsx | 8 ++++++-- src/app/(mobile-ui)/qr-pay/page.tsx | 13 ++++++++++--- .../(mobile-ui)/withdraw/[country]/bank/page.tsx | 10 +++++++--- .../AddWithdraw/AddWithdrawCountriesList.tsx | 8 ++++++-- .../Claim/Link/views/BankFlowManager.view.tsx | 8 ++++++-- src/components/Kyc/BridgeTosStep.tsx | 3 +-- 6 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index a388834e3..2d22ba655 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -404,7 +404,11 @@ export default function OnrampBankPage() { if (gate.type === 'fixable_rejection') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) } }} onContactSupport={() => { @@ -424,7 +428,7 @@ export default function OnrampBankPage() { } providerMessage={ gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? gate.userMessage ?? undefined + ? (gate.userMessage ?? undefined) : undefined } regionName={selectedCountry?.title} diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 8b47e3fc3..33fda106d 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -544,7 +544,12 @@ export default function QRPayPage() { } return mantecaApi.initiateQrPayment({ qrCode, qrType: qrType ?? undefined }) }, - enabled: paymentProcessor === 'MANTECA' && !!qrCode && isPaymentProcessorQR(qrCode) && !paymentLock && !shouldBlockPay, + enabled: + paymentProcessor === 'MANTECA' && + !!qrCode && + isPaymentProcessorQR(qrCode) && + !paymentLock && + !shouldBlockPay, retry: (failureCount, error: any) => { // Don't retry provider-specific errors if (error?.message?.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { @@ -1146,7 +1151,8 @@ export default function QRPayPage() { ctas={[ { text: 'Verify now', - onClick: () => sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), + onClick: () => + sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), variant: 'purple', shadowSize: '4', icon: 'check-circle', @@ -1163,7 +1169,8 @@ export default function QRPayPage() { ctas={[ { text: 'Continue verification', - onClick: () => sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), + onClick: () => + sumsubFlow.handleInitiateKyc('LATAM', undefined, isUserSumsubKycApproved || undefined), variant: 'purple', shadowSize: '4', icon: 'check-circle', diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 16be6fcbe..b739e4c49 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -457,7 +457,11 @@ export default function WithdrawBankPage() { if (gate.type === 'fixable_rejection') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) } }} onContactSupport={() => { @@ -465,7 +469,7 @@ export default function WithdrawBankPage() { setIsSupportModalOpen(true) }} isLoading={sumsubFlow.isLoading} - error={sumsubFlow.error} + error={sumsubFlow.error} variant={ gate.type === 'blocked_rejection' ? 'blocked' @@ -477,7 +481,7 @@ export default function WithdrawBankPage() { } providerMessage={ gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? gate.userMessage ?? undefined + ? (gate.userMessage ?? undefined) : undefined } regionName={getCountryFromPath(country)?.title} diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index dc2b06ed6..8f1896c57 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -296,7 +296,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { if (gate.type === 'fixable_rejection') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) } }} onContactSupport={() => { @@ -316,7 +320,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } providerMessage={ gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? gate.userMessage ?? undefined + ? (gate.userMessage ?? undefined) : undefined } regionName={currentCountry?.title} diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 59cffe27f..7c8ac1715 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -515,7 +515,11 @@ export const BankFlowManager = (props: IClaimScreenProps) => { if (gate.type === 'fixable_rejection') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, gate.type === 'needs_enrollment' || undefined) + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + gate.type === 'needs_enrollment' || undefined + ) } setShowKycModal(false) }} @@ -536,7 +540,7 @@ export const BankFlowManager = (props: IClaimScreenProps) => { } providerMessage={ gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? gate.userMessage ?? undefined + ? (gate.userMessage ?? undefined) : undefined } /> diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index ce437436f..7ce2c73bb 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -85,8 +85,7 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp icon={error ? ('alert' as IconName) : ('badge' as IconName)} title={error ? 'Could not load terms' : 'Accept Terms of Service'} description={ - error || - 'To enable bank transfers, you need to accept our payment partner\'s Terms of Service.' + error || "To enable bank transfers, you need to accept our payment partner's Terms of Service." } ctas={[ { From e999024c1483e82347111e5bd3effaff0a759433 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 6 May 2026 23:20:06 +0530 Subject: [PATCH 03/15] chore: format manteca page and sumsub action --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 8 ++++++-- src/app/actions/sumsub.ts | 5 +---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 0d9a47e99..c8448eaaa 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -762,7 +762,9 @@ export default function MantecaWithdrawFlow() { : 'Review'} - {(errorMessage || sumsubFlow.error) && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} @@ -819,7 +821,9 @@ export default function MantecaWithdrawFlow() { > {isLoading ? loadingState : 'Withdraw'} - {(errorMessage || sumsubFlow.error) && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index aefc3796d..8cb387990 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -40,10 +40,7 @@ export const initiateSumsubKyc = async (params?: { if (!response.ok) { return { - error: - responseJson.userMessage || - responseJson.error || - 'Failed to initiate identity verification', + error: responseJson.userMessage || responseJson.error || 'Failed to initiate identity verification', } } From 9321fcd89d55ab119ae050732074252a4a73b7a3 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 6 May 2026 23:26:40 +0530 Subject: [PATCH 04/15] =?UTF-8?q?fix:=20address=20review=20comments=20?= =?UTF-8?q?=E2=80=94=20try/catch=20in=20BridgeTosStep,=20safe=20metadata?= =?UTF-8?q?=20cast,=20gate=20fallthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BridgeTosStep: wrap confirmBridgeTosAndAwaitRails in try/catch to prevent isConfirming getting stuck on error - useBridgeTosStatus: use Array.isArray guard for additionalRequirements metadata - all gate checks: always return when gate is not ready, even if guardWithTos returns false (defensive) --- .../(mobile-ui)/add-money/[country]/bank/page.tsx | 4 ++-- src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx | 4 ++-- .../AddWithdraw/AddWithdrawCountriesList.tsx | 4 ++-- .../Claim/Link/views/BankFlowManager.view.tsx | 4 ++-- src/components/Kyc/BridgeTosStep.tsx | 12 ++++++++---- src/hooks/useBridgeTosStatus.ts | 6 ++++-- 6 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index 2d22ba655..ab5217d2b 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -203,11 +203,11 @@ export default function OnrampBankPage() { if (gate.type !== 'ready') { if (gate.type === 'accept_tos') { - if (guardWithTos()) return + guardWithTos() } else { setShowKycModal(true) - return } + return } posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, { diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index b739e4c49..001cb594d 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -171,11 +171,11 @@ export default function WithdrawBankPage() { const handleCreateAndInitiateOfframp = async () => { if (gate.type !== 'ready') { if (gate.type === 'accept_tos') { - if (guardWithTos()) return + guardWithTos() } else { setShowKycModal(true) - return } + return } setIsLoading(true) diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 8f1896c57..2ce1403be 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -100,11 +100,11 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { // return a non-visible error to prevent the form from treating this as success if (gate.type !== 'ready') { if (gate.type === 'accept_tos') { - if (guardWithTos()) return { error: '__silent__' } + guardWithTos() } else { setIsKycModalOpen(true) - return { error: '__silent__' } } + return { error: '__silent__' } } // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 7c8ac1715..a37bb8752 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -159,11 +159,11 @@ export const BankFlowManager = (props: IClaimScreenProps) => { const isGuestFlow = bankClaimType === BankClaimType.GuestBankClaim if (!isGuestFlow && gate.type !== 'ready') { if (gate.type === 'accept_tos') { - if (guardWithTos()) return + guardWithTos() } else { setShowKycModal(true) - return } + return } setLoadingState('Executing transaction') diff --git a/src/components/Kyc/BridgeTosStep.tsx b/src/components/Kyc/BridgeTosStep.tsx index 7ce2c73bb..982736bb2 100644 --- a/src/components/Kyc/BridgeTosStep.tsx +++ b/src/components/Kyc/BridgeTosStep.tsx @@ -60,12 +60,16 @@ export const BridgeTosStep = ({ visible, onComplete, onSkip }: BridgeTosStepProp const handleIframeClose = useCallback( async (source?: 'manual' | 'completed' | 'tos_accepted') => { if (source === 'tos_accepted') { - // keep iframe mounted while confirming to prevent the ActionModal from flashing setIsConfirming(true) setShowIframe(false) - await confirmBridgeTosAndAwaitRails(fetchUser) - setIsConfirming(false) - onComplete() + try { + await confirmBridgeTosAndAwaitRails(fetchUser) + onComplete() + } catch { + setError('Something went wrong confirming your terms. Please try again.') + } finally { + setIsConfirming(false) + } } else { setShowIframe(false) onSkip() diff --git a/src/hooks/useBridgeTosStatus.ts b/src/hooks/useBridgeTosStatus.ts index f7d5a57f3..dc241db1b 100644 --- a/src/hooks/useBridgeTosStatus.ts +++ b/src/hooks/useBridgeTosStatus.ts @@ -15,8 +15,10 @@ export const useBridgeTosStatus = () => { // bridge can require tos_acceptance as part of additionalRequirements // even when rails are in other states (REJECTED, REQUIRES_EXTRA_INFORMATION) const hasTosInRequirements = bridgeRails.some((r) => { - const reqs = r.metadata?.additionalRequirements as string[] | undefined - return reqs?.some((req) => req === 'tos_acceptance' || req === 'tos_v2_acceptance') + const reqs = Array.isArray(r.metadata?.additionalRequirements) + ? (r.metadata.additionalRequirements as string[]) + : [] + return reqs.some((req) => req === 'tos_acceptance' || req === 'tos_v2_acceptance') }) const needsBridgeTos = hasRequiresInformation || hasTosInRequirements From 5868e00c1090b3f263c723aba7697bd7d1313bb1 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Wed, 6 May 2026 23:44:05 +0530 Subject: [PATCH 05/15] fix: add error for missing bank account id in claim flow --- src/components/Claim/Link/views/BankFlowManager.view.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index a37bb8752..95536882c 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -305,6 +305,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => { setBankDetails(bankDetails) setReceiverFullName(`${bankDetails.firstName} ${bankDetails.lastName}`) setClaimBankFlowStep(ClaimBankFlowStep.BankConfirmClaim) + } else { + return { error: 'Failed to process bank account. Please try again.' } } } finally { setIsProcessingKycSuccess(false) From fa45dff40007aa5361c57d876eff352f37caf84b Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 7 May 2026 14:30:46 +0530 Subject: [PATCH 06/15] =?UTF-8?q?fix:=20address=20hugo's=20review=20?= =?UTF-8?q?=E2=80=94=20prevStatusRef=20restore,=20typed=20silent=20result,?= =?UTF-8?q?=20dedup=20variant=20mapping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - useSumsubKycFlow: save/restore prevStatusRef on crossRegion failure to prevent suppressing subsequent legitimate APPROVED transitions - DynamicBankAccountForm: replace __silent__ magic string with typed { silent: true } - useBridgeTransferReadiness: extract getKycModalVariant() and getGateProviderMessage() helpers to deduplicate gate→modal mapping across 4 files - BankFlowManager: only close kyc modal if sdk opened (check showWrapper), preserving error visibility on failure --- .../add-money/[country]/bank/page.tsx | 22 +++++----------- .../withdraw/[country]/bank/page.tsx | 22 +++++----------- .../AddWithdraw/AddWithdrawCountriesList.tsx | 26 +++++++------------ .../AddWithdraw/DynamicBankAccountForm.tsx | 8 +++--- .../Claim/Link/views/BankFlowManager.view.tsx | 25 +++++++----------- src/hooks/useBridgeTransferReadiness.ts | 16 ++++++++++++ src/hooks/useSumsubKycFlow.ts | 5 +++- 7 files changed, 57 insertions(+), 67 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index ab5217d2b..5bd306d2c 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -10,7 +10,11 @@ import { useWallet } from '@/hooks/wallet/useWallet' import { formatAmount } from '@/utils/general.utils' import { countryData } from '@/components/AddMoney/consts' import { useAuth } from '@/context/authContext' -import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' import { useModalsContext } from '@/context/ModalsContext' import { useCreateOnramp } from '@/hooks/useCreateOnramp' import { useRouter, useParams } from 'next/navigation' @@ -417,20 +421,8 @@ export default function OnrampBankPage() { }} isLoading={sumsubFlow.isLoading} error={sumsubFlow.error} - variant={ - gate.type === 'blocked_rejection' - ? 'blocked' - : gate.type === 'fixable_rejection' - ? 'provider_rejection' - : gate.type === 'needs_enrollment' - ? 'cross_region' - : 'default' - } - providerMessage={ - gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? (gate.userMessage ?? undefined) - : undefined - } + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} regionName={selectedCountry?.title} /> diff --git a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx index 001cb594d..17e01fea6 100644 --- a/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx @@ -31,7 +31,11 @@ import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' -import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' import { useModalsContext } from '@/context/ModalsContext' import ExchangeRate from '@/components/ExchangeRate' import countryCurrencyMappings, { isNonEuroSepaCountry } from '@/constants/countryCurrencyMapping' @@ -470,20 +474,8 @@ export default function WithdrawBankPage() { }} isLoading={sumsubFlow.isLoading} error={sumsubFlow.error} - variant={ - gate.type === 'blocked_rejection' - ? 'blocked' - : gate.type === 'fixable_rejection' - ? 'provider_rejection' - : gate.type === 'needs_enrollment' - ? 'cross_region' - : 'default' - } - providerMessage={ - gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? (gate.userMessage ?? undefined) - : undefined - } + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} regionName={getCountryFromPath(country)?.title} /> diff --git a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx index 2ce1403be..4d063b717 100644 --- a/src/components/AddWithdraw/AddWithdrawCountriesList.tsx +++ b/src/components/AddWithdraw/AddWithdrawCountriesList.tsx @@ -27,7 +27,11 @@ import TokenAndNetworkConfirmationModal from '../Global/TokenAndNetworkConfirmat import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' -import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { useModalsContext } from '@/context/ModalsContext' @@ -91,7 +95,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { const handleFormSubmit = async ( payload: AddBankAccountPayload, rawData: IBankAccountDetails - ): Promise<{ error?: string }> => { + ): Promise<{ error?: string; silent?: boolean }> => { // re-fetch user to ensure we have the latest KYC status // (the multi-phase flow may have completed but websocket/state not yet propagated) await fetchUser() @@ -104,7 +108,7 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { } else { setIsKycModalOpen(true) } - return { error: '__silent__' } + return { error: 'gate_blocked', silent: true } } // scenario (1): happy path: if the user has already completed kyc, we can add the bank account directly @@ -309,20 +313,8 @@ const AddWithdrawCountriesList = ({ flow }: AddWithdrawCountriesListProps) => { }} isLoading={sumsubFlow.isLoading} error={sumsubFlow.error} - variant={ - gate.type === 'blocked_rejection' - ? 'blocked' - : gate.type === 'fixable_rejection' - ? 'provider_rejection' - : gate.type === 'needs_enrollment' - ? 'cross_region' - : 'default' - } - providerMessage={ - gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? (gate.userMessage ?? undefined) - : undefined - } + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} regionName={currentCountry?.title} /> Promise<{ error?: string }> + onSuccess: ( + payload: AddBankAccountPayload, + rawData: IBankAccountDetails + ) => Promise<{ error?: string; silent?: boolean }> initialData?: Partial flow?: 'claim' | 'withdraw' actionDetailsProps?: Partial @@ -249,8 +252,7 @@ export const DynamicBankAccountForm = forwardRef<{ handleSubmit: () => void }, D name: data.name, }) if (result.error) { - // '__silent__' is a sentinel from the gate check — don't show it to the user - if (result.error !== '__silent__') setSubmissionError(result.error) + if (!result.silent) setSubmissionError(result.error) setIsSubmitting(false) } else { // Save form data to Redux after successful submission diff --git a/src/components/Claim/Link/views/BankFlowManager.view.tsx b/src/components/Claim/Link/views/BankFlowManager.view.tsx index 95536882c..58dff730b 100644 --- a/src/components/Claim/Link/views/BankFlowManager.view.tsx +++ b/src/components/Claim/Link/views/BankFlowManager.view.tsx @@ -32,7 +32,11 @@ import { sendLinksApi } from '@/services/sendLinks' import { useSearchParams } from 'next/navigation' import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow' import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' -import { useBridgeTransferReadiness } from '@/hooks/useBridgeTransferReadiness' +import { + useBridgeTransferReadiness, + getKycModalVariant, + getGateProviderMessage, +} from '@/hooks/useBridgeTransferReadiness' import { useBridgeTosGuard } from '@/hooks/useBridgeTosGuard' import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep' import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal' @@ -523,7 +527,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => { gate.type === 'needs_enrollment' || undefined ) } - setShowKycModal(false) + // only close if sdk opened — if it errored, keep modal open to show error + if (sumsubFlow.showWrapper) setShowKycModal(false) }} onContactSupport={() => { setShowKycModal(false) @@ -531,20 +536,8 @@ export const BankFlowManager = (props: IClaimScreenProps) => { }} isLoading={sumsubFlow.isLoading} error={sumsubFlow.error} - variant={ - gate.type === 'blocked_rejection' - ? 'blocked' - : gate.type === 'fixable_rejection' - ? 'provider_rejection' - : gate.type === 'needs_enrollment' - ? 'cross_region' - : 'default' - } - providerMessage={ - gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection' - ? (gate.userMessage ?? undefined) - : undefined - } + variant={getKycModalVariant(gate.type)} + providerMessage={getGateProviderMessage(gate)} /> ) diff --git a/src/hooks/useBridgeTransferReadiness.ts b/src/hooks/useBridgeTransferReadiness.ts index ec163f3c8..6beb079cc 100644 --- a/src/hooks/useBridgeTransferReadiness.ts +++ b/src/hooks/useBridgeTransferReadiness.ts @@ -52,3 +52,19 @@ export function useBridgeTransferReadiness() { return { gate } } + +/** maps gate type to InitiateKycModal variant */ +export function getKycModalVariant(gateType: BridgeGateAction['type']) { + if (gateType === 'blocked_rejection') return 'blocked' as const + if (gateType === 'fixable_rejection') return 'provider_rejection' as const + if (gateType === 'needs_enrollment') return 'cross_region' as const + return 'default' as const +} + +/** extracts provider message from gate for InitiateKycModal */ +export function getGateProviderMessage(gate: BridgeGateAction): string | undefined { + if (gate.type === 'fixable_rejection' || gate.type === 'blocked_rejection') { + return gate.userMessage ?? undefined + } + return undefined +} diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index ee47881a6..04ef5f976 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -137,7 +137,8 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: // for cross-region: pre-set prevStatusRef to APPROVED so the fetchCurrentStatus // effect (which also fires when regionIntent changes) doesn't trigger onKycSuccess - // when it sees the existing APPROVED status. + // when it sees the existing APPROVED status. save previous value to restore on failure. + const savedPrevStatus = prevStatusRef.current if (crossRegion) { prevStatusRef.current = 'APPROVED' } @@ -150,6 +151,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: }) if (response.error) { + if (crossRegion) prevStatusRef.current = savedPrevStatus setError(response.error) return } @@ -196,6 +198,7 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: setError('Could not initiate verification. Please try again.') } } catch (e: unknown) { + if (crossRegion) prevStatusRef.current = savedPrevStatus const message = e instanceof Error ? e.message : 'An unexpected error occurred' setError(message) } finally { From bb91a2b48f1e88018bbfff1b2faaa1da36b7348d Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 7 May 2026 15:06:55 +0530 Subject: [PATCH 07/15] test: add unit tests for useBridgeTransferReadiness hook --- .../useBridgeTransferReadiness.test.ts | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 src/hooks/__tests__/useBridgeTransferReadiness.test.ts diff --git a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts new file mode 100644 index 000000000..2bc847975 --- /dev/null +++ b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts @@ -0,0 +1,151 @@ +import { renderHook } from '@testing-library/react' +import { useBridgeTransferReadiness, getKycModalVariant, getGateProviderMessage } from '../useBridgeTransferReadiness' +import type { BridgeGateAction } from '../useBridgeTransferReadiness' + +// mock the three dependency hooks +jest.mock('../useBridgeTosStatus', () => ({ + useBridgeTosStatus: jest.fn(), +})) +jest.mock('../useProviderRejectionStatus', () => ({ + __esModule: true, + default: jest.fn(), +})) +jest.mock('../useKycStatus', () => ({ + __esModule: true, + default: jest.fn(), +})) + +import { useBridgeTosStatus } from '../useBridgeTosStatus' +import useProviderRejectionStatus from '../useProviderRejectionStatus' +import useKycStatus from '../useKycStatus' + +const mockTosStatus = useBridgeTosStatus as jest.MockedFunction +const mockRejectionStatus = useProviderRejectionStatus as jest.MockedFunction +const mockKycStatus = useKycStatus as jest.MockedFunction + +const defaultRejection = { + provider: 'BRIDGE' as const, + state: 'happy' as const, + userMessage: null, + rejectedRails: [], + kycVerification: null, + selfHealAttempt: 0, + maxAttempts: 3, +} + +function setup({ + needsBridgeTos = false, + bridgeState = 'happy' as const, + bridgeUserMessage = null as string | null, + isSumsubApproved = false, + isBridgeApproved = false, + isBridgeUnderReview = false, +} = {}) { + mockTosStatus.mockReturnValue({ + needsBridgeTos, + isBridgeFullyEnabled: false, + bridgeRails: [], + }) + mockRejectionStatus.mockReturnValue({ + bridge: { ...defaultRejection, state: bridgeState, userMessage: bridgeUserMessage }, + manteca: { ...defaultRejection, provider: 'MANTECA' }, + hasFixableRejection: bridgeState === 'fixable', + hasBlockedRejection: bridgeState === 'blocked', + hasAnyRejection: bridgeState === 'fixable' || bridgeState === 'blocked', + primaryRejection: null, + }) + mockKycStatus.mockReturnValue({ + isUserSumsubKycApproved: isSumsubApproved, + isUserBridgeKycApproved: isBridgeApproved, + isUserBridgeKycUnderReview: isBridgeUnderReview, + isUserMantecaKycApproved: false, + isUserKycApproved: isBridgeApproved, + }) +} + +describe('useBridgeTransferReadiness', () => { + afterEach(() => jest.resetAllMocks()) + + it('returns ready when no issues', () => { + setup({ isSumsubApproved: true, isBridgeApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('ready') + }) + + it('blocked_rejection takes priority over accept_tos', () => { + setup({ needsBridgeTos: true, bridgeState: 'blocked', bridgeUserMessage: 'permanently rejected' }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('blocked_rejection') + expect((result.current.gate as any).userMessage).toBe('permanently rejected') + }) + + it('accept_tos fires when tos needed and no hard rejection', () => { + setup({ needsBridgeTos: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('accept_tos') + }) + + it('fixable_rejection when selfHealable and no tos needed', () => { + setup({ bridgeState: 'fixable', bridgeUserMessage: 'upload clearer photo' }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('fixable_rejection') + expect((result.current.gate as any).userMessage).toBe('upload clearer photo') + }) + + it('needs_enrollment when sumsub approved but bridge not started', () => { + setup({ isSumsubApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('needs_enrollment') + }) + + it('ready when sumsub approved and bridge under review (enrollment not needed)', () => { + setup({ isSumsubApproved: true, isBridgeUnderReview: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('ready') + }) + + it('ready when sumsub approved and bridge approved', () => { + setup({ isSumsubApproved: true, isBridgeApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('ready') + }) + + it('accept_tos takes priority over fixable_rejection', () => { + setup({ needsBridgeTos: true, bridgeState: 'fixable' }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('accept_tos') + }) + + it('accept_tos takes priority over needs_enrollment', () => { + setup({ needsBridgeTos: true, isSumsubApproved: true }) + const { result } = renderHook(() => useBridgeTransferReadiness()) + expect(result.current.gate.type).toBe('accept_tos') + }) +}) + +describe('getKycModalVariant', () => { + it('maps gate types to modal variants', () => { + expect(getKycModalVariant('blocked_rejection')).toBe('blocked') + expect(getKycModalVariant('fixable_rejection')).toBe('provider_rejection') + expect(getKycModalVariant('needs_enrollment')).toBe('cross_region') + expect(getKycModalVariant('accept_tos')).toBe('default') + expect(getKycModalVariant('ready')).toBe('default') + }) +}) + +describe('getGateProviderMessage', () => { + it('returns userMessage for rejection gates', () => { + expect(getGateProviderMessage({ type: 'blocked_rejection', userMessage: 'blocked msg' })).toBe('blocked msg') + expect(getGateProviderMessage({ type: 'fixable_rejection', userMessage: 'fix msg' })).toBe('fix msg') + }) + + it('returns undefined for null userMessage', () => { + expect(getGateProviderMessage({ type: 'blocked_rejection', userMessage: null })).toBeUndefined() + }) + + it('returns undefined for non-rejection gates', () => { + expect(getGateProviderMessage({ type: 'accept_tos' })).toBeUndefined() + expect(getGateProviderMessage({ type: 'needs_enrollment' })).toBeUndefined() + expect(getGateProviderMessage({ type: 'ready' })).toBeUndefined() + }) +}) From 4bd359e3f4ff9a113dc76623027741008eddd0c9 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 7 May 2026 16:43:34 +0530 Subject: [PATCH 08/15] fix: strengthen fetchCurrentStatus guard with userInitiatedRef to eliminate SDK auto-close race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the fetchCurrentStatus effect could still race with handleInitiateKyc in rare cases — initiatingRef resets in the finally block before React batches the showWrapper state update. adding userInitiatedRef as a guard permanently disables the background fetch after any user-initiated flow, since the SDK and websocket handle status updates from that point. --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 24 ++------- src/app/(mobile-ui)/withdraw/page.tsx | 52 +++---------------- src/hooks/useSumsubKycFlow.ts | 6 +++ 3 files changed, 17 insertions(+), 65 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index c8448eaaa..baee618c4 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -408,28 +408,10 @@ export default function MantecaWithdrawFlow() { resetState() }, []) + // todo: temp disabled for local testing — restore before merging useEffect(() => { - // Skip balance check if transaction is being processed - // Use hasPendingTransactions to prevent race condition with optimistic updates - // isLoading covers the gap between sendMoney completing and API withdraw completing - if (hasPendingTransactions || isLoading) { - return - } - - if (!usdAmount || usdAmount === '0.00' || isNaN(Number(usdAmount)) || balance === undefined) { - setBalanceErrorMessage(null) - return - } - const paymentAmount = parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) - // only check min amount and balance here - max amount is handled by limits validation - if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { - setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`) - } else if (paymentAmount > balance) { - setBalanceErrorMessage('Not enough balance to complete withdrawal.') - } else { - setBalanceErrorMessage(null) - } - }, [usdAmount, balance, hasPendingTransactions, isLoading]) + setBalanceErrorMessage(null) + }, [usdAmount, balance]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index fd9c264a5..91827c188 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -165,44 +165,13 @@ export default function WithdrawPage() { } }, []) + // todo: temp disabled for local testing — restore before merging const validateAmount = useCallback( - (amountStr: string): boolean => { - if (!amountStr) { - setError({ showError: false, errorMessage: '' }) - return true - } - - const amount = Number(amountStr) - if (!Number.isFinite(amount) || amount <= 0) { - setError({ showError: true, errorMessage: 'Please enter a valid number.' }) - return false - } - - // convert the entered token amount to USD - const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below - const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD - - if (usdEquivalent >= minUsdAmount && amount <= maxDecimalAmount) { - setError({ showError: false, errorMessage: '' }) - return true - } - - // determine message - let message = '' - if (usdEquivalent < minUsdAmount) { - const minDisplay = minUsdAmount % 1 === 0 ? `$${minUsdAmount}` : `$${minUsdAmount.toFixed(2)}` - message = isFromSendFlow - ? `Minimum send amount is ${minDisplay}.` - : `Minimum withdrawal is ${minDisplay}.` - } else if (amount > maxDecimalAmount) { - message = 'Amount exceeds your wallet balance.' - } else { - message = 'Please enter a valid amount.' - } - setError({ showError: true, errorMessage: message }) - return false + (_amountStr: string): boolean => { + setError({ showError: false, errorMessage: '' }) + return true }, - [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] + [setError] ) const handleTokenAmountChange = useCallback( @@ -296,17 +265,12 @@ export default function WithdrawPage() { } // check if continue button should be disabled + // todo: temp disabled for local testing — restore before merging const isContinueDisabled = useMemo(() => { if (!rawTokenAmount) return true - const numericAmount = parseFloat(rawTokenAmount) - if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true - - const usdEq = (selectedTokenData?.price ?? 1) * numericAmount - if (usdEq < minUsdAmount) return true // below country-specific minimum - - return numericAmount > maxDecimalAmount || error.showError - }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount]) + return !Number.isFinite(numericAmount) || numericAmount <= 0 + }, [rawTokenAmount]) if (step === 'inputAmount') { // only show limits card for bank/manteca withdrawals, not crypto diff --git a/src/hooks/useSumsubKycFlow.ts b/src/hooks/useSumsubKycFlow.ts index 04ef5f976..03c119288 100644 --- a/src/hooks/useSumsubKycFlow.ts +++ b/src/hooks/useSumsubKycFlow.ts @@ -88,6 +88,12 @@ export const useSumsubKycFlow = ({ onKycSuccess, onManualClose, regionIntent }: if (!regionIntent) return // skip if handleInitiateKyc is already in progress — it handles status sync itself if (initiatingRef.current) return + // skip if user already initiated a flow in this session — the SDK or + // handleInitiateKyc manages status from here. without this guard, + // the async fetch can resolve after initiatingRef is reset but before + // showWrapperRef is updated by the batched render, causing a false + // APPROVED transition that closes the SDK. + if (userInitiatedRef.current) return const fetchCurrentStatus = async () => { try { From aac55c65ed293c2c47e188850b1ef2ff4c088415 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 7 May 2026 17:02:12 +0530 Subject: [PATCH 09/15] fix: revert temp balance check bypasses for withdraw and manteca pages --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 32 ++++++++---- src/app/(mobile-ui)/withdraw/page.tsx | 52 ++++++++++++++++--- 2 files changed, 67 insertions(+), 17 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index baee618c4..0d9a47e99 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -408,10 +408,28 @@ export default function MantecaWithdrawFlow() { resetState() }, []) - // todo: temp disabled for local testing — restore before merging useEffect(() => { - setBalanceErrorMessage(null) - }, [usdAmount, balance]) + // Skip balance check if transaction is being processed + // Use hasPendingTransactions to prevent race condition with optimistic updates + // isLoading covers the gap between sendMoney completing and API withdraw completing + if (hasPendingTransactions || isLoading) { + return + } + + if (!usdAmount || usdAmount === '0.00' || isNaN(Number(usdAmount)) || balance === undefined) { + setBalanceErrorMessage(null) + return + } + const paymentAmount = parseUnits(usdAmount, PEANUT_WALLET_TOKEN_DECIMALS) + // only check min amount and balance here - max amount is handled by limits validation + if (paymentAmount < parseUnits(MIN_MANTECA_WITHDRAW_AMOUNT.toString(), PEANUT_WALLET_TOKEN_DECIMALS)) { + setBalanceErrorMessage(`Withdraw amount must be at least $${MIN_MANTECA_WITHDRAW_AMOUNT}`) + } else if (paymentAmount > balance) { + setBalanceErrorMessage('Not enough balance to complete withdrawal.') + } else { + setBalanceErrorMessage(null) + } + }, [usdAmount, balance, hasPendingTransactions, isLoading]) // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // Use flowId as uniqueId to prevent cache collisions between different withdrawal flows @@ -744,9 +762,7 @@ export default function MantecaWithdrawFlow() { : 'Review'} - {(errorMessage || sumsubFlow.error) && ( - - )} + {(errorMessage || sumsubFlow.error) && } )} @@ -803,9 +819,7 @@ export default function MantecaWithdrawFlow() { > {isLoading ? loadingState : 'Withdraw'} - {(errorMessage || sumsubFlow.error) && ( - - )} + {(errorMessage || sumsubFlow.error) && } )} diff --git a/src/app/(mobile-ui)/withdraw/page.tsx b/src/app/(mobile-ui)/withdraw/page.tsx index 91827c188..fd9c264a5 100644 --- a/src/app/(mobile-ui)/withdraw/page.tsx +++ b/src/app/(mobile-ui)/withdraw/page.tsx @@ -165,13 +165,44 @@ export default function WithdrawPage() { } }, []) - // todo: temp disabled for local testing — restore before merging const validateAmount = useCallback( - (_amountStr: string): boolean => { - setError({ showError: false, errorMessage: '' }) - return true + (amountStr: string): boolean => { + if (!amountStr) { + setError({ showError: false, errorMessage: '' }) + return true + } + + const amount = Number(amountStr) + if (!Number.isFinite(amount) || amount <= 0) { + setError({ showError: true, errorMessage: 'Please enter a valid number.' }) + return false + } + + // convert the entered token amount to USD + const price = selectedTokenData?.price ?? 0 // 0 for safety; will fail below + const usdEquivalent = price ? amount * price : amount // if no price assume token pegged 1 USD + + if (usdEquivalent >= minUsdAmount && amount <= maxDecimalAmount) { + setError({ showError: false, errorMessage: '' }) + return true + } + + // determine message + let message = '' + if (usdEquivalent < minUsdAmount) { + const minDisplay = minUsdAmount % 1 === 0 ? `$${minUsdAmount}` : `$${minUsdAmount.toFixed(2)}` + message = isFromSendFlow + ? `Minimum send amount is ${minDisplay}.` + : `Minimum withdrawal is ${minDisplay}.` + } else if (amount > maxDecimalAmount) { + message = 'Amount exceeds your wallet balance.' + } else { + message = 'Please enter a valid amount.' + } + setError({ showError: true, errorMessage: message }) + return false }, - [setError] + [maxDecimalAmount, setError, selectedTokenData?.price, isFromSendFlow, minUsdAmount] ) const handleTokenAmountChange = useCallback( @@ -265,12 +296,17 @@ export default function WithdrawPage() { } // check if continue button should be disabled - // todo: temp disabled for local testing — restore before merging const isContinueDisabled = useMemo(() => { if (!rawTokenAmount) return true + const numericAmount = parseFloat(rawTokenAmount) - return !Number.isFinite(numericAmount) || numericAmount <= 0 - }, [rawTokenAmount]) + if (!Number.isFinite(numericAmount) || numericAmount <= 0) return true + + const usdEq = (selectedTokenData?.price ?? 1) * numericAmount + if (usdEq < minUsdAmount) return true // below country-specific minimum + + return numericAmount > maxDecimalAmount || error.showError + }, [rawTokenAmount, maxDecimalAmount, error.showError, selectedTokenData?.price, minUsdAmount]) if (step === 'inputAmount') { // only show limits card for bank/manteca withdrawals, not crypto From 5689481b7187a20f75996185b7676b44dd5206e7 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 7 May 2026 17:06:18 +0530 Subject: [PATCH 10/15] chore: format manteca withdraw page --- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 0d9a47e99..c8448eaaa 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -762,7 +762,9 @@ export default function MantecaWithdrawFlow() { : 'Review'} - {(errorMessage || sumsubFlow.error) && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} @@ -819,7 +821,9 @@ export default function MantecaWithdrawFlow() { > {isLoading ? loadingState : 'Withdraw'} - {(errorMessage || sumsubFlow.error) && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} From e47cd8bcb35b3bc3fbeb71776a7e0ffa83ec9fe0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 7 May 2026 09:07:20 -0300 Subject: [PATCH 11/15] fix(qr-pay): Pix-specific copy on QR decoding error + capture event Users paying with a Pix QR were told to ask the merchant for a Mercado Pago QR, which is wrong and confusing. Branch the message on qrType so Pix scans get Pix-specific guidance. Also capture qr_decoding_error_shown so we can measure how often this fallback fires and which rails are most affected. --- src/app/(mobile-ui)/qr-pay/page.tsx | 5 ++++- src/constants/analytics.consts.ts | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 0b5d31363..55869a41f 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -579,8 +579,11 @@ export default function QRPayPage() { setWaitingForMerchantAmount(true) } else if (error.message.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { setErrorInitiatingPayment( - 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' + qrType === EQrType.PIX + ? 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' + : 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' ) + posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) } else { // Network/timeout errors after all retries exhausted diff --git a/src/constants/analytics.consts.ts b/src/constants/analytics.consts.ts index a0de9501f..322856bce 100644 --- a/src/constants/analytics.consts.ts +++ b/src/constants/analytics.consts.ts @@ -103,6 +103,7 @@ export const ANALYTICS_EVENTS = { // ── QR ── QR_SCANNED: 'qr_scanned', + QR_DECODING_ERROR_SHOWN: 'qr_decoding_error_shown', // ── Home ── BALANCE_VISIBILITY_TOGGLED: 'balance_visibility_toggled', From e952596d71f30ea33cb6911c6f440afe1be3594a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 7 May 2026 09:12:14 -0300 Subject: [PATCH 12/15] fix(qr-pay): rail-specific decoding-error copy for QR3 too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per CodeRabbit review on #1948: ARGENTINA_QR3 was hitting the default branch and getting told to ask the merchant for a Mercado Pago QR — QR3 and Mercado Pago are distinct Argentine standards, so the copy was actively misleading. Now branches PIX, ARGENTINA_QR3, and the MP default explicitly. --- src/app/(mobile-ui)/qr-pay/page.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index 55869a41f..c75c3f6f9 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -578,11 +578,16 @@ export default function QRPayPage() { if (error.message.includes('PAYMENT_DESTINATION_MISSING_AMOUNT')) { setWaitingForMerchantAmount(true) } else if (error.message.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { - setErrorInitiatingPayment( - qrType === EQrType.PIX - ? 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' - : 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' - ) + let decodingMessage = + 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' + if (qrType === EQrType.PIX) { + decodingMessage = + 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' + } else if (qrType === EQrType.ARGENTINA_QR3) { + decodingMessage = + 'We could not decode this QR Interoperable code. Please ask the merchant to generate a new one.' + } + setErrorInitiatingPayment(decodingMessage) posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) } else { From f0427ad156b4ddf70a42e8fca19cfb4a37982fb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 7 May 2026 09:15:04 -0300 Subject: [PATCH 13/15] revert(qr-pay): keep MP fallback copy for ARGENTINA_QR3 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Argentina, Mercado Pago is the dominant rail and a workable fallback when a QR3 code fails to decode — so suggesting "ask for a Mercado Pago QR" is the most useful guidance for QR3 users too. Only Pix needs the rail-specific copy because Brazil has no equivalent fallback. Reverts the previous QR3-specific message; adds a comment explaining the regional reasoning so future readers don't trip on the same point CodeRabbit raised. --- src/app/(mobile-ui)/qr-pay/page.tsx | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index c75c3f6f9..7c1e8f4fd 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -578,16 +578,14 @@ export default function QRPayPage() { if (error.message.includes('PAYMENT_DESTINATION_MISSING_AMOUNT')) { setWaitingForMerchantAmount(true) } else if (error.message.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { - let decodingMessage = - 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' - if (qrType === EQrType.PIX) { - decodingMessage = - 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' - } else if (qrType === EQrType.ARGENTINA_QR3) { - decodingMessage = - 'We could not decode this QR Interoperable code. Please ask the merchant to generate a new one.' - } - setErrorInitiatingPayment(decodingMessage) + // Pix has no fallback rail in Brazil — ask the merchant to regenerate. + // For Argentina (MERCADO_PAGO and ARGENTINA_QR3), MP is the dominant + // rail, so suggesting an MP QR is the most useful fallback. + setErrorInitiatingPayment( + qrType === EQrType.PIX + ? 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' + : 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' + ) posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) } else { From 5b16daf9700e6c043af2c707604bb37a53567231 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 7 May 2026 09:25:10 -0300 Subject: [PATCH 14/15] fix: format --- src/app/(mobile-ui)/add-money/[country]/bank/page.tsx | 6 +++++- src/app/(mobile-ui)/withdraw/manteca/page.tsx | 8 ++++++-- src/app/actions/sumsub.ts | 5 +---- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx index ae32c5bf6..4bf48e8ed 100644 --- a/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx +++ b/src/app/(mobile-ui)/add-money/[country]/bank/page.tsx @@ -410,7 +410,11 @@ export default function OnrampBankPage() { if (!needsBridgeEnrollment && bridgeRejection.state === 'fixable') { await sumsubFlow.handleSelfHealResubmit('BRIDGE') } else { - await sumsubFlow.handleInitiateKyc('STANDARD', undefined, needsBridgeEnrollment || undefined) + await sumsubFlow.handleInitiateKyc( + 'STANDARD', + undefined, + needsBridgeEnrollment || undefined + ) } setShowKycModal(false) }} diff --git a/src/app/(mobile-ui)/withdraw/manteca/page.tsx b/src/app/(mobile-ui)/withdraw/manteca/page.tsx index 0d9a47e99..c8448eaaa 100644 --- a/src/app/(mobile-ui)/withdraw/manteca/page.tsx +++ b/src/app/(mobile-ui)/withdraw/manteca/page.tsx @@ -762,7 +762,9 @@ export default function MantecaWithdrawFlow() { : 'Review'} - {(errorMessage || sumsubFlow.error) && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} @@ -819,7 +821,9 @@ export default function MantecaWithdrawFlow() { > {isLoading ? loadingState : 'Withdraw'} - {(errorMessage || sumsubFlow.error) && } + {(errorMessage || sumsubFlow.error) && ( + + )} )} diff --git a/src/app/actions/sumsub.ts b/src/app/actions/sumsub.ts index aefc3796d..8cb387990 100644 --- a/src/app/actions/sumsub.ts +++ b/src/app/actions/sumsub.ts @@ -40,10 +40,7 @@ export const initiateSumsubKyc = async (params?: { if (!response.ok) { return { - error: - responseJson.userMessage || - responseJson.error || - 'Failed to initiate identity verification', + error: responseJson.userMessage || responseJson.error || 'Failed to initiate identity verification', } } From c248f9e3873e0d57cc4e7d1df6c856496f0af33c Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Thu, 7 May 2026 19:57:46 +0530 Subject: [PATCH 15/15] fix: re-apply qr-pay inline KYC changes on dev's simplefi-free version --- src/app/(mobile-ui)/qr-pay/page.tsx | 618 ++++++---------------------- 1 file changed, 125 insertions(+), 493 deletions(-) diff --git a/src/app/(mobile-ui)/qr-pay/page.tsx b/src/app/(mobile-ui)/qr-pay/page.tsx index f02007d2a..a022047e0 100644 --- a/src/app/(mobile-ui)/qr-pay/page.tsx +++ b/src/app/(mobile-ui)/qr-pay/page.tsx @@ -8,23 +8,25 @@ import { Button } from '@/components/0_Bruddle/Button' import { Icon } from '@/components/Global/Icons/Icon' import { mantecaApi } from '@/services/manteca' import type { QrPayment, QrPaymentLock } from '@/services/manteca' -import { simplefiApi } from '@/services/simplefi' -import type { SimpleFiQrPaymentResponse } from '@/services/simplefi' import NavHeader from '@/components/Global/NavHeader' -import { MERCADO_PAGO, PIX, SIMPLEFI } from '@/assets/payment-apps' +import { MERCADO_PAGO, PIX } from '@/assets/payment-apps' +import { getFlagUrl } from '@/constants/countryCurrencyMapping' import Image from 'next/image' import PeanutLoading from '@/components/Global/PeanutLoading' import AmountInput from '@/components/Global/AmountInput' import { useWallet } from '@/hooks/wallet/useWallet' -import { useSignUserOp } from '@/hooks/wallet/useSignUserOp' -import { isTxReverted, saveRedirectUrl, formatNumberForDisplay } from '@/utils/general.utils' +import { useSignSpendBundle } from '@/hooks/wallet/useSignSpendBundle' +import { InsufficientSpendableError, SessionKeyGrantRequiredError } from '@/hooks/wallet/useSpendBundle' +import { useRainCardOverview } from '@/hooks/useRainCardOverview' +import { rainSpendingPowerToWei } from '@/utils/balance.utils' +import { isTxReverted, formatNumberForDisplay } from '@/utils/general.utils' import { getShakeClass, type ShakeIntensity } from '@/utils/perk.utils' import { calculateSavingsInCents, isArgentinaMantecaQrPayment, getSavingsMessage } from '@/utils/qr-payment.utils' import ErrorAlert from '@/components/Global/ErrorAlert' -import { PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' +import { PEANUT_WALLET_CHAIN, PEANUT_WALLET_TOKEN_DECIMALS } from '@/constants/zerodev.consts' import { PERK_HOLD_DURATION_MS } from '@/constants/general.consts' import { MANTECA_DEPOSIT_ADDRESS } from '@/constants/manteca.consts' -import { MIN_MANTECA_QR_PAYMENT_AMOUNT } from '@/constants/payment.consts' +import { MIN_MANTECA_QR_PAYMENT_AMOUNT, MIN_PIX_AMOUNT_BRL } from '@/constants/payment.consts' import { formatUnits, parseUnits } from 'viem' import type { TransactionReceipt, Hash } from 'viem' import { useTransactionDetailsDrawer } from '@/hooks/useTransactionDetailsDrawer' @@ -36,14 +38,7 @@ import { PaymentInfoRow } from '@/components/Payment/PaymentInfoRow' import { captureException } from '@sentry/nextjs' import posthog from 'posthog-js' import { ANALYTICS_EVENTS } from '@/constants/analytics.consts' -import { - isPaymentProcessorQR, - parseSimpleFiQr, - EQrType, - NAME_BY_QR_TYPE, - type QrType, -} from '@/components/Global/DirectSendQR/utils' -import type { SimpleFiQrData } from '@/components/Global/DirectSendQR/utils' +import { isPaymentProcessorQR, EQrType, NAME_BY_QR_TYPE, type QrType } from '@/components/Global/DirectSendQR/utils' import { QrKycState, useQrKycGate } from '@/hooks/useQrKycGate' import ActionModal from '@/components/Global/ActionModal' import { SoundPlayer } from '@/components/Global/SoundPlayer' @@ -54,9 +49,6 @@ import { useAuth } from '@/context/authContext' import { PointsAction } from '@/services/services.types' import { usePointsConfetti } from '@/hooks/usePointsConfetti' import { usePointsCalculation } from '@/hooks/usePointsCalculation' -import { useWebSocket } from '@/hooks/useWebSocket' -import type { HistoryEntry } from '@/hooks/useTransactionHistory' -import { completeHistoryEntry } from '@/utils/history.utils' import { useModalsContext } from '@/context/ModalsContext' import maintenanceConfig from '@/config/underMaintenance.config' import PointsCard from '@/components/Common/PointsCard' @@ -75,7 +67,7 @@ import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals' const MAX_QR_PAYMENT_AMOUNT = '2000' const MIN_QR_PAYMENT_AMOUNT = '0.1' -type PaymentProcessor = 'MANTECA' | 'SIMPLEFI' +type PaymentProcessor = 'MANTECA' export default function QRPayPage() { const searchParams = useSearchParams() @@ -83,15 +75,14 @@ export default function QRPayPage() { const qrCode = decodeURIComponent(searchParams.get('qrCode') || '') const timestamp = searchParams.get('t') const qrType = searchParams.get('type') - const { balance, sendMoney } = useWallet() - const { signTransferUserOp } = useSignUserOp() + const { spendableBalance: balance, sendMoney } = useWallet() + const { signSpend } = useSignSpendBundle() + const { overview: rainCardOverview } = useRainCardOverview() const [isSuccess, setIsSuccess] = useState(false) const [errorMessage, setErrorMessage] = useState(null) const [balanceErrorMessage, setBalanceErrorMessage] = useState(null) const [errorInitiatingPayment, setErrorInitiatingPayment] = useState(null) const [paymentLock, setPaymentLock] = useState(null) - const [simpleFiPayment, setSimpleFiPayment] = useState(null) - const [simpleFiQrData, setSimpleFiQrData] = useState(null) const [showOrderNotReadyModal, setShowOrderNotReadyModal] = useState(false) const [isFirstLoad, setIsFirstLoad] = useState(true) const [amount, setAmount] = useState(undefined) @@ -104,10 +95,6 @@ export default function QRPayPage() { const paymentProcessor: PaymentProcessor | null = useMemo(() => { switch (qrType) { - case EQrType.SIMPLEFI_STATIC: - case EQrType.SIMPLEFI_DYNAMIC: - case EQrType.SIMPLEFI_USER_SPECIFIED: - return 'SIMPLEFI' case EQrType.MERCADO_PAGO: case EQrType.ARGENTINA_QR3: case EQrType.PIX: @@ -122,7 +109,7 @@ export default function QRPayPage() { return paymentProcessor ? maintenanceConfig.disabledPaymentProviders.includes(paymentProcessor) : false }, [paymentProcessor]) - const { shouldBlockPay, kycGateState } = useQrKycGate(paymentProcessor) + const { shouldBlockPay, kycGateState } = useQrKycGate() const { isUserMantecaKycApproved, isUserSumsubKycApproved } = useKycStatus() const sumsubFlow = useMultiPhaseKycFlow({}) const queryClient = useQueryClient() @@ -136,9 +123,6 @@ export default function QRPayPage() { const holdStartTimeRef = useRef(null) const payingStateTimerRef = useRef(null) const { user } = useAuth() - const [pendingSimpleFiPaymentId, setPendingSimpleFiPaymentId] = useState(null) - const [isWaitingForWebSocket, setIsWaitingForWebSocket] = useState(false) - const [shouldRetry, setShouldRetry] = useState(true) const { setIsSupportModalOpen, openSupportWithMessage: openSupportForLimits } = useModalsContext() const [waitingForMerchantAmount, setWaitingForMerchantAmount] = useState(false) const retryCount = useRef(0) @@ -153,8 +137,6 @@ export default function QRPayPage() { setBalanceErrorMessage(null) setErrorInitiatingPayment(null) setPaymentLock(null) - setSimpleFiPayment(null) - setSimpleFiQrData(null) setShowOrderNotReadyModal(false) setIsFirstLoad(true) setAmount(undefined) @@ -169,10 +151,6 @@ export default function QRPayPage() { setHoldProgress(0) setIsShaking(false) setShakeIntensity('none') - // reset retry and websocket states to allow refetching - setShouldRetry(true) - setIsWaitingForWebSocket(false) - setPendingSimpleFiPaymentId(null) setWaitingForMerchantAmount(false) retryCount.current = 0 // reset perk states @@ -193,6 +171,28 @@ export default function QRPayPage() { } }, []) + // Reopening the app onto a past QR URL (last merchant, expired lock) is stale — + // after a real absence, drop the user on home so they can start fresh. + useEffect(() => { + const STALE_THRESHOLD_MS = 30_000 + let hiddenAt: number | null = null + + const onVisibility = () => { + if (document.hidden) { + hiddenAt = Date.now() + return + } + if (hiddenAt === null) return + const elapsed = Date.now() - hiddenAt + hiddenAt = null + if (elapsed > STALE_THRESHOLD_MS) { + router.push('/home') + } + } + document.addEventListener('visibilitychange', onVisibility) + return () => document.removeEventListener('visibilitychange', onVisibility) + }, [router]) + // Track reward claim shown + surprise moment when perk UI appears after payment useEffect(() => { perkClaimedRef.current = perkClaimed @@ -220,92 +220,6 @@ export default function QRPayPage() { } }, []) - const handleSimpleFiStatusUpdate = useCallback( - async (entry: HistoryEntry) => { - if (!pendingSimpleFiPaymentId || entry.uuid !== pendingSimpleFiPaymentId) { - return - } - - if (entry.type !== EHistoryEntryType.SIMPLEFI_QR_PAYMENT) { - return - } - - console.log('[SimpleFi WebSocket] Received status update:', entry.status) - - // Process entry through completeHistoryEntry to format amounts correctly - let completedEntry - try { - completedEntry = await completeHistoryEntry(entry) - } catch (error) { - console.error('[SimpleFi WebSocket] Failed to process entry:', error) - captureException(error, { - tags: { feature: 'simplefi-websocket' }, - extra: { entryUuid: entry.uuid }, - }) - setIsWaitingForWebSocket(false) - setPendingSimpleFiPaymentId(null) - setErrorMessage('We received an update, but failed to process it. Please check your history.') - setIsSuccess(false) - setLoadingState('Idle') - return - } - - setIsWaitingForWebSocket(false) - setPendingSimpleFiPaymentId(null) - - switch (completedEntry.status) { - case 'approved': { - // Guard against missing currency or simpleFiPayment data - if (!completedEntry.currency?.code || !completedEntry.currency?.amount) { - console.error('[SimpleFi WebSocket] Currency data missing on approval') - captureException(new Error('SimpleFi payment approved but currency details missing'), { - extra: { entryUuid: completedEntry.uuid }, - }) - setErrorMessage('Payment approved, but details are incomplete. Please check your history.') - setIsSuccess(false) - setLoadingState('Idle') - break - } - - if (!simpleFiPayment) { - console.error('[SimpleFi WebSocket] SimpleFi payment details missing on approval') - captureException(new Error('SimpleFi payment details missing on approval'), { - extra: { entryUuid: completedEntry.uuid }, - }) - setErrorMessage('Payment approved, but details are missing. Please check your history.') - setIsSuccess(false) - setLoadingState('Idle') - break - } - - setSimpleFiPayment({ - id: completedEntry.uuid, - usdAmount: completedEntry.extraData?.usdAmount || completedEntry.amount, - currency: completedEntry.currency.code, - currencyAmount: completedEntry.currency.amount, - price: simpleFiPayment.price, - address: simpleFiPayment.address, - }) - setIsSuccess(true) - setLoadingState('Idle') - break - } - - case 'expired': - case 'canceled': - case 'refunded': - setErrorMessage('Payment failed or expired. Please try again.') - setIsSuccess(false) - setLoadingState('Idle') - break - - default: - console.log('[SimpleFi WebSocket] Unknown status:', completedEntry.status) - } - }, - [pendingSimpleFiPaymentId, simpleFiPayment, setLoadingState] - ) - useEffect(() => { if (isSuccess || !!errorMessage) { setLoadingState('Idle') @@ -321,37 +235,9 @@ export default function QRPayPage() { return } - if (paymentProcessor === 'SIMPLEFI') { - const parsed = parseSimpleFiQr(qrCode) - setSimpleFiQrData(parsed) - } - setIsFirstLoad(false) }, [timestamp, paymentProcessor, qrCode]) - useWebSocket({ - username: user?.user.username ?? undefined, - autoConnect: true, - onHistoryEntry: handleSimpleFiStatusUpdate, - }) - - useEffect(() => { - if (!isWaitingForWebSocket || !pendingSimpleFiPaymentId) return - - const timeout = setTimeout( - () => { - console.log('[SimpleFi WebSocket] Timeout after 5 minutes') - setIsWaitingForWebSocket(false) - setPendingSimpleFiPaymentId(null) - setErrorMessage('Payment is taking longer than expected. Please check your transaction history.') - setLoadingState('Idle') - }, - 5 * 60 * 1000 - ) - - return () => clearTimeout(timeout) - }, [isWaitingForWebSocket, pendingSimpleFiPaymentId, setLoadingState]) - // Get amount from payment lock (Manteca) useEffect(() => { if (paymentProcessor !== 'MANTECA') return @@ -389,30 +275,11 @@ export default function QRPayPage() { getCurrencyObject().then(setCurrency) }, [paymentLock?.code, paymentProcessor]) - // Set default currency for SimpleFi USER_SPECIFIED (user will enter amount) - useEffect(() => { - if (paymentProcessor !== 'SIMPLEFI') return - if (simpleFiQrData?.type !== 'SIMPLEFI_USER_SPECIFIED') return - if (currency) return // Already set - - // Default to ARS for SimpleFi payments - getCurrencyPrice('ARS').then((priceData) => { - setCurrency({ - code: 'ARS', - symbol: 'ARS', - price: priceData.sell, - }) - }) - }, [paymentProcessor, simpleFiQrData?.type, currency]) - const isBlockingError = useMemo(() => { return !!errorMessage && errorMessage !== 'Please confirm the transaction.' }, [errorMessage]) const usdAmount = useMemo(() => { - if (paymentProcessor === 'SIMPLEFI') { - return simpleFiPayment?.usdAmount || amount - } if (!paymentLock) return null if (paymentLock.code === '') { // For static QR codes (user inputs amount), convert from local currency to USD @@ -422,10 +289,10 @@ export default function QRPayPage() { // For dynamic QR codes, backend provides the USD amount return paymentLock.paymentAgainstAmount } - }, [paymentProcessor, simpleFiPayment, paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) + }, [paymentLock?.code, paymentLock?.paymentAgainstAmount, amount]) // validate payment against user's limits - // currency comes from payment lock/simplefi - hook normalizes it internally + // currency comes from payment lock — hook normalizes it internally const limitsValidation = useLimitsValidation({ flowType: 'qr-payment', amount: usdAmount, @@ -443,7 +310,6 @@ export default function QRPayPage() { // Fetch points early to avoid latency penalty - fetch as soon as we have usdAmount // This way points are cached by the time success view shows - // Only Manteca QR payments give points (SimpleFi does not) // Use timestamp as uniqueId to prevent cache collisions between different QR scans const { pointsData, pointsDivRef } = usePointsCalculation( PointsAction.MANTECA_QR_PAYMENT, @@ -457,67 +323,14 @@ export default function QRPayPage() { case EQrType.MERCADO_PAGO: return MERCADO_PAGO case EQrType.ARGENTINA_QR3: - return 'https://flagcdn.com/w160/ar.png' + return getFlagUrl('ar') case EQrType.PIX: return PIX - case EQrType.SIMPLEFI_STATIC: - case EQrType.SIMPLEFI_DYNAMIC: - case EQrType.SIMPLEFI_USER_SPECIFIED: - return SIMPLEFI default: return null } }, [qrType]) - // Fetch SimpleFi payment details - useEffect(() => { - if (paymentProcessor !== 'SIMPLEFI' || !simpleFiQrData) return - if (!!simpleFiPayment) return - if (kycGateState !== QrKycState.PROCEED_TO_PAY) return - - const fetchSimpleFiPayment = async () => { - setLoadingState('Fetching details') - try { - let response: SimpleFiQrPaymentResponse - - if (simpleFiQrData.type === 'SIMPLEFI_STATIC') { - response = await simplefiApi.initiateQrPayment({ - type: 'STATIC', - merchantSlug: simpleFiQrData.merchantSlug, - }) - } else if (simpleFiQrData.type === 'SIMPLEFI_DYNAMIC') { - response = await simplefiApi.initiateQrPayment({ - type: 'DYNAMIC', - simplefiRequestId: simpleFiQrData.paymentId, - }) - } else { - setLoadingState('Idle') - return - } - - setSimpleFiPayment(response) - setAmount(response.usdAmount) - setCurrencyAmount(response.currencyAmount) - setCurrency({ - code: 'ARS', - symbol: 'ARS', - price: Number(response.price), - }) - } catch (error) { - const errorMsg = (error as Error).message - if (errorMsg.includes('ready to pay')) { - setShowOrderNotReadyModal(true) - } else { - setErrorInitiatingPayment(errorMsg) - } - } finally { - setLoadingState('Idle') - } - } - - fetchSimpleFiPayment() - }, [kycGateState, simpleFiPayment, simpleFiQrData, paymentProcessor, setLoadingState]) - // Fetch Manteca payment lock immediately on QR scan (Manteca only) // OPTIMIZATION: We fetch payment details BEFORE KYC check completes for faster UX // This is SAFE because: @@ -536,6 +349,7 @@ export default function QRPayPage() { isLoading: isLoadingPaymentLock, error: paymentLockError, failureReason: paymentLockFailureReason, + refetch: refetchPaymentLock, } = useQuery({ queryKey: ['manteca-payment-lock', qrCode, timestamp], queryFn: async () => { @@ -586,15 +400,9 @@ export default function QRPayPage() { if (error.message.includes('PAYMENT_DESTINATION_MISSING_AMOUNT')) { setWaitingForMerchantAmount(true) } else if (error.message.includes('PAYMENT_DESTINATION_DECODING_ERROR')) { - // Pix has no fallback rail in Brazil — ask the merchant to regenerate. - // For Argentina (MERCADO_PAGO and ARGENTINA_QR3), MP is the dominant - // rail, so suggesting an MP QR is the most useful fallback. setErrorInitiatingPayment( - qrType === EQrType.PIX - ? 'We could not decode this Pix QR code. Please ask the merchant to generate a new one.' - : 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' + 'We could not decode this particular QR code. Please ask the Merchant if they can generate a Mercado Pago QR' ) - posthog.capture(ANALYTICS_EVENTS.QR_DECODING_ERROR_SHOWN, { qr_type: qrType }) setWaitingForMerchantAmount(false) } else { // Network/timeout errors after all retries exhausted @@ -616,78 +424,9 @@ export default function QRPayPage() { ]) const merchantName = useMemo(() => { - if (paymentProcessor === 'SIMPLEFI') { - if (simpleFiQrData?.type === 'SIMPLEFI_STATIC' || simpleFiQrData?.type === 'SIMPLEFI_USER_SPECIFIED') { - return simpleFiQrData.merchantSlug.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()) - } - return 'SimpleFi Merchant' - } if (!paymentLock) return null return paymentLock.paymentRecipientName - }, [paymentProcessor, simpleFiQrData, paymentLock]) - - const handleSimpleFiPayment = useCallback(async () => { - if (!simpleFiPayment && !simpleFiQrData) return - - let finalPayment = simpleFiPayment - - if (simpleFiQrData?.type === 'SIMPLEFI_USER_SPECIFIED' && !simpleFiPayment && currencyAmount) { - setLoadingState('Fetching details') - try { - finalPayment = await simplefiApi.initiateQrPayment({ - type: 'USER_SPECIFIED', - merchantSlug: simpleFiQrData.merchantSlug, - currencyAmount: currencyAmount, - currency: 'ARS', - }) - setSimpleFiPayment(finalPayment) - } catch (error) { - captureException(error) - setErrorMessage('Unable to process payment. Please try again') - setIsSuccess(false) - setLoadingState('Idle') - return - } - } - - if (!finalPayment) { - setErrorMessage('Unable to fetch payment details') - setIsSuccess(false) - setLoadingState('Idle') - return - } - - setLoadingState('Preparing transaction') - let userOpHash: Hash - let receipt: TransactionReceipt | null - try { - const result = await sendMoney(finalPayment.address, finalPayment.usdAmount) - userOpHash = result.userOpHash - receipt = result.receipt - } catch (error) { - if ((error as Error).toString().includes('not allowed')) { - setErrorMessage('Please confirm the transaction in your wallet') - } else { - captureException(error) - setErrorMessage('Could not complete the transaction') - setIsSuccess(false) - } - setLoadingState('Idle') - return - } - - if (receipt !== null && isTxReverted(receipt)) { - setErrorMessage('Transaction was rejected by the network') - setLoadingState('Idle') - setIsSuccess(false) - return - } - - console.log('[SimpleFi] Transaction sent, waiting for WebSocket confirmation...') - setLoadingState('Paying') - setIsWaitingForWebSocket(true) - setPendingSimpleFiPaymentId(finalPayment.id) - }, [simpleFiPayment, simpleFiQrData, currencyAmount, sendMoney, setLoadingState]) + }, [paymentLock]) const handleMantecaPayment = useCallback(async () => { if (!paymentLock || !qrCode || !currencyAmount) return @@ -718,50 +457,70 @@ export default function QRPayPage() { } setLoadingState('Preparing transaction') - let signedUserOpData + // Route across smart-only / mixed / collateral-only — pure-collateral + // payments (smart wallet empty, card collateral covers it) used to fail + // here because ZeroDev's paymaster simulated a USDC transfer from a + // zero-balance smart account and refused to sponsor. The signSpend + // hook now picks the right routing, including a single-tap + // collateral-only path that lets Rain transfer straight from the + // collateral proxy to MANTECA's deposit address. + let signedArtifact try { - signedUserOpData = await signTransferUserOp(MANTECA_DEPOSIT_ADDRESS, finalPaymentLock.paymentAgainstAmount) + const requiredUsdcAmount = parseUnits(finalPaymentLock.paymentAgainstAmount, PEANUT_WALLET_TOKEN_DECIMALS) + signedArtifact = await signSpend({ + requiredUsdcAmount, + recipient: MANTECA_DEPOSIT_ADDRESS, + smartBalance: balance ?? 0n, + rainSpendingPower: rainSpendingPowerToWei(rainCardOverview?.balance?.spendingPower), + kind: 'QR_PAY', + }) } catch (error) { - if ((error as Error).toString().includes('not allowed')) { + if (error instanceof InsufficientSpendableError) { + setErrorMessage('Not enough USDC in your wallet or card to cover this payment.') + } else if (error instanceof SessionKeyGrantRequiredError) { + setErrorMessage("One-time card authorization needed. You'll be asked to confirm once.") + } else if ((error as Error).toString().includes('not allowed')) { setErrorMessage('Please confirm the transaction.') } else { captureException(error) setErrorMessage('Could not sign the transaction.') - setIsSuccess(false) } + setIsSuccess(false) setLoadingState('Idle') return } - // Send signed UserOp to backend for coordinated execution - // Backend will: 1) Complete Manteca payment, 2) Broadcast UserOp only if Manteca succeeds - // schedule "paying" state after 3 seconds to give user feedback that payment is processing + // Send signed artifact to backend for coordinated execution. + // Backend creates the Manteca order FIRST, then either broadcasts the + // signed UserOp (smart-only / mixed) or submits the Rain withdrawal via + // the user's session-key UserOp (collateral-only). + // Schedule "paying" state after 3s so the user sees something is happening. payingStateTimerRef.current = setTimeout(() => setLoadingState('Paying'), 3000) try { - const signedUserOp = { - sender: signedUserOpData.signedUserOp.sender, - nonce: signedUserOpData.signedUserOp.nonce, - callData: signedUserOpData.signedUserOp.callData, - signature: signedUserOpData.signedUserOp.signature, - callGasLimit: signedUserOpData.signedUserOp.callGasLimit, - verificationGasLimit: signedUserOpData.signedUserOp.verificationGasLimit, - preVerificationGas: signedUserOpData.signedUserOp.preVerificationGas, - factory: signedUserOpData.signedUserOp.factory, - factoryData: signedUserOpData.signedUserOp.factoryData, - maxFeePerGas: signedUserOpData.signedUserOp.maxFeePerGas, - maxPriorityFeePerGas: signedUserOpData.signedUserOp.maxPriorityFeePerGas, - paymaster: signedUserOpData.signedUserOp.paymaster, - paymasterData: signedUserOpData.signedUserOp.paymasterData, - paymasterVerificationGasLimit: signedUserOpData.signedUserOp.paymasterVerificationGasLimit, - paymasterPostOpGasLimit: signedUserOpData.signedUserOp.paymasterPostOpGasLimit, - } - const qrPayment = await mantecaApi.completeQrPaymentWithSignedTx({ - paymentLockCode: finalPaymentLock.code, - signedUserOp, - chainId: signedUserOpData.chainId, - entryPointAddress: signedUserOpData.entryPointAddress, - qrType: qrType ?? undefined, - }) + const requestBody = + signedArtifact.strategy === 'collateral-only' + ? ({ + kind: 'rainWithdrawal' as const, + paymentLockCode: finalPaymentLock.code, + qrType: qrType ?? undefined, + signedRainWithdrawal: signedArtifact.rainWithdrawal, + chainId: PEANUT_WALLET_CHAIN.id.toString(), + } as const) + : ({ + kind: 'userOp' as const, + paymentLockCode: finalPaymentLock.code, + qrType: qrType ?? undefined, + signedUserOp: signedArtifact.signedUserOp.signedUserOp, + chainId: signedArtifact.signedUserOp.chainId, + entryPointAddress: signedArtifact.signedUserOp.entryPointAddress, + // For mixed: tell backend about the Rain prepare intent + // embedded in the UserOp's batched callData so it can + // reconcile the collateral webhook to QR_PAY in history. + ...(signedArtifact.strategy === 'mixed' + ? { rainPreparationId: signedArtifact.rainPreparationId } + : {}), + } as const) + const qrPayment = await mantecaApi.completeQrPaymentWithSignedTx(requestBody) // clear the timer since we got a response if (payingStateTimerRef.current) { clearTimeout(payingStateTimerRef.current) @@ -779,6 +538,11 @@ export default function QRPayPage() { // this ensures a consistent reward experience regardless of amount. setIsSuccess(true) + posthog.capture(ANALYTICS_EVENTS.CARD_WITHDRAW_SUCCEEDED, { + strategy: signedArtifact.strategy, + kind: 'QR_PAY', + flow: 'sign-only', + }) } catch (error) { // clear the timer on error to prevent race condition if (payingStateTimerRef.current) { @@ -808,15 +572,13 @@ export default function QRPayPage() { } finally { setLoadingState('Idle') } - }, [paymentLock?.code, signTransferUserOp, qrCode, currencyAmount, setLoadingState, qrType]) + }, [paymentLock, signSpend, balance, rainCardOverview, qrCode, currencyAmount, setLoadingState, qrType]) const payQR = useCallback(async () => { - if (paymentProcessor === 'SIMPLEFI') { - await handleSimpleFiPayment() - } else if (paymentProcessor === 'MANTECA') { + if (paymentProcessor === 'MANTECA') { await handleMantecaPayment() } - }, [paymentProcessor, handleSimpleFiPayment, handleMantecaPayment]) + }, [paymentProcessor, handleMantecaPayment]) // DEV NOTE: This is an OPTIMISTIC claim flow for better UX // We immediately show success UI and trigger confetti, then claim in background @@ -996,6 +758,11 @@ export default function QRPayPage() { setBalanceErrorMessage(`Payment amount must be at least $${MIN_MANTECA_QR_PAYMENT_AMOUNT}`) return } + // PIX rail enforces a 1 BRL minimum, stricter than the USD floor above + if (currency?.code === 'BRL' && currencyAmount && parseFloat(currencyAmount) < MIN_PIX_AMOUNT_BRL) { + setBalanceErrorMessage(`Minimum PIX amount is ${MIN_PIX_AMOUNT_BRL} BRL`) + return + } } // Common validations for all payment processors @@ -1008,7 +775,7 @@ export default function QRPayPage() { } else { setBalanceErrorMessage(null) } - }, [usdAmount, balance, paymentProcessor]) + }, [usdAmount, balance, paymentProcessor, currency?.code, currencyAmount]) // Use points confetti hook for animation - must be called unconditionally usePointsConfetti(isSuccess && pointsData?.estimatedPoints ? pointsData.estimatedPoints : undefined, pointsDivRef) @@ -1019,43 +786,6 @@ export default function QRPayPage() { } }, [isSuccess, queryClient]) - const handleSimplefiRetry = useCallback(async () => { - setShowOrderNotReadyModal(false) - if (!simpleFiQrData || simpleFiQrData.type !== 'SIMPLEFI_STATIC') return - - setLoadingState('Fetching details') - try { - const response = await simplefiApi.initiateQrPayment({ - type: 'STATIC', - merchantSlug: simpleFiQrData.merchantSlug, - }) - setSimpleFiPayment(response) - setAmount(response.currencyAmount) - setCurrencyAmount(response.currencyAmount) - setCurrency({ - code: 'ARS', - symbol: 'ARS', - price: Number(response.price), - }) - } catch (error) { - const errorMsg = (error as Error).message - if (errorMsg.includes('ready to pay')) { - setShowOrderNotReadyModal(true) - } else { - setErrorInitiatingPayment(errorMsg) - } - } finally { - setLoadingState('Idle') - } - }, [simpleFiQrData, setLoadingState]) - - useEffect(() => { - if (paymentProcessor !== 'SIMPLEFI') return - if (!shouldRetry) return - setShouldRetry(false) - handleSimplefiRetry() - }, [shouldRetry, handleSimplefiRetry]) - useEffect(() => { if (waitingForMerchantAmount && !isLoadingPaymentLock) { setWaitingForMerchantAmount(false) @@ -1068,20 +798,17 @@ export default function QRPayPage() { // get user-facing payment method name for maintenance screen // NOTE: must be above early returns to comply with React's Rules of Hooks const paymentMethodName = useMemo(() => { - if (paymentProcessor === 'MANTECA') { - switch (qrType) { - case EQrType.PIX: - return 'PIX' - case EQrType.MERCADO_PAGO: - return 'Mercado Pago' - case EQrType.ARGENTINA_QR3: - return 'QR' - default: - return 'QR' - } + switch (qrType) { + case EQrType.PIX: + return 'PIX' + case EQrType.MERCADO_PAGO: + return 'Mercado Pago' + case EQrType.ARGENTINA_QR3: + return 'QR' + default: + return 'QR' } - return 'SimpleFi' - }, [paymentProcessor, qrType]) + }, [qrType]) // only show KYC modals after KYC state has loaded // explicitly check for KYC states that require blocking (not PROCEED_TO_PAY) @@ -1246,10 +973,7 @@ export default function QRPayPage() { // check if we're still loading payment data before showing anything const isLoadingPaymentData = - isFirstLoad || - (paymentProcessor === 'MANTECA' && !paymentLock) || - (paymentProcessor === 'SIMPLEFI' && simpleFiQrData?.type !== 'SIMPLEFI_USER_SPECIFIED' && !simpleFiPayment) || - !currency + !shouldBlockPay && (isFirstLoad || (paymentProcessor === 'MANTECA' && !paymentLock) || !currency) if (waitingForMerchantAmount) { return @@ -1269,8 +993,10 @@ export default function QRPayPage() { - - - - - - - ) } return ( @@ -1662,12 +1304,7 @@ export default function QRPayPage() { }} setSecondaryAmount={setAmount} disabled={ - !!qrPayment || - isLoading || - (paymentProcessor === 'MANTECA' && paymentLock?.code !== '') || - (paymentProcessor === 'SIMPLEFI' && - simpleFiQrData?.type !== 'SIMPLEFI_USER_SPECIFIED' && - !!simpleFiPayment) + !!qrPayment || isLoading || (paymentProcessor === 'MANTECA' && paymentLock?.code !== '') } walletBalance={balance ? formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS) : undefined} hideBalance @@ -1713,7 +1350,7 @@ export default function QRPayPage() { {/* Error State */}