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
61 changes: 25 additions & 36 deletions src/hooks/__tests__/kyc-withdrawal-gate.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,17 +26,27 @@ import { useAuth } from '@/context/authContext'

const mockUseAuth = useAuth as jest.MockedFunction<typeof useAuth>

const enabledMantecaArRail = {
id: 'rail-1',
// ENABLED Manteca AR rail with no mantecaUserId — a "QR-tier" rail. Enough to
// pay QR via the corporate pool, but NOT to deposit / withdraw.
const qrTierMantecaArRail = {
id: 'rail-qr',
railId: 'rail-def-1',
status: 'ENABLED',
metadata: null,
rail: {
id: 'rail-def-1',
provider: { code: 'MANTECA', name: 'Manteca' },
method: { code: 'BANK_TRANSFER_AR', name: 'Bank Transfer AR', country: 'AR', currency: 'ARS' },
},
}

// Same rail, ENABLED and carrying a mantecaUserId — "full-tier", real Manteca KYC.
const fullTierMantecaArRail = {
...qrTierMantecaArRail,
id: 'rail-full',
metadata: { mantecaUserId: '2414354' },
}

function setUser(authUser: Record<string, unknown>) {
mockUseAuth.mockReturnValue({
user: authUser,
Expand All @@ -46,7 +56,7 @@ function setUser(authUser: Record<string, unknown>) {
describe('kyc withdrawal gating', () => {
afterEach(() => jest.resetAllMocks())

it('treats migrated SUMSUB ACTIVE Manteca rows as approved', () => {
it('treats migrated SUMSUB ACTIVE Manteca rows as approved (useUnifiedKycStatus)', () => {
setUser({
user: {
bridgeKycStatus: null,
Expand All @@ -69,55 +79,34 @@ describe('kyc withdrawal gating', () => {
expect(result.current.isKycApproved).toBe(true)
})

it('allows Argentina Manteca withdrawal when migrated KYC is country-scoped', () => {
// Phase 6 (rail-gating): isVerifiedForCountry — the deposit / withdraw gate —
// is now derived from rails, and requires a *full-tier* Manteca rail.
it('isVerifiedForCountry(AR) is true with a full-tier Manteca rail (ENABLED + mantecaUserId)', () => {
setUser({
user: {
bridgeKycStatus: null,
kycVerifications: [
{
provider: 'SUMSUB',
mantecaGeo: 'AR',
status: 'ACTIVE',
updatedAt: '2026-03-17T14:47:34.702Z',
},
],
},
rails: [],
user: { bridgeKycStatus: null, kycVerifications: [] },
rails: [fullTierMantecaArRail],
})

const { result } = renderHook(() => useIdentityVerification())

expect(result.current.isVerifiedForCountry('AR')).toBe(true)
})

it('allows Argentina Manteca withdrawal for a normal active Manteca row', () => {
it('isVerifiedForCountry(AR) is false from a QR-tier rail alone (ENABLED, no mantecaUserId)', () => {
setUser({
user: {
bridgeKycStatus: null,
kycVerifications: [
{
provider: 'MANTECA',
mantecaGeo: 'AR',
status: 'ACTIVE',
updatedAt: '2025-10-30T01:12:06.099Z',
},
],
},
rails: [],
user: { bridgeKycStatus: null, kycVerifications: [] },
rails: [qrTierMantecaArRail],
})

const { result } = renderHook(() => useIdentityVerification())

expect(result.current.isVerifiedForCountry('AR')).toBe(true)
expect(result.current.isVerifiedForCountry('AR')).toBe(false)
})

it('does not allow Argentina Manteca withdrawal from an enabled rail alone', () => {
it('isVerifiedForCountry(AR) is false with no Manteca rail', () => {
setUser({
user: {
bridgeKycStatus: null,
kycVerifications: [],
},
rails: [enabledMantecaArRail],
user: { bridgeKycStatus: null, kycVerifications: [] },
rails: [],
})

const { result } = renderHook(() => useIdentityVerification())
Expand Down
64 changes: 36 additions & 28 deletions src/hooks/__tests__/useBridgeTransferReadiness.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { renderHook } from '@testing-library/react'
import { useBridgeTransferReadiness, getKycModalVariant, getGateProviderMessage } from '../useBridgeTransferReadiness'
import type { BridgeGateAction } from '../useBridgeTransferReadiness'
import { type IUserRail, type UserRailStatus } from '@/interfaces'

// mock the three dependency hooks
jest.mock('../useBridgeTosStatus', () => ({
Expand Down Expand Up @@ -34,19 +34,33 @@ const defaultRejection = {
maxAttempts: 3,
}

function bridgeRail(status: UserRailStatus): IUserRail {
return {
id: `ur-bridge-${status}`,
railId: 'r-bridge',
status,
metadata: null,
rail: {
id: 'r-bridge',
provider: { code: 'BRIDGE', name: 'Bridge' },
method: { code: 'ACH_US', name: 'ACH', country: 'US', currency: 'USD' },
},
}
}

function setup({
needsBridgeTos = false,
bridgeState = 'happy' as ProviderRejectionState,
bridgeUserMessage = null as string | null,
isSumsubApproved = false,
isBridgeApproved = false,
isBridgeUnderReview = false,
isBridgeIncomplete = false,
// null → no Bridge rails (not enrolled); otherwise the user's single Bridge rail status
bridgeRailStatus = null as UserRailStatus | null,
} = {}) {
const bridgeRails = bridgeRailStatus ? [bridgeRail(bridgeRailStatus)] : []
mockTosStatus.mockReturnValue({
needsBridgeTos,
isBridgeFullyEnabled: false,
bridgeRails: [],
isBridgeFullyEnabled: bridgeRailStatus === 'ENABLED',
bridgeRails,
})
mockRejectionStatus.mockReturnValue({
bridge: { ...defaultRejection, state: bridgeState, userMessage: bridgeUserMessage },
Expand All @@ -58,19 +72,19 @@ function setup({
})
mockKycStatus.mockReturnValue({
isUserSumsubKycApproved: isSumsubApproved,
isUserBridgeKycApproved: isBridgeApproved,
isUserBridgeKycUnderReview: isBridgeUnderReview,
isUserBridgeKycIncomplete: isBridgeIncomplete,
isUserBridgeKycApproved: false,
isUserBridgeKycUnderReview: false,
isUserBridgeKycIncomplete: false,
isUserMantecaKycApproved: false,
isUserKycApproved: isBridgeApproved,
isUserKycApproved: false,
})
}

describe('useBridgeTransferReadiness', () => {
afterEach(() => jest.resetAllMocks())

it('returns ready when no issues', () => {
setup({ isSumsubApproved: true, isBridgeApproved: true })
it('returns ready when bridge rail is ENABLED', () => {
setup({ isSumsubApproved: true, bridgeRailStatus: 'ENABLED' })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('ready')
})
Expand All @@ -79,7 +93,7 @@ describe('useBridgeTransferReadiness', () => {
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')
expect((result.current.gate as { userMessage?: string }).userMessage).toBe('permanently rejected')
})

it('accept_tos fires when tos needed and no hard rejection', () => {
Expand All @@ -92,29 +106,29 @@ describe('useBridgeTransferReadiness', () => {
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')
expect((result.current.gate as { userMessage?: string }).userMessage).toBe('upload clearer photo')
})

it('needs_enrollment when sumsub approved but bridge not started', () => {
setup({ isSumsubApproved: true })
it('needs_enrollment when sumsub approved but no bridge rail exists', () => {
setup({ isSumsubApproved: true, bridgeRailStatus: null })
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 })
it('ready when sumsub approved and bridge rail is PENDING (enrollment already started)', () => {
setup({ isSumsubApproved: true, bridgeRailStatus: 'PENDING' })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('ready')
})

it('ready when sumsub approved and bridge incomplete (enrollment not needed)', () => {
setup({ isSumsubApproved: true, isBridgeIncomplete: true })
it('ready when sumsub approved and bridge rail is REQUIRES_INFORMATION', () => {
setup({ isSumsubApproved: true, bridgeRailStatus: 'REQUIRES_INFORMATION' })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('ready')
})

it('ready when sumsub approved and bridge approved', () => {
setup({ isSumsubApproved: true, isBridgeApproved: true })
it('does not flag needs_enrollment when sumsub is not approved', () => {
setup({ isSumsubApproved: false, bridgeRailStatus: null })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('ready')
})
Expand All @@ -130,12 +144,6 @@ describe('useBridgeTransferReadiness', () => {
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('accept_tos')
})

it('accept_tos when bridge incomplete and tos needed (main bug scenario)', () => {
setup({ needsBridgeTos: true, isBridgeIncomplete: true, isSumsubApproved: true })
const { result } = renderHook(() => useBridgeTransferReadiness())
expect(result.current.gate.type).toBe('accept_tos')
})
})

describe('getKycModalVariant', () => {
Expand Down
32 changes: 13 additions & 19 deletions src/hooks/useBridgeTransferReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useMemo } from 'react'
import { useBridgeTosStatus } from './useBridgeTosStatus'
import useProviderRejectionStatus from './useProviderRejectionStatus'
import useKycStatus from './useKycStatus'
import { hasFunctionalRail } from '@/utils/railGate.utils'

export type BridgeGateAction =
| { type: 'accept_tos' }
Expand All @@ -19,14 +20,18 @@ 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, bridge not started)
* 4. needs enrollment (sumsub approved, no functional bridge rail yet)
* 5. ready
*
* Phase 6 of rail-gating: the bridge-state checks are derived from the
* user's Bridge rails (via useBridgeTosStatus / useProviderRejectionStatus).
* Sumsub approval has no rail representation, so it is still read from
* useKycStatus — it is a precondition, not the gate.
*/
export function useBridgeTransferReadiness() {
const { needsBridgeTos } = useBridgeTosStatus()
const { needsBridgeTos, bridgeRails } = useBridgeTosStatus()
const { bridge: bridgeRejection } = useProviderRejectionStatus()
const { isUserSumsubKycApproved, isUserBridgeKycApproved, isUserBridgeKycUnderReview, isUserBridgeKycIncomplete } =
useKycStatus()
const { isUserSumsubKycApproved } = useKycStatus()

const gate: BridgeGateAction = useMemo(() => {
// 1. hard rejection — contact support (checked first because tos is moot for hard-rejected users)
Expand All @@ -42,26 +47,15 @@ export function useBridgeTransferReadiness() {
return { type: 'fixable_rejection', userMessage: bridgeRejection.userMessage }
}

// 4. needs enrollment (sumsub approved but bridge not started/approved/in-progress)
if (
isUserSumsubKycApproved &&
!isUserBridgeKycApproved &&
!isUserBridgeKycUnderReview &&
!isUserBridgeKycIncomplete
) {
// 4. 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
return { type: 'ready' }
}, [
needsBridgeTos,
bridgeRejection,
isUserSumsubKycApproved,
isUserBridgeKycApproved,
isUserBridgeKycUnderReview,
isUserBridgeKycIncomplete,
])
}, [needsBridgeTos, bridgeRejection, isUserSumsubKycApproved, bridgeRails])

return { gate }
}
Expand Down
22 changes: 9 additions & 13 deletions src/hooks/useIdentityVerification.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { isMantecaSupportedCountryCode } from '@/constants/manteca.consts'
import { getFlagUrl } from '@/constants/countryCurrencyMapping'
import { type KYCRegionIntent } from '@/app/actions/types/sumsub.types'
import React from 'react'
import { isKycStatusApproved } from '@/constants/kyc.consts'
import { hasEnabledRail, hasFullMantecaRail } from '@/utils/railGate.utils'

/** Represents a geographic region with its display information */
export type Region = {
Expand Down Expand Up @@ -138,19 +138,15 @@ export const useIdentityVerification = () => {
const isVerifiedForCountry = useCallback(
(code: string) => {
const upper = code.toUpperCase()

const mantecaActive =
user?.user.kycVerifications?.some(
(v) =>
(v.provider === 'MANTECA' || v.provider === 'SUMSUB') &&
(v.mantecaGeo || '').toUpperCase() === upper &&
isKycStatusApproved(v.status)
) ?? false

// Manteca countries need country-specific verification, others just need Bridge KYC
return isMantecaSupportedCountry(upper) ? mantecaActive : isUserBridgeKycApproved
// Phase 6 (rail-gating): verified for a Manteca country = a full-tier
// ENABLED Manteca rail for it (carries a mantecaUserId — QR-tier rails
// do not count; deposit / withdraw need real KYC). Other countries: an
// ENABLED Bridge rail.
return isMantecaSupportedCountry(upper)
? hasFullMantecaRail(user?.rails, upper)
: hasEnabledRail(user?.rails, 'BRIDGE')
},
[user, isUserBridgeKycApproved, isMantecaSupportedCountry]
[user?.rails, isMantecaSupportedCountry]
)

/**
Expand Down
Loading
Loading