From 265a6b125f9246cf79a3d7aeaa8dfc70122198ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juan=20Jos=C3=A9=20Ram=C3=ADrez?= Date: Thu, 21 May 2026 07:56:12 -0300 Subject: [PATCH 1/4] feat(rails): derive frontend gate hooks from UserRail status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 6 of rail-gating. The frontend gate hooks now read the user's rails instead of kycVerifications / bridgeKycStatus, mirroring the backend resolveEnabledRail: - useQrKycGate — QR gate from Manteca rails: ENABLED -> proceed, rejected -> fixable/blocked (via useProviderRejectionStatus), in-progress -> verifying, none -> needs verification. - useBridgeTransferReadiness — needs_enrollment now derives from the Bridge rails (functional-rail check) rather than useKycStatus flags. - isVerifiedForCountry — a Manteca country needs a full-tier ENABLED Manteca rail (carries mantecaUserId); QR-tier rails do not count, since deposit/withdraw need real KYC. Other countries need an ENABLED Bridge rail. New railGate.utils.ts holds the pure rail-interpretation helpers; useProviderRejectionStatus / useBridgeTosStatus were already rail-based. Deploy note: ship after the Phase 1b/2/4 backfills run — until rails are backfilled a verified user could briefly read as unverified. --- .../__tests__/kyc-withdrawal-gate.test.tsx | 61 +++++------- .../useBridgeTransferReadiness.test.ts | 64 +++++++------ src/hooks/useBridgeTransferReadiness.ts | 32 +++---- src/hooks/useIdentityVerification.tsx | 22 ++--- src/hooks/useQrKycGate.ts | 96 ++++++------------- src/utils/__tests__/railGate.utils.test.ts | 85 ++++++++++++++++ src/utils/railGate.utils.ts | 46 +++++++++ 7 files changed, 242 insertions(+), 164 deletions(-) create mode 100644 src/utils/__tests__/railGate.utils.test.ts create mode 100644 src/utils/railGate.utils.ts 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 2b1cea8c6..00ccebaa2 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -2,10 +2,8 @@ import { useCallback, useState, useEffect, useRef } from 'react' import { useAuth } from '@/context/authContext' -import { MantecaKycStatus } from '@/interfaces' -import { isKycStatusApproved, isSumsubStatusInProgress } from '@/constants/kyc.consts' - -const MAX_SELF_HEAL_ATTEMPTS = 3 +import useProviderRejectionStatus from './useProviderRejectionStatus' +import { hasEnabledRail, hasRailInProgress } from '@/utils/railGate.utils' export enum QrKycState { LOADING = 'loading', @@ -22,26 +20,35 @@ 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 hasRequestedUserFetchRef = useRef(false) const determineKycGateState = useCallback(async () => { - const currentUser = user?.user // while auth is fetching, keep loading to avoid flashing the verify modal if (isFetchingUser) { setKycGateState(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 and wait if (!hasRequestedUserFetchRef.current) { hasRequestedUserFetchRef.current = true setKycGateState(QrKycState.LOADING) @@ -52,79 +59,32 @@ export function useQrKycGate(paymentProcessor?: 'MANTECA' | null): QrKycGateResu } return } - // if we already tried fetching and still have no user, require verification setKycGateState(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 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 isFixable = - railMeta.selfHealable === true && - mantecaKyc?.rejectType !== 'PROVIDER_FINAL' && - ((kycMeta.selfHealAttempt as number) || 0) < MAX_SELF_HEAL_ATTEMPTS - setKycGateState( - isFixable ? QrKycState.PROVIDER_REJECTION_FIXABLE : QrKycState.PROVIDER_REJECTION_BLOCKED - ) - return - } - setKycGateState(QrKycState.PROCEED_TO_PAY) + // a provider rejection takes precedence over rail presence + if (mantecaRejection.state === 'blocked') { + setKycGateState(QrKycState.PROVIDER_REJECTION_BLOCKED) 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) - - if (hasAnyActiveMantecaKyc) { - setKycGateState(QrKycState.PROCEED_TO_PAY) + if (mantecaRejection.state === 'fixable') { + setKycGateState(QrKycState.PROVIDER_REJECTION_FIXABLE) return } - if (currentUser.bridgeKycStatus === 'approved') { + const rails = user.rails + if (hasEnabledRail(rails, 'MANTECA')) { setKycGateState(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') { - setKycGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) - return - } - - if (hasAnyMantecaKyc) { - setKycGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) - return - } - - // sumsub verification in progress - const hasSumsubInProgress = currentUser.kycVerifications?.some( - (v) => v.provider === 'SUMSUB' && isSumsubStatusInProgress(v.status) - ) - if (hasSumsubInProgress) { + if (hasRailInProgress(rails, 'MANTECA')) { setKycGateState(QrKycState.IDENTITY_VERIFICATION_IN_PROGRESS) return } setKycGateState(QrKycState.REQUIRES_IDENTITY_VERIFICATION) - }, [user?.user, user?.rails, isFetchingUser, paymentProcessor, fetchUser]) + }, [user, isFetchingUser, fetchUser, mantecaRejection.state]) 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..c3882257c --- /dev/null +++ b/src/utils/railGate.utils.ts @@ -0,0 +1,46 @@ +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 + const id = r.metadata?.mantecaUserId + return typeof id === 'string' && id.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) + ) +} From 6905b8b2a1af83d923076c1ade4678b08a065152 Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 25 May 2026 11:06:44 +0100 Subject: [PATCH 2/4] fix(rails): coerce metadata.mantecaUserId to string before gating MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma Json column stores mantecaUserId as either a string (current backend) or a number (older rows, any path that writes the raw provider id). The strict 'typeof id === string' check silently failed the gate on number-typed ids, locking the user out of QR/withdraw with no error log. Coerce via String(id) and check non-empty length — robust to both shapes, no behavior change for the string path. --- src/utils/railGate.utils.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/utils/railGate.utils.ts b/src/utils/railGate.utils.ts index c3882257c..2e6219514 100644 --- a/src/utils/railGate.utils.ts +++ b/src/utils/railGate.utils.ts @@ -28,8 +28,14 @@ export function hasFullMantecaRail(rails: IUserRail[] | undefined, country?: str 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 - return typeof id === 'string' && id.length > 0 + const idStr = id == null ? '' : String(id) + return idStr.length > 0 }) } From 9f71e46652a96e016b06c8df49d7f5f2476a3fbf Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 25 May 2026 12:08:49 +0100 Subject: [PATCH 3/4] fix(useQrKycGate): allow Sumsub-approved US-restricted users to QR via fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test 'allows Sumsub-approved users to pay QR through fallback despite Manteca US restriction' (added by dev #2092) was failing because my rail-based gate routed US-restricted users to PROVIDER_REJECTION_BLOCKED without exception. dev #2092's logic: a Sumsub-approved user whose only Manteca rejection is the US-nationality restriction can still pay QR through the BE's Sumsub-pool fallback. enableQrPoolRails creates the enabling pool rail on Sumsub approval — but the FE may not have observed the rail row yet (or the user is mid-migration), so we can't rely on hasEnabledRail alone. In the 'blocked' branch, when (a) user has SUMSUB+APPROVED kyc AND (b) the Manteca rejection metadata carries restrictionCode === US, set PROCEED_TO_PAY. BE adjudicates on the actual request. Uses the existing hasMantecaUsNationalityRestrictionMetadata helper rather than fragile string-match on userMessage. 3/3 useQrKycGate tests pass locally. Eslint failures on the CI run are pre-existing dev noise (verified identical lines exist on origin/dev) — not from this PR. --- src/hooks/useQrKycGate.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 9f275d4f7..51ea8b5f8 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -4,6 +4,7 @@ import { useCallback, useState, useEffect, useRef } from 'react' import { useAuth } from '@/context/authContext' import useProviderRejectionStatus from './useProviderRejectionStatus' import { hasEnabledRail, hasRailInProgress } from '@/utils/railGate.utils' +import { hasMantecaUsNationalityRestrictionMetadata } from '@/utils/manteca-restriction.utils' export enum QrKycState { LOADING = 'loading', @@ -89,6 +90,30 @@ export function useQrKycGate(_paymentProcessor?: 'MANTECA' | null): QrKycGateRes // 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 } From d77e032930492df5d235cdb2ea32529f39aa63cb Mon Sep 17 00:00:00 2001 From: Hugo Montenegro Date: Mon, 25 May 2026 12:16:32 +0100 Subject: [PATCH 4/4] fix(useQrKycGate): prettier format --- src/hooks/useQrKycGate.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/hooks/useQrKycGate.ts b/src/hooks/useQrKycGate.ts index 51ea8b5f8..e1a9b3478 100644 --- a/src/hooks/useQrKycGate.ts +++ b/src/hooks/useQrKycGate.ts @@ -103,9 +103,7 @@ export function useQrKycGate(_paymentProcessor?: 'MANTECA' | null): QrKycGateRes .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) ?? [] + user.user?.kycVerifications?.filter((v) => v.provider === 'MANTECA').map((v) => v.metadata) ?? [] const isUsRestricted = hasMantecaUsNationalityRestrictionMetadata([ ...rejectedMantecaMetadata, ...mantecaKycMetadata,