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/18] =?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/18] 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/18] 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/18] =?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/18] 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/18] =?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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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/18] 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 acfbf7525a262f810ccca1f4fabb50afde733482 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 8 May 2026 11:16:03 +0100 Subject: [PATCH 15/18] ci(update-content): target dev (not default branch) for content submodule bumps --- .github/workflows/update-content.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/update-content.yml b/.github/workflows/update-content.yml index aa741a920..ee1132ce3 100644 --- a/.github/workflows/update-content.yml +++ b/.github/workflows/update-content.yml @@ -66,5 +66,6 @@ jobs: -f "sha=$COMMIT_SHA" gh pr create \ --head "$BRANCH" \ + --base dev \ --title "Update content submodule" \ --body "Auto-generated: updates content submodule to latest peanut-content main." From 933617c00de59a353e4a0bb4290f608f6f067dcc Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 8 May 2026 11:16:04 +0100 Subject: [PATCH 16/18] ci(tests): trigger on pull_request so API-created PRs run tests --- .github/workflows/tests.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 181cb4a07..78710d38c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -3,6 +3,13 @@ name: Tests on: push: branches: ['**'] + pull_request: + branches: [main, dev] + +# Cancel in-progress runs on the same PR / branch when a newer commit lands. +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: test: From 24886c53940d9ca31240345a63e122ade9c28a97 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Fri, 8 May 2026 15:06:42 +0100 Subject: [PATCH 17/18] fix(bridge): zero-out cross-currency dev fee to match backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sister change to peanut-api-ts hotfix/bridge-developer-fee-zero. With the backend no longer attaching developer_fee_percent on Bridge transfers, the UI's "amount you will receive" math must stop deducting the same 50bps locally — otherwise we under-quote. Setting BRIDGE_DEVELOPER_FEE_RATE to 0 turns applyBridgeCrossCurrencyFee and reverseBridgeCrossCurrencyFee into identity functions, which matches what Bridge actually delivers. The hardcoded "Fee: $ 0.00" PaymentInfoRows in withdraw/bank-claim flows now become truthful. Tests are reparameterized against the constant so they stay correct when the FX-spread followup re-enables a margin. --- src/constants/payment.consts.ts | 6 ++-- src/utils/__tests__/bridge.utils.test.ts | 44 +++++++++++++----------- 2 files changed, 28 insertions(+), 22 deletions(-) diff --git a/src/constants/payment.consts.ts b/src/constants/payment.consts.ts index 44c44856c..6970575d7 100644 --- a/src/constants/payment.consts.ts +++ b/src/constants/payment.consts.ts @@ -20,8 +20,10 @@ export const MIN_MANTECA_QR_PAYMENT_AMOUNT = 0.1 // Manteca provider minimum export const MAX_QR_PAYMENT_AMOUNT_FOREIGN = 2000 // max per transaction for foreign users // Bridge developer fee applied to cross-currency (non-USD) transfers. -// Must match backend BRIDGE_DEVELOPER_FEE_PERCENT in peanut-api-ts/src/bridge/consts.ts -export const BRIDGE_DEVELOPER_FEE_RATE = 0.005 +// Must match backend BRIDGE_DEVELOPER_FEE_PERCENT in peanut-api-ts/src/bridge/consts.ts. +// Currently 0 — fee was undisclosed in-app while charged via Bridge's email +// receipt. Will re-enable as an FX-rate spread once we ship the quote endpoint. +export const BRIDGE_DEVELOPER_FEE_RATE = 0 /** * validate if amount meets minimum requirement for a payment method diff --git a/src/utils/__tests__/bridge.utils.test.ts b/src/utils/__tests__/bridge.utils.test.ts index 7fd727cf5..148cf945f 100644 --- a/src/utils/__tests__/bridge.utils.test.ts +++ b/src/utils/__tests__/bridge.utils.test.ts @@ -1,3 +1,4 @@ +import { BRIDGE_DEVELOPER_FEE_RATE } from '@/constants/payment.consts' import { applyBridgeCrossCurrencyFee, getCurrencyConfig, @@ -7,6 +8,10 @@ import { reverseBridgeCrossCurrencyFee, } from '../bridge.utils' +// Tests track the constant so they remain correct whether the fee is 0 +// (current state — disabled until FX-spread followup) or non-zero. +const NET_OF_100 = 100 * (1 - BRIDGE_DEVELOPER_FEE_RATE) + describe('bridge.utils', () => { describe('getCurrencyConfig', () => { it('should return USD with correct payment rails for US', () => { @@ -208,24 +213,24 @@ describe('bridge.utils', () => { // is the USDC stablecoin (not the 'USD' fiat display code). Callers must // pass 'USDC' so the fee helper matches backend `getBridgeDeveloperFeeParams`. - it('applies 0.5% fee for EUR → USDC (onramp EUR deposit)', () => { - expect(applyBridgeCrossCurrencyFee(100, 'EUR', 'USDC')).toBeCloseTo(99.5, 10) + it('applies fee for EUR → USDC (onramp EUR deposit)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'EUR', 'USDC')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for USDC → EUR (offramp to EUR bank)', () => { - expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'EUR')).toBeCloseTo(99.5, 10) + it('applies fee for USDC → EUR (offramp to EUR bank)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'EUR')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for GBP → USDC', () => { - expect(applyBridgeCrossCurrencyFee(100, 'GBP', 'USDC')).toBeCloseTo(99.5, 10) + it('applies fee for GBP → USDC', () => { + expect(applyBridgeCrossCurrencyFee(100, 'GBP', 'USDC')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for MXN → USDC', () => { - expect(applyBridgeCrossCurrencyFee(100, 'MXN', 'USDC')).toBeCloseTo(99.5, 10) + it('applies fee for MXN → USDC', () => { + expect(applyBridgeCrossCurrencyFee(100, 'MXN', 'USDC')).toBeCloseTo(NET_OF_100, 10) }) - it('applies 0.5% fee for USDC → MXN (offramp to Mexican bank)', () => { - expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'MXN')).toBeCloseTo(99.5, 10) + it('applies fee for USDC → MXN (offramp to Mexican bank)', () => { + expect(applyBridgeCrossCurrencyFee(100, 'USDC', 'MXN')).toBeCloseTo(NET_OF_100, 10) }) it('does not apply fee for USD → USDC (fiat rail ↔ stablecoin is fee-free)', () => { @@ -241,21 +246,19 @@ describe('bridge.utils', () => { }) it('is case-insensitive', () => { - expect(applyBridgeCrossCurrencyFee(100, 'eur', 'usdc')).toBeCloseTo(99.5, 10) + expect(applyBridgeCrossCurrencyFee(100, 'eur', 'usdc')).toBeCloseTo(NET_OF_100, 10) expect(applyBridgeCrossCurrencyFee(100, 'Usd', 'Usdc')).toBe(100) }) it('matches the real onramp display-quote math (EUR 500 @ 1.167)', () => { - // 500 EUR × 1.167 rate = 583.50 gross USDC - // after 0.5% Bridge fee = 580.5825 USDC delivered const gross = 500 * 1.167 const net = applyBridgeCrossCurrencyFee(gross, 'EUR', 'USDC') - expect(net).toBeCloseTo(580.5825, 4) + expect(net).toBeCloseTo(gross * (1 - BRIDGE_DEVELOPER_FEE_RATE), 4) }) it('handles zero and negative amounts without surprises', () => { expect(applyBridgeCrossCurrencyFee(0, 'EUR', 'USDC')).toBe(0) - expect(applyBridgeCrossCurrencyFee(-100, 'EUR', 'USDC')).toBeCloseTo(-99.5, 10) + expect(applyBridgeCrossCurrencyFee(-100, 'EUR', 'USDC')).toBeCloseTo(-NET_OF_100, 10) }) }) @@ -264,10 +267,11 @@ describe('bridge.utils', () => { // Guards against the classic algebra bug of using `net * (1 + rate)` // instead of `net / (1 - rate)` — those differ by rate² (~0.0025%). - it('reverse(99.5) for EUR → USDC yields exactly 100 (not 99.9975)', () => { - // The canonical sanity check: the naive `net * (1 + rate)` = 99.9975 - // would under-shoot. Correct inverse `net / (1 - rate)` lands on 100. - expect(reverseBridgeCrossCurrencyFee(99.5, 'EUR', 'USDC')).toBeCloseTo(100, 10) + it('reverse(net) yields exactly the gross input (not net * (1 + rate))', () => { + // The canonical sanity check: the naive `net * (1 + rate)` would + // under-shoot by rate². Correct inverse `net / (1 - rate)` lands + // on the original gross. Holds for any rate including 0. + expect(reverseBridgeCrossCurrencyFee(NET_OF_100, 'EUR', 'USDC')).toBeCloseTo(100, 10) }) it.each([0.01, 1, 100, 999.99, 1_000_000])('apply(reverse(%f)) round-trips for EUR → USDC', (amount) => { @@ -293,7 +297,7 @@ describe('bridge.utils', () => { }) it('is case-insensitive', () => { - expect(reverseBridgeCrossCurrencyFee(99.5, 'eur', 'usdc')).toBeCloseTo(100, 10) + expect(reverseBridgeCrossCurrencyFee(NET_OF_100, 'eur', 'usdc')).toBeCloseTo(100, 10) expect(reverseBridgeCrossCurrencyFee(100, 'Usd', 'Usdc')).toBe(100) }) }) From e1afb44e34f4c477c2e2c0e93c2c0b1178e19bb3 Mon Sep 17 00:00:00 2001 From: kushagrasarathe <76868364+kushagrasarathe@users.noreply.github.com> Date: Sun, 10 May 2026 15:48:55 +0530 Subject: [PATCH 18/18] fix: resolve typecheck errors from merge - useQrKycGate: restore paymentProcessor param (regressed by cherry-pick), remove SimpleFi references (removed from dev), keep user?.rails dep - useBridgeTransferReadiness.test: use ProviderRejectionState type instead of `as const` to allow state comparisons in mock setup --- src/hooks/__tests__/useBridgeTransferReadiness.test.ts | 5 +++-- src/hooks/useQrKycGate.ts | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts index 2bc847975..f78ab8564 100644 --- a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts +++ b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts @@ -17,6 +17,7 @@ jest.mock('../useKycStatus', () => ({ import { useBridgeTosStatus } from '../useBridgeTosStatus' import useProviderRejectionStatus from '../useProviderRejectionStatus' +import type { ProviderRejectionState } from '../useProviderRejectionStatus' import useKycStatus from '../useKycStatus' const mockTosStatus = useBridgeTosStatus as jest.MockedFunction @@ -25,7 +26,7 @@ const mockKycStatus = useKycStatus as jest.MockedFunction const defaultRejection = { provider: 'BRIDGE' as const, - state: 'happy' as const, + state: 'happy' as ProviderRejectionState, userMessage: null, rejectedRails: [], kycVerification: null, @@ -35,7 +36,7 @@ const defaultRejection = { function setup({ needsBridgeTos = false, - bridgeState = 'happy' as const, + bridgeState = 'happy' as ProviderRejectionState, bridgeUserMessage = null as string | null, isSumsubApproved = false, isBridgeApproved = false, diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 3b6e46c0a..2b1cea8c6 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -23,10 +23,11 @@ export interface QrKycGateResult { /** * This hook determines the KYC gate state for the QR pay page. - * It checks the user's KYC status to determine the appropriate action. + * It checks the user's KYC status and the payment processor to determine the appropriate action. + * @param paymentProcessor - The payment processor type ('MANTECA' | null) * @returns {QrKycGateResult} An object with the KYC gate state and a boolean indicating if the user should be blocked from paying. */ -export function useQrKycGate(): QrKycGateResult { +export function useQrKycGate(paymentProcessor?: 'MANTECA' | null): QrKycGateResult { const { user, isFetchingUser, fetchUser } = useAuth() const [kycGateState, setKycGateState] = useState(QrKycState.LOADING) const hasRequestedUserFetchRef = useRef(false) @@ -74,7 +75,6 @@ export function useQrKycGate(): QrKycGateResult { const isFixable = railMeta.selfHealable === true && mantecaKyc?.rejectType !== 'PROVIDER_FINAL' && - mantecaKyc?.rejectType !== 'FINAL' && ((kycMeta.selfHealAttempt as number) || 0) < MAX_SELF_HEAL_ATTEMPTS setKycGateState( isFixable ? QrKycState.PROVIDER_REJECTION_FIXABLE : QrKycState.PROVIDER_REJECTION_BLOCKED @@ -124,7 +124,7 @@ export function useQrKycGate(): QrKycGateResult { } setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) - }, [user?.user, user?.rails, isFetchingUser, fetchUser]) + }, [user?.user, user?.rails, isFetchingUser, paymentProcessor, fetchUser]) useEffect(() => { determineKycGateState()