Skip to content
Merged
73 changes: 53 additions & 20 deletions src/app/(mobile-ui)/add-money/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
import { useCapabilities } from '@/hooks/useCapabilities'
import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate'
import { useModalsContext } from '@/context/ModalsContext'
import { useCreateOnramp } from '@/hooks/useCreateOnramp'
import { useCreateOnramp, GENERIC_ONRAMP_ERROR } from '@/hooks/useCreateOnramp'
import { useParams, useSearchParams } from 'next/navigation'
import { useCallback, useEffect, useMemo, useState } from 'react'
import countryCurrencyMappings, { isNonEuroSepaCountry, isUKCountry } from '@/constants/countryCurrencyMapping'
Expand All @@ -33,10 +33,13 @@
import { useTosGuard } from '@/hooks/useTosGuard'
import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal'
import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils'
import posthog from 'posthog-js'
import { ANALYTICS_EVENTS } from '@/constants/analytics.consts'
import { addMoneyCountryUrl } from '@/utils/native-routes'
Expand Down Expand Up @@ -68,12 +71,11 @@
// Local UI state (not URL-appropriate - transient)
const [showWarningModal, setShowWarningModal] = useState<boolean>(false)
const [showKycModal, setShowKycModal] = useState<boolean>(false)
const [isRiskAccepted, setIsRiskAccepted] = useState<boolean>(false)
const { setError, error, setOnrampData, onrampData } = useOnrampFlow()

const { balance } = useWallet()
const { user, fetchUser } = useAuth()
const { createOnramp, isLoading: isCreatingOnramp, error: onrampError } = useCreateOnramp()
const { createOnramp, isLoading: isCreatingOnramp } = useCreateOnramp()

// inline sumsub kyc flow for bridge bank onramp
// regionIntent is NOT passed here to avoid creating a backend record on mount.
Expand Down Expand Up @@ -131,6 +133,9 @@
const { gateFor } = useCapabilities()
const bankCountry = useMemo(() => railJurisdictionForBank(selectedCountry?.id), [selectedCountry?.id])
const gate = useMemo(() => gateFor('deposit', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
// bridge re-verification ("we're reviewing your details") modal for the
// waiting-on-provider gate — keeps the status poll alive + auto-dismisses.
const pendingModal = useWaitingOnProviderModal(gate)
// A ready bank rail can still carry a pending Bridge requirement (the gate's
// `advisory`). Enforce it as a mandatory, non-skippable pre-empt at the
// proceed step — the deposit cannot continue until it's completed.
Expand All @@ -141,9 +146,10 @@
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
// note: eea_uplift_started is fired at modal-open (handleAmountContinue),
// not here, so abandoners are captured too.
onCompleteNow: () => {
if (!advisory) return Promise.resolve()
trackUpliftStarted(advisory)
return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)
},
})
Expand All @@ -157,7 +163,7 @@

useEffect(() => {
fetchUser()
}, [])

Check warning on line 166 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'fetchUser'. Either include it or remove the dependency array

Check warning on line 166 in src/app/(mobile-ui)/add-money/[country]/bank/page.tsx

View workflow job for this annotation

GitHub Actions / eslint

React Hook useEffect has a missing dependency: 'fetchUser'. Either include it or remove the dependency array

const peanutWalletBalance = useMemo(() => {
return balance !== undefined ? formatAmount(formatUnits(balance, PEANUT_WALLET_TOKEN_DECIMALS)) : ''
Expand Down Expand Up @@ -254,14 +260,25 @@
if (!validateAmount(rawTokenAmount)) return

if (gate.kind !== 'ready') {
// capabilities still loading OR provider doing internal review —
// silently no-op instead of flashing a misleading needs_kyc modal.
// `waiting-on-provider` means the user has nothing to do; opening
// a KYC modal would imply otherwise.
if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return
// capabilities still loading — silently no-op instead of flashing
// a misleading needs_kyc modal.
if (gate.kind === 'loading') return
// `waiting-on-provider` means bridge is re-reviewing submitted info
// (e.g. right after an eea uplift) — the user has nothing to do but
// wait. Show the pending modal instead of a dead button, and re-arm
// the capability poller so we pick up bridge's latest status live and
// the modal auto-dismisses the moment the gate clears.
if (gate.kind === 'waiting-on-provider') {
pendingModal.open()
return
}
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
// urgent (post-cliff) eea uplift lands here as a fixable-rejection —
// fire the funnel event as this KYC modal opens.
const upliftTrigger = upliftTriggerFromGate(gate)
if (upliftTrigger) trackUpliftStarted(upliftTrigger)
setShowKycModal(true)
}
return
Expand All @@ -271,6 +288,10 @@
// (record the amount-entered event, open the confirmation modal) only
// runs once there's no pending requirement; while one exists the modal
// blocks and this never fires, so the event can't double-count.
// upcoming (future-dated) eea uplift opens the advisory modal here — fire
// the funnel event as it opens.
const advisoryTrigger = upliftTriggerFromAdvisory(advisory)
if (advisoryTrigger) trackUpliftStarted(advisoryTrigger)
advisoryIntercept(() => {
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_AMOUNT_ENTERED, {
amount_usd: usdEquivalent,
Expand All @@ -291,7 +312,6 @@
}

setShowWarningModal(false)
setIsRiskAccepted(false)
try {
const onrampDataResponse = await createOnramp({
amount: rawTokenAmount,
Expand All @@ -314,23 +334,24 @@
}
} catch (error) {
setShowWarningModal(false)
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
const isError = error instanceof Error
const errorMessage = isError ? error.message : GENERIC_ONRAMP_ERROR
posthog.capture(ANALYTICS_EVENTS.DEPOSIT_FAILED, {
method_type: 'bank',
error_message: errorMessage,
// keep the distinct label for truly-unexpected non-Error throws
error_message: isError ? errorMessage : 'Unknown error',
})
// show the caught message directly — createOnramp carries the specific
// reason on the thrown Error, so we don't read any hook state here.
setError({
showError: true,
errorMessage,
})
if (onrampError) {
setError({
showError: true,
errorMessage: onrampError,
})
}
}
}

const handleWarningCancel = () => {
setShowWarningModal(false)
setIsRiskAccepted(false)
}

// Redirect to inputAmount if showDetails is accessed without required data (deep link / back navigation)
Expand Down Expand Up @@ -454,7 +475,12 @@

<InitiateKycModal
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onClose={() => {
// dismiss = abandon: clear the uplift latch so a later
// unrelated KYC success can't mis-fire eea_uplift_completed.
setShowKycModal(false)
resetUpliftFunnel()
}}
onVerify={async () => {
if (gate.kind === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
Expand All @@ -471,6 +497,7 @@
}}
onContactSupport={() => {
setShowKycModal(false)
resetUpliftFunnel()
setIsSupportModalOpen(true)
}}
isLoading={sumsubFlow.isLoading}
Expand All @@ -482,6 +509,12 @@

<AdvisoryPreemptModal {...advisoryModalProps} />

<KycReverificationPendingModal
isOpen={pendingModal.isOpen}
onClose={pendingModal.close}
message={pendingModal.message}
/>

<SumsubKycModals flow={sumsubFlow} autoStartSdk />

<BridgeTosStep
Expand Down
57 changes: 51 additions & 6 deletions src/app/(mobile-ui)/add-money/__tests__/add-money-states.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,14 @@ function setParams(params: Record<string, string>) {
// then push it onto the useCapabilities mock. The page reads `gateFor(...)`,
// so the mock returns a stub gateFor closing over the desired state; it also
// exposes `bankRails()` for the few sites that read it directly.
type Gate = 'ready' | 'accept-tos' | 'fixable-rejection' | 'blocked-rejection' | 'needs-identity' | 'needs-enrollment'
type Gate =
| 'ready'
| 'accept-tos'
| 'fixable-rejection'
| 'blocked-rejection'
| 'needs-identity'
| 'needs-enrollment'
| 'waiting-on-provider'

function setGate(kind: Gate) {
let rails: any[] = []
Expand Down Expand Up @@ -833,6 +840,22 @@ function setGate(kind: Gate) {
rails = []
gateState = { kind: 'needs-identity' }
break
case 'waiting-on-provider':
// provider reviewing submitted info (e.g. eea-uplift docs) — user
// has nothing to do but wait
rails = [
{
id: 'bridge.ach_us',
provider: 'bridge',
method: 'ACH_US',
country: 'US',
currency: 'USD',
status: 'requires-info',
blockingActions: ['wait:bridge'],
},
]
gateState = { kind: 'waiting-on-provider', reason: { code: 'bridge_processing' } }
break
}

mockUseCapabilities.mockReturnValue({
Expand Down Expand Up @@ -898,7 +921,6 @@ function applyDefaults() {
mockUseCreateOnramp.mockReturnValue({
createOnramp: jest.fn(),
isLoading: false,
error: null,
})

mockUseLimitsValidation.mockReturnValue({
Expand Down Expand Up @@ -1286,7 +1308,6 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
error: null,
})
resetQueryState({ step: 'inputAmount', amount: '100' })

Expand All @@ -1308,10 +1329,11 @@ describe('GROUP 5: Bridge Bank Onramp', () => {

test('onramp error displays ErrorAlert', async () => {
const mockCreateOnramp = jest.fn().mockRejectedValue(new Error('Service unavailable'))
// the page must surface the caught error's message directly — the hook
// exposes no error state to read (that channel was the stale-closure trap).
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
error: 'Service unavailable',
})
resetQueryState({ step: 'inputAmount', amount: '100' })

Expand All @@ -1327,8 +1349,31 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
fireEvent.click(screen.getByTestId('confirm-onramp'))
})

// After error, the setError should have been called
expect(mockOnrampFlow.setError).toHaveBeenCalled()
// the caught message must be shown to the user
expect(mockOnrampFlow.setError).toHaveBeenCalledWith({
showError: true,
errorMessage: 'Service unavailable',
})
})

test('waiting-on-provider gate shows the reverification pending modal instead of a dead button', async () => {
setGate('waiting-on-provider')
const mockCreateOnramp = jest.fn()
mockUseCreateOnramp.mockReturnValue({
createOnramp: mockCreateOnramp,
isLoading: false,
})
resetQueryState({ step: 'inputAmount', amount: '100' })

renderWithProviders(<OnrampBankPage />)

await act(async () => {
fireEvent.click(screen.getByText('Continue'))
})

// no doomed transfer attempt; the user sees the bridge-review pending modal
expect(mockCreateOnramp).not.toHaveBeenCalled()
expect(await screen.findByText(/reviewing your details/i)).toBeInTheDocument()
})

test('limits blocking disables Continue and shows LimitsWarningCard', () => {
Expand Down
48 changes: 42 additions & 6 deletions src/app/(mobile-ui)/withdraw/[country]/bank/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,13 @@ import { useTosGuard } from '@/hooks/useTosGuard'
import { BridgeTosStep } from '@/components/Kyc/BridgeTosStep'
import { useMultiPhaseKycFlow } from '@/hooks/useMultiPhaseKycFlow'
import { SumsubKycModals } from '@/components/Kyc/SumsubKycModals'
import { KycReverificationPendingModal } from '@/components/Kyc/KycReverificationPendingModal'
import { useWaitingOnProviderModal } from '@/hooks/useWaitingOnProviderModal'
import { InitiateKycModal } from '@/components/Kyc/InitiateKycModal'
import AdvisoryPreemptModal from '@/components/Kyc/AdvisoryPreemptModal'
import { useAdvisoryPreempt } from '@/hooks/useAdvisoryPreempt'
import { useEeaUpliftFunnel } from '@/hooks/useEeaUpliftFunnel'
import { upliftTriggerFromGate, upliftTriggerFromAdvisory } from '@/utils/eea-uplift.utils'
import { useCapabilities } from '@/hooks/useCapabilities'
import { getKycModalVariant, getGateUserMessage } from '@/utils/capability-gate'
import { useModalsContext } from '@/context/ModalsContext'
Expand Down Expand Up @@ -90,6 +93,9 @@ export default function WithdrawBankPage() {
const { gateFor } = useCapabilities()
const bankCountry = useMemo(() => railJurisdictionForBank(getCountryFromPath(country)?.id), [country])
const gate = useMemo(() => gateFor('withdraw', { channel: 'bank', country: bankCountry }), [gateFor, bankCountry])
// bridge re-verification ("we're reviewing your details") modal for the
// waiting-on-provider gate — keeps the status poll alive + auto-dismisses.
const pendingModal = useWaitingOnProviderModal(gate)
// EEA-uplift funnel events (PostHog): started on launch, completed on KYC
// success. trackCompleted no-ops unless an uplift was started this session.
const {
Expand Down Expand Up @@ -117,9 +123,10 @@ export default function WithdrawBankPage() {
// Route through the self-heal resubmit path (reheal-tagged action) so the
// completed submission round-trips to Bridge. start-action mints a plain
// token whose webhook completion has no Bridge relay → answers are dropped.
// note: eea_uplift_started is fired at modal-open (the handlers below),
// not here, so abandoners are captured too.
onCompleteNow: () => {
if (!advisory) return Promise.resolve()
trackUpliftStarted(advisory)
return sumsubFlow.handleSelfHealResubmit('BRIDGE', advisory.requirementKey)
},
})
Expand Down Expand Up @@ -211,12 +218,23 @@ export default function WithdrawBankPage() {

const proceedWithOfframp = async () => {
if (gate.kind !== 'ready') {
// Loading and waiting-on-provider both mean "user has no action to
// take" — silently no-op instead of bouncing them through Sumsub.
if (gate.kind === 'loading' || gate.kind === 'waiting-on-provider') return
// capabilities still loading — silently no-op.
if (gate.kind === 'loading') return
// `waiting-on-provider` means bridge is re-reviewing submitted info
// (e.g. right after an eea uplift) — show the pending modal instead of
// a dead button, and re-arm the capability poller so we pick up
// bridge's latest status live and the modal auto-dismisses on clear.
if (gate.kind === 'waiting-on-provider') {
pendingModal.open()
return
}
if (gate.kind === 'accept-tos') {
guardWithTos()
} else {
// urgent (post-cliff) eea uplift lands here as a fixable-rejection —
// fire the funnel event as this KYC modal opens.
const upliftTrigger = upliftTriggerFromGate(gate)
if (upliftTrigger) trackUpliftStarted(upliftTrigger)
setShowKycModal(true)
}
return
Expand Down Expand Up @@ -346,7 +364,13 @@ export default function WithdrawBankPage() {
// Enforce the mandatory verification pre-empt, then run the offramp. When the
// gate isn't `ready` (or there's no pending requirement) this is a no-op and
// proceedWithOfframp runs straight away (it handles the not-ready cases).
const handleCreateAndInitiateOfframp = () => advisoryIntercept(() => void proceedWithOfframp())
// upcoming (future-dated) eea uplift opens the advisory modal here — fire the
// funnel event as it opens.
const handleCreateAndInitiateOfframp = () => {
const advisoryTrigger = upliftTriggerFromAdvisory(advisory)
if (advisoryTrigger) trackUpliftStarted(advisoryTrigger)
advisoryIntercept(() => void proceedWithOfframp())
}

const countryCodeForFlag = () => {
if (!bankAccount?.details?.countryCode) return ''
Expand Down Expand Up @@ -543,7 +567,12 @@ export default function WithdrawBankPage() {

<InitiateKycModal
visible={showKycModal}
onClose={() => setShowKycModal(false)}
onClose={() => {
// dismiss = abandon: clear the uplift latch so a later
// unrelated KYC success can't mis-fire eea_uplift_completed.
setShowKycModal(false)
resetUpliftFunnel()
}}
onVerify={async () => {
if (gate.kind === 'restart-identity') {
await sumsubFlow.handleRestartIdentity()
Expand All @@ -560,6 +589,7 @@ export default function WithdrawBankPage() {
}}
onContactSupport={() => {
setShowKycModal(false)
resetUpliftFunnel()
setIsSupportModalOpen(true)
}}
isLoading={sumsubFlow.isLoading}
Expand All @@ -569,6 +599,12 @@ export default function WithdrawBankPage() {
regionName={getCountryFromPath(country)?.title}
/>
<AdvisoryPreemptModal {...advisoryModalProps} />

<KycReverificationPendingModal
isOpen={pendingModal.isOpen}
onClose={pendingModal.close}
message={pendingModal.message}
/>
<SumsubKycModals flow={sumsubFlow} />
</div>
)
Expand Down
Loading
Loading