Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -1113,12 +1113,12 @@ describe('GROUP 5: Bridge Bank Onramp', () => {
expect(screen.getByText('Country not found')).toBeInTheDocument()
})

test('user not KYC approved shows InitiateKycModal on Continue', async () => {
test('fresh user needs KYC before Bridge deposit confirmation', async () => {
mockUseKycStatus.mockReturnValue({
isUserKycApproved: false,
isUserMantecaKycApproved: false,
})
mockGate.mockReturnValue({ type: 'needs_enrollment' })
mockGate.mockReturnValue({ type: 'needs_kyc' })
resetQueryState({ step: 'inputAmount', amount: '100' })

renderWithProviders(<OnrampBankPage />)
Expand Down
2 changes: 1 addition & 1 deletion src/components/Kyc/states/KycActionRequired.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export const KycActionRequired = ({
icon={'retry' as IconName}
className="w-full"
shadowSize="4"
onClick={onResume}
onClick={() => onResume()}
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Re-submit verification'}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Kyc/states/KycFailed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export const KycFailed = ({
variant="purple"
className="w-full"
shadowSize="4"
onClick={onRetry}
onClick={() => onRetry()}
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Retry verification'}
Expand Down
2 changes: 1 addition & 1 deletion src/components/Kyc/states/KycNotStarted.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const KycNotStarted = ({ onResume, isLoading }: { onResume: () => void; i
payments."
/>

<Button className="w-full" shadowSize="4" onClick={onResume} disabled={isLoading}>
<Button className="w-full" shadowSize="4" onClick={() => onResume()} disabled={isLoading}>
{isLoading ? 'Loading...' : 'Continue verification'}
</Button>
</div>
Expand Down
8 changes: 7 additions & 1 deletion src/components/Kyc/states/KycRequiresDocuments.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,13 @@ export const KycRequiresDocuments = ({
)}
</div>

<Button icon="docs" className="w-full" shadowSize="4" onClick={onSubmitDocuments} disabled={isLoading}>
<Button
icon="docs"
className="w-full"
shadowSize="4"
onClick={() => onSubmitDocuments()}
disabled={isLoading}
>
{isLoading ? 'Loading...' : 'Submit documents'}
</Button>
</div>
Expand Down
94 changes: 94 additions & 0 deletions src/components/Kyc/states/__tests__/KycNotStarted.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { type ReactNode } from 'react'
import { KycActionRequired } from '../KycActionRequired'
import { KycFailed } from '../KycFailed'
import { KycNotStarted } from '../KycNotStarted'
import { KycRequiresDocuments } from '../KycRequiresDocuments'

jest.mock('use-haptic', () => ({
useHaptic: () => ({ triggerHaptic: jest.fn() }),
}))

jest.mock('@/hooks/useLongPress', () => ({
useLongPress: () => ({
isLongPressed: false,
pressProgress: 0,
handlers: {},
}),
}))

jest.mock('../../KYCStatusDrawerItem', () => ({
KYCStatusDrawerItem: () => <div data-testid="kyc-status-drawer-item" />,
}))

jest.mock('../../RejectLabelsList', () => ({
RejectLabelsList: () => <div data-testid="reject-labels-list" />,
}))

jest.mock('../../KycFailedContent', () => ({
KycFailedContent: () => <div data-testid="kyc-failed-content" />,
}))

jest.mock('../../CountryRegionRow', () => ({
CountryRegionRow: () => <div data-testid="country-region-row" />,
}))

jest.mock('@/components/Payment/PaymentInfoRow', () => ({
PaymentInfoRow: () => <div data-testid="payment-info-row" />,
}))

jest.mock('@/components/Global/Card', () => ({
__esModule: true,
default: ({ children }: { children: ReactNode }) => <div>{children}</div>,
}))

jest.mock('@/components/Global/InfoCard', () => ({
__esModule: true,
default: ({ description }: { description: string }) => <div>{description}</div>,
}))

jest.mock('@/context/ModalsContext', () => ({
useModalsContext: () => ({ setIsSupportModalOpen: jest.fn() }),
}))

describe('KycNotStarted', () => {
it('does not pass the click event to onResume', () => {
const onResume = jest.fn()
render(<KycNotStarted onResume={onResume} />)

fireEvent.click(screen.getByText('Continue verification'))

expect(onResume).toHaveBeenCalledTimes(1)
expect(onResume).toHaveBeenCalledWith()
})

it('does not pass the click event to action-required resume', () => {
const onResume = jest.fn()
render(<KycActionRequired onResume={onResume} />)

fireEvent.click(screen.getByText('Re-submit verification'))

expect(onResume).toHaveBeenCalledTimes(1)
expect(onResume).toHaveBeenCalledWith()
})

it('does not pass the click event to failed retry', () => {
const onRetry = jest.fn()
render(<KycFailed onRetry={onRetry} isSumsub={false} />)

fireEvent.click(screen.getByText('Retry verification'))

expect(onRetry).toHaveBeenCalledTimes(1)
expect(onRetry).toHaveBeenCalledWith()
})

it('does not pass the click event to document submission', () => {
const onSubmitDocuments = jest.fn()
render(<KycRequiresDocuments requirements={[]} onSubmitDocuments={onSubmitDocuments} />)

fireEvent.click(screen.getByText('Submit documents'))

expect(onSubmitDocuments).toHaveBeenCalledTimes(1)
expect(onSubmitDocuments).toHaveBeenCalledWith()
})
})
16 changes: 15 additions & 1 deletion src/hooks/__tests__/useBridgeTransferReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,21 @@ describe('useBridgeTransferReadiness', () => {
expect(result.current.gate.type).toBe('ready')
})

it('does not flag needs_enrollment when sumsub is not approved', () => {
it('needs_kyc when user has not started standard verification', () => {
setup({ isSumsubApproved: false, bridgeRailStatus: null })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('needs_kyc')
})

it('needs_kyc when standard verification is not approved and bridge rail is pending', () => {
setup({ isSumsubApproved: false, bridgeRailStatus: 'PENDING' })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('needs_kyc')
})

it('ready when standard verification is not approved but bridge rail is enabled', () => {
setup({ isSumsubApproved: false, bridgeRailStatus: 'ENABLED' })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('ready')
})

Expand All @@ -151,6 +163,7 @@ describe('getKycModalVariant', () => {
expect(getKycModalVariant('blocked_rejection')).toBe('blocked')
expect(getKycModalVariant('fixable_rejection')).toBe('provider_rejection')
expect(getKycModalVariant('needs_enrollment')).toBe('cross_region')
expect(getKycModalVariant('needs_kyc')).toBe('default')
expect(getKycModalVariant('accept_tos')).toBe('default')
expect(getKycModalVariant('ready')).toBe('default')
})
Expand All @@ -168,6 +181,7 @@ describe('getGateProviderMessage', () => {

it('returns undefined for non-rejection gates', () => {
expect(getGateProviderMessage({ type: 'accept_tos' })).toBeUndefined()
expect(getGateProviderMessage({ type: 'needs_kyc' })).toBeUndefined()
expect(getGateProviderMessage({ type: 'needs_enrollment' })).toBeUndefined()
expect(getGateProviderMessage({ type: 'ready' })).toBeUndefined()
})
Expand Down
18 changes: 13 additions & 5 deletions src/hooks/useBridgeTransferReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import { useMemo } from 'react'
import { useBridgeTosStatus } from './useBridgeTosStatus'
import useProviderRejectionStatus from './useProviderRejectionStatus'
import useKycStatus from './useKycStatus'
import { hasFunctionalRail } from '@/utils/railGate.utils'
import { hasEnabledRail, hasFunctionalRail } from '@/utils/railGate.utils'

export type BridgeGateAction =
| { type: 'accept_tos' }
| { type: 'fixable_rejection'; userMessage: string | null }
| { type: 'blocked_rejection'; userMessage: string | null }
| { type: 'needs_kyc' }
| { type: 'needs_enrollment' }
| { type: 'ready' }

Expand All @@ -20,8 +21,9 @@ export type BridgeGateAction =
* 1. hard rejection (contact support — tos is moot)
* 2. tos acceptance
* 3. fixable rejection (user can submit additional details)
* 4. needs enrollment (sumsub approved, no functional bridge rail yet)
* 5. ready
* 4. needs standard kyc (fresh user)
* 5. needs enrollment (sumsub approved, no functional bridge rail yet)
* 6. ready
*
* Phase 6 of rail-gating: the bridge-state checks are derived from the
* user's Bridge rails (via useBridgeTosStatus / useProviderRejectionStatus).
Expand All @@ -47,13 +49,19 @@ export function useBridgeTransferReadiness() {
return { type: 'fixable_rejection', userMessage: bridgeRejection.userMessage }
}

// 4. needs enrollment — sumsub approved but no Bridge rail in a
// 4. fresh user needs standard kyc before creating a transfer.
// an enabled bridge rail still passes for legacy/out-of-band approvals.
if (!isUserSumsubKycApproved && !hasEnabledRail(bridgeRails, 'BRIDGE')) {
return { type: 'needs_kyc' }
}

// 5. needs enrollment — sumsub approved but no Bridge rail in a
// functional or in-progress state (the user has not started Bridge)
if (isUserSumsubKycApproved && !hasFunctionalRail(bridgeRails, 'BRIDGE')) {
return { type: 'needs_enrollment' }
}

// 5. ready
// 6. ready
return { type: 'ready' }
}, [needsBridgeTos, bridgeRejection, isUserSumsubKycApproved, bridgeRails])

Expand Down
Loading