diff --git a/src/hooks/__tests__/kyc-withdrawal-gate.test.tsx b/src/hooks/__tests__/kyc-withdrawal-gate.test.tsx index 5e20839fa..f3205404f 100644 --- a/src/hooks/__tests__/kyc-withdrawal-gate.test.tsx +++ b/src/hooks/__tests__/kyc-withdrawal-gate.test.tsx @@ -26,10 +26,13 @@ import { useAuth } from '@/context/authContext' const mockUseAuth = useAuth as jest.MockedFunction -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' }, @@ -37,6 +40,13 @@ const enabledMantecaArRail = { }, } +// 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) { mockUseAuth.mockReturnValue({ user: authUser, @@ -46,7 +56,7 @@ function setUser(authUser: Record) { 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, @@ -69,20 +79,12 @@ 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()) @@ -90,34 +92,21 @@ describe('kyc withdrawal gating', () => { 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()) diff --git a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts index fad5663e6..dde34fdfb 100644 --- a/src/hooks/__tests__/useBridgeTransferReadiness.test.ts +++ b/src/hooks/__tests__/useBridgeTransferReadiness.test.ts @@ -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', () => ({ @@ -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 }, @@ -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') }) @@ -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', () => { @@ -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') }) @@ -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', () => { diff --git a/src/hooks/useBridgeTransferReadiness.ts b/src/hooks/useBridgeTransferReadiness.ts index 8bed58d17..9fb43822e 100644 --- a/src/hooks/useBridgeTransferReadiness.ts +++ b/src/hooks/useBridgeTransferReadiness.ts @@ -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' } @@ -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) @@ -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 } } diff --git a/src/hooks/useIdentityVerification.tsx b/src/hooks/useIdentityVerification.tsx index 7377eafaf..741e76446 100644 --- a/src/hooks/useIdentityVerification.tsx +++ b/src/hooks/useIdentityVerification.tsx @@ -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 = { @@ -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] ) /** diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 3a7e25058..e1a9b3478 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -2,8 +2,8 @@ import { useCallback, useState, useEffect, useRef } from 'react' import { useAuth } from '@/context/authContext' -import { MantecaKycStatus } from '@/interfaces' -import { isKycStatusApproved, isSumsubStatusInProgress, MAX_SELF_HEAL_ATTEMPTS } from '@/constants/kyc.consts' +import useProviderRejectionStatus from './useProviderRejectionStatus' +import { hasEnabledRail, hasRailInProgress } from '@/utils/railGate.utils' import { hasMantecaUsNationalityRestrictionMetadata } from '@/utils/manteca-restriction.utils' export enum QrKycState { @@ -22,13 +22,23 @@ export interface QrKycGateResult { } /** - * This hook determines the KYC gate state for the QR pay page. - * 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. + * KYC gate for the QR-pay page, derived from the user's Manteca rails + * (Phase 6 of rail-gating): + * - rejected rail → fixable / blocked (via useProviderRejectionStatus) + * - ENABLED rail → proceed + * - in-progress rail → verification in progress + * - no Manteca rail → identity verification required + * + * Geo-agnostic by design (unchanged signature): any ENABLED Manteca rail + * passes, since QR-tier rails enable PIX_BR and MERCADOPAGO_QR_AR together + * on Sumsub approval. The backend QR gate is the geo-scoped authority. + * + * @param _paymentProcessor retained for call-site compatibility; the gate is + * Manteca-rail based and does not branch on it. */ -export function useQrKycGate(paymentProcessor?: 'MANTECA' | null): QrKycGateResult { +export function useQrKycGate(_paymentProcessor?: 'MANTECA' | null): QrKycGateResult { const { user, isFetchingUser, fetchUser } = useAuth() + const { manteca: mantecaRejection } = useProviderRejectionStatus() const [kycGateState, setKycGateState] = useState(QrKycState.LOADING) const [userMessage, setUserMessage] = useState(null) const hasRequestedUserFetchRef = useRef(false) @@ -39,108 +49,84 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | null): QrKycGateResu }, []) const determineKycGateState = useCallback(async () => { - const currentUser = user?.user // while auth is fetching, keep loading to avoid flashing the verify modal if (isFetchingUser) { setGateState(QrKycState.LOADING) return } - if (!currentUser) { - // on public routes (like qr pay), auth may not auto-fetch; trigger it explicitly once and wait + if (!user) { + // on public routes (like qr pay) auth may not auto-fetch; trigger + // it once. If the fetch produced a user, the re-render will pick + // up the new branch; if it produced nothing (returned null or + // threw), fall through to REQUIRES_IDENTITY_VERIFICATION rather + // than leaving the gate stuck in LOADING (CR comment on this PR). if (!hasRequestedUserFetchRef.current) { hasRequestedUserFetchRef.current = true setGateState(QrKycState.LOADING) try { - await fetchUser() + const fetched = await fetchUser() + if (fetched) return } catch { - // ignore errors and fall through after one attempt + // ignore errors and fall through } - return } - // if we already tried fetching and still have no user, require verification setGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) return } - // sumsub approved users can proceed to qr pay, unless manteca rejected them - const hasSumsubApproved = currentUser.kycVerifications?.some( - (v) => v.provider === 'SUMSUB' && isKycStatusApproved(v.status) - ) - if (hasSumsubApproved) { - // check if manteca has rejected rails (qr payments use manteca) - const rejectedMantecaRails = (user?.rails ?? []).filter( - (r) => r.rail.provider.code === 'MANTECA' && r.status === 'REJECTED' - ) - if (rejectedMantecaRails.length > 0) { - const railMeta = (rejectedMantecaRails[0].metadata ?? {}) as Record - const rejectedRailMetadata = rejectedMantecaRails.map((rail) => rail.metadata) - const mantecaKyc = currentUser.kycVerifications - ?.filter((v) => v.provider === 'MANTECA') - .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime())[0] - const kycMeta = (mantecaKyc?.metadata ?? {}) as Record - const isMantecaUsNationalityRestricted = hasMantecaUsNationalityRestrictionMetadata([ - kycMeta, - ...rejectedRailMetadata, - ]) - if (isMantecaUsNationalityRestricted) { - setGateState(QrKycState.PROCEED_TO_PAY) - return - } - const isFixable = - railMeta.selfHealable === true && - mantecaKyc?.rejectType !== 'PROVIDER_FINAL' && - ((kycMeta.selfHealAttempt as number) || 0) < MAX_SELF_HEAL_ATTEMPTS - setGateState( - isFixable ? QrKycState.PROVIDER_REJECTION_FIXABLE : QrKycState.PROVIDER_REJECTION_BLOCKED, - null - ) - return - } - setGateState(QrKycState.PROCEED_TO_PAY) - return - } - - const mantecaKycs = currentUser.kycVerifications?.filter((v) => v.provider === 'MANTECA') ?? [] - - const hasAnyMantecaKyc = mantecaKycs.length > 0 - const hasAnyActiveMantecaKyc = - hasAnyMantecaKyc && - mantecaKycs.some((v) => v.provider === 'MANTECA' && v.status === MantecaKycStatus.ACTIVE) + const rails = user.rails - if (hasAnyActiveMantecaKyc) { + // An ENABLED Manteca rail wins before the rejection check — this + // covers dev #2092's "Sumsub-approved pool fallback" intent: a + // user with a US-restricted rejected full-tier Manteca rail but an + // ENABLED Sumsub-pool rail can still pay QR through the pool. The + // rejection branches only fire when there's nothing functional. + if (hasEnabledRail(rails, 'MANTECA')) { setGateState(QrKycState.PROCEED_TO_PAY) return } - if (currentUser.bridgeKycStatus === 'approved') { - setGateState(QrKycState.PROCEED_TO_PAY) - return - } - - // check if bridge kyc is in progress (user started but hasn't completed) - // bridge kyc status is 'incomplete' or 'under_review' when user has initiated the kyc process - if (currentUser.bridgeKycStatus === 'under_review' || currentUser.bridgeKycStatus === 'incomplete') { - setGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) + // No enabled rail — defer to the provider rejection state. The + // userMessage carries US-nationality copy etc. when applicable. + if (mantecaRejection.state === 'blocked') { + // Exception (dev #2092): a Sumsub-approved user whose Manteca + // rejection is the US-nationality restriction can still pay QR + // through the Sumsub-pool fallback. BE enableQrPoolRails creates + // the enabling rail on Sumsub approval — but the FE may not + // have observed it yet (or the user is mid-migration). Surface + // PROCEED and let the BE adjudicate. + const hasSumsubApproved = user.user?.kycVerifications?.some( + (v) => v.provider === 'SUMSUB' && v.status === 'APPROVED' + ) + const rejectedMantecaMetadata = (user.rails ?? []) + .filter((r) => r.rail.provider.code === 'MANTECA' && r.status === 'REJECTED') + .map((r) => r.metadata) + const mantecaKycMetadata = + user.user?.kycVerifications?.filter((v) => v.provider === 'MANTECA').map((v) => v.metadata) ?? [] + const isUsRestricted = hasMantecaUsNationalityRestrictionMetadata([ + ...rejectedMantecaMetadata, + ...mantecaKycMetadata, + ]) + if (hasSumsubApproved && isUsRestricted) { + setGateState(QrKycState.PROCEED_TO_PAY) + return + } + setGateState(QrKycState.PROVIDER_REJECTION_BLOCKED, mantecaRejection.userMessage) return } - - if (hasAnyMantecaKyc) { - setGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) + if (mantecaRejection.state === 'fixable') { + setGateState(QrKycState.PROVIDER_REJECTION_FIXABLE, mantecaRejection.userMessage) return } - // sumsub verification in progress - const hasSumsubInProgress = currentUser.kycVerifications?.some( - (v) => v.provider === 'SUMSUB' && isSumsubStatusInProgress(v.status) - ) - if (hasSumsubInProgress) { + if (hasRailInProgress(rails, 'MANTECA')) { setGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) return } setGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) - }, [user?.user, user?.rails, isFetchingUser, paymentProcessor, fetchUser, setGateState]) + }, [user, isFetchingUser, fetchUser, mantecaRejection.state, mantecaRejection.userMessage, setGateState]) useEffect(() => { determineKycGateState() diff --git a/src/utils/__tests__/railGate.utils.test.ts b/src/utils/__tests__/railGate.utils.test.ts new file mode 100644 index 000000000..f334a9b8f --- /dev/null +++ b/src/utils/__tests__/railGate.utils.test.ts @@ -0,0 +1,85 @@ +import { hasEnabledRail, hasFullMantecaRail, hasRailInProgress, hasFunctionalRail } from '../railGate.utils' +import { type IUserRail, type UserRailStatus } from '@/interfaces' + +function rail( + provider: string, + status: UserRailStatus, + opts: { country?: string; mantecaUserId?: string } = {} +): IUserRail { + return { + id: `ur-${provider}-${status}-${opts.country ?? ''}`, + railId: `r-${provider}-${opts.country ?? ''}`, + status, + metadata: opts.mantecaUserId ? { mantecaUserId: opts.mantecaUserId } : null, + rail: { + id: `r-${provider}-${opts.country ?? ''}`, + provider: { code: provider, name: provider }, + method: { code: `M_${provider}`, name: provider, country: opts.country ?? 'BR', currency: 'BRL' }, + }, + } +} + +describe('hasEnabledRail', () => { + test('true when an ENABLED rail for the provider exists', () => { + expect(hasEnabledRail([rail('MANTECA', 'ENABLED')], 'MANTECA')).toBe(true) + expect(hasEnabledRail([rail('BRIDGE', 'ENABLED')], 'BRIDGE')).toBe(true) + }) + + test('false for a different provider, non-ENABLED status, or no rails', () => { + expect(hasEnabledRail([rail('MANTECA', 'ENABLED')], 'BRIDGE')).toBe(false) + expect(hasEnabledRail([rail('MANTECA', 'PENDING')], 'MANTECA')).toBe(false) + expect(hasEnabledRail([], 'MANTECA')).toBe(false) + expect(hasEnabledRail(undefined, 'MANTECA')).toBe(false) + }) +}) + +describe('hasFullMantecaRail', () => { + test('true only for an ENABLED Manteca rail carrying a mantecaUserId', () => { + expect(hasFullMantecaRail([rail('MANTECA', 'ENABLED', { mantecaUserId: '2414356' })])).toBe(true) + }) + + test('false for a QR-tier rail — ENABLED but no mantecaUserId', () => { + expect(hasFullMantecaRail([rail('MANTECA', 'ENABLED')])).toBe(false) + }) + + test('false for a full rail that is not ENABLED', () => { + expect(hasFullMantecaRail([rail('MANTECA', 'PENDING', { mantecaUserId: '2414356' })])).toBe(false) + }) + + test('country narrows to the matching rail method country', () => { + const rails = [ + rail('MANTECA', 'ENABLED', { country: 'BR', mantecaUserId: 'm-br' }), + rail('MANTECA', 'ENABLED', { country: 'AR' }), // AR is QR-tier only + ] + expect(hasFullMantecaRail(rails, 'BR')).toBe(true) + expect(hasFullMantecaRail(rails, 'AR')).toBe(false) + expect(hasFullMantecaRail(rails, 'br')).toBe(true) // case-insensitive + }) +}) + +describe('hasRailInProgress', () => { + test('true for PENDING / REQUIRES_INFORMATION / REQUIRES_EXTRA_INFORMATION', () => { + expect(hasRailInProgress([rail('MANTECA', 'PENDING')], 'MANTECA')).toBe(true) + expect(hasRailInProgress([rail('BRIDGE', 'REQUIRES_INFORMATION')], 'BRIDGE')).toBe(true) + expect(hasRailInProgress([rail('BRIDGE', 'REQUIRES_EXTRA_INFORMATION')], 'BRIDGE')).toBe(true) + }) + + test('false for ENABLED / REJECTED / FAILED', () => { + expect(hasRailInProgress([rail('MANTECA', 'ENABLED')], 'MANTECA')).toBe(false) + expect(hasRailInProgress([rail('MANTECA', 'REJECTED')], 'MANTECA')).toBe(false) + expect(hasRailInProgress([rail('MANTECA', 'FAILED')], 'MANTECA')).toBe(false) + }) +}) + +describe('hasFunctionalRail', () => { + test('true for ENABLED or any in-progress status', () => { + expect(hasFunctionalRail([rail('BRIDGE', 'ENABLED')], 'BRIDGE')).toBe(true) + expect(hasFunctionalRail([rail('BRIDGE', 'PENDING')], 'BRIDGE')).toBe(true) + }) + + test('false when the only rail is REJECTED / FAILED, or none exists', () => { + expect(hasFunctionalRail([rail('BRIDGE', 'REJECTED')], 'BRIDGE')).toBe(false) + expect(hasFunctionalRail([rail('BRIDGE', 'FAILED')], 'BRIDGE')).toBe(false) + expect(hasFunctionalRail([], 'BRIDGE')).toBe(false) + }) +}) diff --git a/src/utils/railGate.utils.ts b/src/utils/railGate.utils.ts new file mode 100644 index 000000000..2e6219514 --- /dev/null +++ b/src/utils/railGate.utils.ts @@ -0,0 +1,52 @@ +import { type IUserRail } from '@/interfaces' + +// Pure helpers for reading gate state off the user's rails. Phase 6 of +// rail-gating: the frontend gate hooks derive from UserRail.status instead +// of kycVerifications / bridgeKycStatus. Mirrors the backend resolveEnabledRail. + +export type RailProviderCode = 'MANTECA' | 'BRIDGE' + +const IN_PROGRESS_STATUSES: ReadonlyArray = ['PENDING', 'REQUIRES_INFORMATION', 'REQUIRES_EXTRA_INFORMATION'] + +function railsForProvider(rails: IUserRail[] | undefined, provider: RailProviderCode): IUserRail[] { + return (rails ?? []).filter((r) => r.rail.provider.code === provider) +} + +/** True when the user holds an ENABLED rail for the provider. */ +export function hasEnabledRail(rails: IUserRail[] | undefined, provider: RailProviderCode): boolean { + return railsForProvider(rails, provider).some((r) => r.status === 'ENABLED') +} + +/** + * True when the user holds a *full-tier* ENABLED Manteca rail — one that + * carries a mantecaUserId. QR-tier rails (enabled on Sumsub approval, no id) + * do not count: deposit / withdraw need real per-user Manteca KYC. `country` + * (ISO-2) optionally narrows to a specific rail method country. + */ +export function hasFullMantecaRail(rails: IUserRail[] | undefined, country?: string): boolean { + const c = country?.toUpperCase() + return railsForProvider(rails, 'MANTECA').some((r) => { + if (r.status !== 'ENABLED') return false + if (c && r.rail.method.country.toUpperCase() !== c) return false + // metadata.mantecaUserId comes from a Prisma Json column — it may + // arrive as a string (current backend) or a number (older rows or + // any path that stores the raw provider id). Coerce both to a + // non-empty string before deciding the rail is gateable, so a number + // doesn't silently fail this check and lock the user out of QR/withdraw. + const id = r.metadata?.mantecaUserId + const idStr = id == null ? '' : String(id) + return idStr.length > 0 + }) +} + +/** True when any rail for the provider is mid-review (pending / requires info). */ +export function hasRailInProgress(rails: IUserRail[] | undefined, provider: RailProviderCode): boolean { + return railsForProvider(rails, provider).some((r) => IN_PROGRESS_STATUSES.includes(r.status)) +} + +/** True when the provider has at least one rail in a functional or in-progress state. */ +export function hasFunctionalRail(rails: IUserRail[] | undefined, provider: RailProviderCode): boolean { + return railsForProvider(rails, provider).some( + (r) => r.status === 'ENABLED' || IN_PROGRESS_STATUSES.includes(r.status) + ) +}