diff --git a/components/Card/NewCardDetails/CardActionsRow.tsx b/components/Card/NewCardDetails/CardActionsRow.tsx index f46541e4b..5cdd56168 100644 --- a/components/Card/NewCardDetails/CardActionsRow.tsx +++ b/components/Card/NewCardDetails/CardActionsRow.tsx @@ -76,9 +76,9 @@ interface CardActionsRowProps { onFreezeToggle: () => void; onManagePress: () => void; /** - * Whether funds can move onto the card: not frozen, and KYC not paused or - * offboarded. Derived by the parent (`canAddFundsToCard`) rather than here, so - * this row and the freeze state it renders come from one reading of the card. + * Whether funds can move onto the card: it is not frozen. Derived by the + * parent (`canAddFundsToCard`) rather than here, so this row and the freeze + * state it renders come from one reading of the card. */ canAddFunds: boolean; } diff --git a/components/Card/NewCardDetails/CardDetailsPane.tsx b/components/Card/NewCardDetails/CardDetailsPane.tsx index 29813112c..40e187c31 100644 --- a/components/Card/NewCardDetails/CardDetailsPane.tsx +++ b/components/Card/NewCardDetails/CardDetailsPane.tsx @@ -50,7 +50,6 @@ import { useCardSpendRegistration, } from '@/hooks/useCardSpendRegistration'; import { useCardStatus } from '@/hooks/useCardStatus'; -import { useCustomer } from '@/hooks/useCustomer'; import useEuroSpendEnablement from '@/hooks/useEuroSpendEnablement'; import useRainRealTimeFunding from '@/hooks/useRainRealTimeFunding'; import { useRewardsUserData } from '@/hooks/useRewards'; @@ -61,7 +60,6 @@ import { canAddFundsToCard, canToggleCardFreeze, canWithdrawFromCard, - isCustomerFundsRestricted, } from '@/lib/utils/cardHelpers'; import { useCardHeroStore } from '@/store/useCardHeroStore'; import { useCardPaneStore } from '@/store/useCardPaneStore'; @@ -109,7 +107,6 @@ const CardDetailsPane = () => { const { data: cardDetails, refetch } = useCardDetails(); const { data: cardStatus } = useCardStatus(); - const { data: customer } = useCustomer(); const { data: rewardsData } = useRewardsUserData(); const { provider } = useCardProvider(); const [isFreezing, setIsFreezing] = useState(false); @@ -242,7 +239,6 @@ const CardDetailsPane = () => { // Add funds and Withdraw part ways on a frozen card — see the two helpers. const fundsAccess = { isCardFrozen, - isCustomerRestricted: isCustomerFundsRestricted(customer?.status), provider, }; diff --git a/components/Card/NewCardDetails/SpendMode/SpendModeModals.tsx b/components/Card/NewCardDetails/SpendMode/SpendModeModals.tsx index d59ff37c6..258a45941 100644 --- a/components/Card/NewCardDetails/SpendMode/SpendModeModals.tsx +++ b/components/Card/NewCardDetails/SpendMode/SpendModeModals.tsx @@ -6,13 +6,8 @@ import SpendModeSheet from '@/components/Card/NewCardDetails/SpendMode/SpendMode import WirexCardFundModal from '@/components/Card/WirexCardFundModal'; import { useCardDetails } from '@/hooks/useCardDetails'; import { useCardProvider } from '@/hooks/useCardProvider'; -import { useCustomer } from '@/hooks/useCustomer'; import { CardStatus } from '@/lib/types'; -import { - canAddFundsToCard, - canDepositToCard, - isCustomerFundsRestricted, -} from '@/lib/utils/cardHelpers'; +import { canAddFundsToCard, canDepositToCard } from '@/lib/utils/cardHelpers'; import { useCardPaneStore } from '@/store/useCardPaneStore'; import { useSpendModeHelpStore } from '@/store/useSpendModeHelpStore'; import { useUserStore } from '@/store/useUserStore'; @@ -36,12 +31,10 @@ const SpendModeModals = ({ const [isHelpOpen, setIsHelpOpen] = useState(false); const [isFundOpen, setIsFundOpen] = useState(false); const { data: cardDetails } = useCardDetails(); - const { data: customer } = useCustomer(); const { provider } = useCardProvider(); const canAddFunds = canAddFundsToCard({ isCardFrozen: cardDetails?.status === CardStatus.FROZEN, - isCustomerRestricted: isCustomerFundsRestricted(customer?.status), provider, }) && !canDepositToCard(provider); diff --git a/components/Card/NewCardDetails/__tests__/SpendModeModals.test.tsx b/components/Card/NewCardDetails/__tests__/SpendModeModals.test.tsx index 93a2b8532..0b4810693 100644 --- a/components/Card/NewCardDetails/__tests__/SpendModeModals.test.tsx +++ b/components/Card/NewCardDetails/__tests__/SpendModeModals.test.tsx @@ -3,7 +3,7 @@ import { BackHandler, Platform } from 'react-native'; import SpendingModeCard from '@/components/Card/NewCardDetails/SpendingModeCard'; import SpendModeModals from '@/components/Card/NewCardDetails/SpendMode/SpendModeModals'; -import { CardProvider, CardStatus, KycStatus } from '@/lib/types'; +import { CardProvider, CardStatus } from '@/lib/types'; import { useCardPaneStore } from '@/store/useCardPaneStore'; import { useSpendModeHelpStore } from '@/store/useSpendModeHelpStore'; @@ -11,7 +11,6 @@ import { useSpendModeHelpStore } from '@/store/useSpendModeHelpStore'; const { act, create } = require('react-test-renderer'); let mockCardStatus = CardStatus.ACTIVE; -let mockCustomerStatus = KycStatus.APPROVED; let mockProvider = CardProvider.WIREX; jest.mock('@/hooks/useCardDetails', () => ({ @@ -20,9 +19,6 @@ jest.mock('@/hooks/useCardDetails', () => ({ jest.mock('@/hooks/useCardProvider', () => ({ useCardProvider: () => ({ provider: mockProvider }), })); -jest.mock('@/hooks/useCustomer', () => ({ - useCustomer: () => ({ data: { status: mockCustomerStatus } }), -})); jest.mock('@/store/useUserStore', () => ({ useUserStore: (selector: any) => selector({ users: [{ selected: true, userId: 'user-a' }] }), })); @@ -63,7 +59,6 @@ const open = () => act(() => useCardPaneStore.getState().openSpendMode()); beforeEach(() => { (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; mockCardStatus = CardStatus.ACTIVE; - mockCustomerStatus = KycStatus.APPROVED; mockProvider = CardProvider.WIREX; useCardPaneStore.setState({ isOpen: false, isSpendModeOpen: false, originRect: null }); useSpendModeHelpStore.setState({ shownByUserId: {} }); @@ -153,17 +148,13 @@ test('closes the selector before funding and returns to Home afterwards', () => expect(sheet().isOpen).toBe(false); }); -test.each(['frozen card', 'restricted customer', 'Rain card'])( - 'keeps Add funds unavailable for a %s', - restriction => { - if (restriction === 'frozen card') mockCardStatus = CardStatus.FROZEN; - if (restriction === 'restricted customer') mockCustomerStatus = KycStatus.PAUSED; - if (restriction === 'Rain card') mockProvider = CardProvider.RAIN; - render(); - open(); - expect(sheet().onAddFunds).toBeUndefined(); - }, -); +test.each(['frozen card', 'Rain card'])('keeps Add funds unavailable for a %s', restriction => { + if (restriction === 'frozen card') mockCardStatus = CardStatus.FROZEN; + if (restriction === 'Rain card') mockProvider = CardProvider.RAIN; + render(); + open(); + expect(sheet().onAddFunds).toBeUndefined(); +}); test('discards an unavailable selector without opening the card pane or marking help seen', () => { render(false); diff --git a/hooks/useActivateCard.ts b/hooks/useActivateCard.ts index 9a48f3740..8a6052c9e 100644 --- a/hooks/useActivateCard.ts +++ b/hooks/useActivateCard.ts @@ -75,19 +75,12 @@ export function useActivateCard() { toggleStep, canToggleStep, activatingCard, - cardsEndorsement, pushCardReady, } = useCardSteps(_kycStatus as KycStatus | undefined, cardStatusResponse); // Derived: under review state — the screen shows "your card is on its way" - // instead of a steps list the user cannot move. Previously read from the - // bridge.xyz endorsement alone, which is a concept neither live issuer has, so - // a Wirex/Sumsub or Rain/Didit applicant mid-decision fell through to the - // steps list and was asked to verify all over again. - const isUnderReview = isCardIssuanceUnderReview({ - cardStatus: cardStatusResponse, - cardsEndorsement, - }); + // instead of a steps list the user cannot move. + const isUnderReview = isCardIssuanceUnderReview({ cardStatus: cardStatusResponse }); // Track page view on mount useEffect(() => { diff --git a/hooks/useCardSteps/__tests__/buildCardSteps.test.ts b/hooks/useCardSteps/__tests__/buildCardSteps.test.ts index 75f0431bb..0121e0bc2 100644 --- a/hooks/useCardSteps/__tests__/buildCardSteps.test.ts +++ b/hooks/useCardSteps/__tests__/buildCardSteps.test.ts @@ -1,15 +1,9 @@ -import { EndorsementStatus } from '@/components/BankTransfer/enums'; import { buildCardSteps } from '@/hooks/useCardSteps/stepHelpers'; -import { - BridgeCustomerEndorsement, - CardProvider, - KycStatus, - RainApplicationStatus, -} from '@/lib/types'; +import { CardProvider, KycStatus, RainApplicationStatus } from '@/lib/types'; const noop = () => {}; -type Options = Parameters[7]; +type Options = Parameters[5]; const build = ({ cardActivated = false, @@ -21,8 +15,6 @@ const build = ({ options?: Options; } = {}) => buildCardSteps( - undefined, // cardsEndorsement - undefined, // customerRejectionReasons cardActivated, activationBlocked, noop, // handleProceedToKyc @@ -281,33 +273,35 @@ describe('buildCardSteps - a blocked activation', () => { }); }); -describe('buildCardSteps - a retired Bridge endorsement', () => { - // An old bridge.xyz customer keeps their "cards" endorsement on Bridge's side. - const approvedBridgeEndorsement = { - name: 'cards', - status: EndorsementStatus.APPROVED, - } as unknown as BridgeCustomerEndorsement; - +describe('buildCardSteps - an applicant with no card customer yet', () => { + // `/cards/status` reports no kycStatus until a card customer exists. The + // retired bridge.xyz "cards" endorsement used to stand in for it here; nothing + // does now, so the applicant is simply asked to start verification. const kycStepFor = (kycStatus?: KycStatus) => - buildCardSteps(approvedBridgeEndorsement, undefined, false, undefined, noop, noop, noop, { + buildCardSteps(false, undefined, noop, noop, noop, { cardIssuer: CardProvider.WIREX, kycStatus, depositRequired: false, }).find(s => s.key === 'kyc'); - it('does not complete KYC for a Wirex applicant who has not verified', () => { - // The backend reports a card customer, so its kycStatus is the answer — - // the Bridge approval is for a card that no longer exists. - const kyc = kycStepFor(KycStatus.NOT_STARTED); + it.each([undefined, KycStatus.NOT_STARTED])( + 'offers to start verification (kycStatus %s)', + kycStatus => { + const kyc = kycStepFor(kycStatus); - expect(kyc?.completed).toBe(false); - expect(kyc?.buttonText).toBe('Continue verification'); - expect(kyc?.onPress).toBeDefined(); - }); + expect(kyc?.completed).toBe(false); + expect(kyc?.buttonText).toBe('Continue verification'); + expect(kyc?.onPress).toBeDefined(); + }, + ); - it('still honours the endorsement for a Bridge-only user with no card customer', () => { - const kyc = kycStepFor(undefined); + it('does not offer to activate a card', () => { + const activate = buildCardSteps(false, undefined, noop, noop, noop, { + cardIssuer: CardProvider.WIREX, + depositRequired: false, + }).find(s => s.key === 'activate'); - expect(kyc?.completed).toBe(true); + expect(activate?.buttonText).toBeUndefined(); + expect(activate?.onPress).toBeUndefined(); }); }); diff --git a/hooks/useCardSteps/kycDisplayHelpers.ts b/hooks/useCardSteps/kycDisplayHelpers.ts index b3e8e2ea4..54567ec58 100644 --- a/hooks/useCardSteps/kycDisplayHelpers.ts +++ b/hooks/useCardSteps/kycDisplayHelpers.ts @@ -1,53 +1,4 @@ -import { EndorsementStatus } from '@/components/BankTransfer/enums'; -import { - BridgeCustomerEndorsement, - BridgeEndorsementIssue, - BridgeRejectionReason, - CardProvider, - KycStatus, - KycWarning, - RainApplicationStatus, -} from '@/lib/types'; - -// ============================================================================ -// Issue Formatting Functions -// ============================================================================ - -/** - * Format a code string to be user-friendly (replace underscores, capitalize) - */ -function formatCodeToReadable(code: string): string { - return code.replace(/_/g, ' ').replace(/\b\w/g, char => char.toUpperCase()); -} - -/** - * Format a single endorsement issue for display - */ -function formatEndorsementIssue(issue: BridgeEndorsementIssue): string { - if (typeof issue === 'string') { - return formatCodeToReadable(issue); - } - // For objects like { id_front_photo: "id_expired" } - return Object.entries(issue) - .map(([field, error]) => `${formatCodeToReadable(field)}: ${formatCodeToReadable(error)}`) - .join('. '); -} - -/** - * Format rejection reasons from customer response - */ -function formatRejectionReasons(rejectionReasons: BridgeRejectionReason[] | undefined): string[] { - if (!rejectionReasons || rejectionReasons.length === 0) return []; - return rejectionReasons.map(r => r.reason).filter(r => r && r.trim().length > 0); -} - -/** - * Check if endorsement has pending requirements (under review) - */ -function hasEndorsementPendingReview(cardsEndorsement: BridgeCustomerEndorsement): boolean { - const pending = cardsEndorsement.requirements?.pending; - return Array.isArray(pending) && pending.length > 0; -} +import { CardProvider, KycStatus, KycWarning, RainApplicationStatus } from '@/lib/types'; // ============================================================================ // Rain KYC state helpers (application status from Rain API) @@ -208,18 +159,14 @@ export function isRainKYCButtonDisabled( // ============================================================================ /** - * Get the description text for the KYC step based on endorsement status or Rain KYC status + * Get the description text for the KYC step from the Rain application or backend KYC status */ -export function getStepDescription( - cardsEndorsement: BridgeCustomerEndorsement | undefined, - customerRejectionReasons?: BridgeRejectionReason[], - options?: { - cardIssuer?: CardProvider | null; - rainApplicationStatus?: RainApplicationStatus | null; - kycStatus?: KycStatus | null; - kycWarnings?: KycWarning[] | null; - }, -): string { +export function getStepDescription(options?: { + cardIssuer?: CardProvider | null; + rainApplicationStatus?: RainApplicationStatus | null; + kycStatus?: KycStatus | null; + kycWarnings?: KycWarning[] | null; +}): string { // Only use Rain description for recognized Rain application statuses const isRecognizedRainStatus = options?.rainApplicationStatus && @@ -231,9 +178,9 @@ export function getStepDescription( return getKYCDescription(options.rainApplicationStatus, warnings); } - // Approved by the issuer (Wirex/Sumsub has no Bridge endorsement to fall back - // on, so without this it dropped to DEFAULT_KYC_DESCRIPTION and still told an - // approved user that "verification is required for us to issue your card"). + // Approved by the issuer (without this a Wirex/Sumsub approval dropped to + // DEFAULT_KYC_DESCRIPTION and still told an approved user that "verification + // is required for us to issue your card"). if (options?.kycStatus === KycStatus.APPROVED) { return 'Identity verification complete. You can now order your card.'; } @@ -268,83 +215,17 @@ export function getStepDescription( return 'Your information is being reviewed. This usually takes a few minutes.'; } - // No endorsement yet - default message - if (!cardsEndorsement) { - return DEFAULT_KYC_DESCRIPTION; - } - - const requirements = cardsEndorsement.requirements; - const issues = requirements?.issues || []; - const missingKeys = requirements?.missing ? Object.keys(requirements.missing) : []; - - // APPROVED - verification complete - if (cardsEndorsement.status === EndorsementStatus.APPROVED) { - return 'Identity verification complete. You can now order your card.'; - } - - // REVOKED - show rejection reasons or expiry message - if (cardsEndorsement.status === EndorsementStatus.REVOKED) { - // Show customer rejection reasons if available - const reasons = formatRejectionReasons(customerRejectionReasons); - if (reasons.length > 0) { - return `We couldn't verify your identity:\n- ${reasons.join('\n- ')}`; - } - - // Default expiry message - return 'Your verification has expired. Please complete the process again within 24 hours of approval.'; - } - - // INCOMPLETE - check if pending review or needs action - if (cardsEndorsement.status === EndorsementStatus.INCOMPLETE) { - // Pending review - user should wait - if (hasEndorsementPendingReview(cardsEndorsement)) { - return 'Your information is being reviewed. This usually takes a few minutes.'; - } - - // Show what's missing or has issues - const parts: string[] = []; - - // Customer rejection reasons take priority - const reasons = formatRejectionReasons(customerRejectionReasons); - if (reasons.length > 0) { - return `We couldn't verify your identity:\n- ${reasons.join('\n- ')}`; - } - - // Missing requirements - if (missingKeys.length > 0) { - const formattedMissing = missingKeys.map(formatCodeToReadable).join(', '); - parts.push(`Missing: ${formattedMissing}`); - } - - // Issues with submitted data - if (issues.length > 0) { - const formattedIssues = issues.map(formatEndorsementIssue).join('. '); - parts.push(formattedIssues); - } - - if (parts.length > 0) { - return `Additional verification required:\n${parts.join('\n')}`; - } - - // Default message - return 'Additional information needed to complete verification.'; - } - - // Default fallback return DEFAULT_KYC_DESCRIPTION; } /** - * Get the button text for the KYC step based on endorsement status or Rain KYC status + * Get the button text for the KYC step from the Rain application or backend KYC status */ -export function getStepButtonText( - cardsEndorsement: BridgeCustomerEndorsement | undefined, - options?: { - cardIssuer?: CardProvider | null; - rainApplicationStatus?: RainApplicationStatus | null; - kycStatus?: KycStatus | null; - }, -): string | undefined { +export function getStepButtonText(options?: { + cardIssuer?: CardProvider | null; + rainApplicationStatus?: RainApplicationStatus | null; + kycStatus?: KycStatus | null; +}): string | undefined { const isRecognizedRainStatus = options?.rainApplicationStatus && Object.values(RainApplicationStatus).includes(options.rainApplicationStatus); @@ -354,9 +235,9 @@ export function getStepButtonText( } // Approved — the step is done, so there is no action left. Without this it fell - // through to the no-endorsement default and rendered "Continue verification" - // on an already-approved step, which just bounced the user off the Sumsub - // screen (createSession 409s once KYC is approved) and back again. + // through to the default and rendered "Continue verification" on an + // already-approved step, which just bounced the user off the Sumsub screen + // (createSession 409s once KYC is approved) and back again. if (options?.kycStatus === KycStatus.APPROVED) { return undefined; } @@ -376,44 +257,17 @@ export function getStepButtonText( return 'Under Review'; } - // No endorsement - start KYC - if (!cardsEndorsement) { - return 'Continue verification'; - } - - switch (cardsEndorsement.status) { - case EndorsementStatus.APPROVED: - // Step is complete, no button needed - return undefined; - - case EndorsementStatus.REVOKED: - // Allow retry even for region restrictions (user may have another nationality) - return 'Retry KYC'; - - case EndorsementStatus.INCOMPLETE: - // Pending review - user should wait - if (hasEndorsementPendingReview(cardsEndorsement)) { - return 'Under Review'; - } - // Allow retry even for region restrictions (user may have another nationality) - return 'Continue verification'; - - default: - return 'Continue verification'; - } + return 'Continue verification'; } /** * Check if the button should be disabled (for pending review or Rain under_review) */ -export function isStepButtonDisabled( - cardsEndorsement: BridgeCustomerEndorsement | undefined, - options?: { - cardIssuer?: CardProvider | null; - rainApplicationStatus?: RainApplicationStatus | null; - kycStatus?: KycStatus | null; - }, -): boolean { +export function isStepButtonDisabled(options?: { + cardIssuer?: CardProvider | null; + rainApplicationStatus?: RainApplicationStatus | null; + kycStatus?: KycStatus | null; +}): boolean { const isRecognizedRainStatus = options?.rainApplicationStatus && Object.values(RainApplicationStatus).includes(options.rainApplicationStatus); @@ -432,23 +286,6 @@ export function isStepButtonDisabled( return true; } - if (!cardsEndorsement) { - return false; - } - - // Pending review - disabled - if ( - cardsEndorsement.status === EndorsementStatus.INCOMPLETE && - hasEndorsementPendingReview(cardsEndorsement) - ) { - return true; - } - - // Approved - step is complete, button hidden anyway - if (cardsEndorsement.status === EndorsementStatus.APPROVED) { - return true; - } - return false; } diff --git a/hooks/useCardSteps/kycStatusHelpers.ts b/hooks/useCardSteps/kycStatusHelpers.ts index c150990ef..3fcc71571 100644 --- a/hooks/useCardSteps/kycStatusHelpers.ts +++ b/hooks/useCardSteps/kycStatusHelpers.ts @@ -1,22 +1,13 @@ import { useEffect } from 'react'; -import { KycLinkFromBridgeResponse, KycStatus } from '@/lib/types'; +import { KycStatus } from '@/lib/types'; import { isFinalKycStatus } from '@/lib/utils/kyc'; /** - * Compute KYC status from multiple sources (prefer live link status) + * Compute KYC status from the status the KYC screen returned with */ -export function computeKycStatus( - kycLinkStatus: string | undefined, - initialKycStatus: KycStatus | undefined, -): KycStatus { - if (kycLinkStatus) { - return (kycLinkStatus as KycStatus) || KycStatus.UNDER_REVIEW; - } - if (initialKycStatus) { - return initialKycStatus; - } - return KycStatus.NOT_STARTED; +export function computeKycStatus(initialKycStatus: KycStatus | undefined): KycStatus { + return initialKycStatus ?? KycStatus.NOT_STARTED; } /** @@ -24,35 +15,12 @@ export function computeKycStatus( */ export function computeUiKycStatus( processingUntil: number | null, - liveStatus: KycStatus | undefined, kycStatus: KycStatus, ): KycStatus { - const now = Date.now(); - const activeWindow = - Boolean(processingUntil && now < processingUntil) && !isFinalKycStatus(liveStatus); + const activeWindow = Boolean(processingUntil && Date.now() < processingUntil); return activeWindow ? KycStatus.UNDER_REVIEW : kycStatus; } -/** - * Format rejection reasons for display - */ -export function formatRejectionReasons( - kycLink: KycLinkFromBridgeResponse | undefined, -): string | undefined { - const reasons = (kycLink as any)?.rejection_reasons; - if (!reasons || !Array.isArray(reasons) || reasons.length === 0) return undefined; - - try { - const items = reasons - .map((r: any) => (typeof r === 'string' ? r : r?.reason)) - .filter((r: any) => typeof r === 'string' && r.trim().length > 0); - if (!items.length) return undefined; - return `We couldn't verify your identity:\n- ${items.join('\n- ')}`; - } catch { - return undefined; - } -} - /** * Hook to manage the processing window for optimistic KYC status updates */ @@ -62,7 +30,6 @@ export function useProcessingWindow( processingUntil: number | null, setProcessingUntil: (time: number) => void, clearProcessingUntil: () => void, - kycLink: KycLinkFromBridgeResponse | undefined, ): void { // Initialize processing window when returning from redirect useEffect(() => { @@ -74,7 +41,7 @@ export function useProcessingWindow( setProcessingUntil(next); } } - }, [initialKycStatus, kycStatus, processingUntil, setProcessingUntil, kycLink]); + }, [initialKycStatus, kycStatus, processingUntil, setProcessingUntil]); // Clear processing window on final status useEffect(() => { diff --git a/hooks/useCardSteps/stepHelpers.ts b/hooks/useCardSteps/stepHelpers.ts index df36ad83b..9f743dc78 100644 --- a/hooks/useCardSteps/stepHelpers.ts +++ b/hooks/useCardSteps/stepHelpers.ts @@ -1,12 +1,9 @@ import { useCallback, useEffect, useState } from 'react'; import { Router } from 'expo-router'; -import { EndorsementStatus } from '@/components/BankTransfer/enums'; import { MINIMUM_CARD_DEPOSIT_USD } from '@/constants/card'; import { path } from '@/constants/path'; import { - BridgeCustomerEndorsement, - BridgeRejectionReason, CardProvider, CardStatus, KycStatus, @@ -22,11 +19,9 @@ import { getStepButtonText, getStepDescription, isStepButtonDisabled } from './k import { Step } from './types'; /** - * Build the card activation steps array based on endorsement status (Bridge) or Rain KYC status + * Build the card activation steps array from the Rain application or backend KYC status */ export function buildCardSteps( - cardsEndorsement: BridgeCustomerEndorsement | undefined, - customerRejectionReasons: BridgeRejectionReason[] | undefined, cardActivated: boolean, activationBlocked: boolean | undefined, handleProceedToKyc: () => void, @@ -63,12 +58,6 @@ export function buildCardSteps( isSubmittingPendingApplication?: boolean; }, ): Step[] { - // The Bridge endorsement is a fallback for Bridge-only users, who have no card - // customer and so no `kycStatus`. Once `/cards/status` reports one, the - // application is on Rain or Wirex and bridge.xyz has no say in it: an old - // Bridge "cards" approval would otherwise mark a Wirex applicant's KYC step - // done (and offer "Activate card") before they have verified with Sumsub. - const legacyEndorsement = options?.kycStatus != null ? undefined : cardsEndorsement; const stepOptions = options?.cardIssuer != null || options?.kycStatus != null ? { @@ -78,18 +67,15 @@ export function buildCardSteps( kycWarnings: options?.kycWarnings, } : undefined; - const description = getStepDescription(legacyEndorsement, customerRejectionReasons, stepOptions); - const buttonText = getStepButtonText(legacyEndorsement, stepOptions); - const isButtonDisabled = isStepButtonDisabled(legacyEndorsement, stepOptions); + const description = getStepDescription(stepOptions); + const buttonText = getStepButtonText(stepOptions); + const isButtonDisabled = isStepButtonDisabled(stepOptions); const isRainKycApproved = options?.cardIssuer === CardProvider.RAIN && options?.rainApplicationStatus === RainApplicationStatus.APPROVED; - // Wirex has no Bridge endorsement and no Rain application, so it must key off - // the canonical backend kycStatus. Without this branch it fell through to the - // endorsement check, which is a Bridge concept that never exists for a Wirex - // user — leaving isKycComplete permanently false, so the KYC step stayed open - // and "Activate your card" never became enabled even once Wirex had approved. + // Wirex has no Rain application, so it must key off the canonical backend + // kycStatus. const isKycComplete = options?.cardIssuer === CardProvider.RAIN ? isRainKycApproved @@ -97,10 +83,10 @@ export function buildCardSteps( // return cardProvider, so cardIssuer is null for Wirex users and a // provider-specific branch never matched — leaving the step incomplete // and the activate button unrendered even with kycStatus "approved". - // kycStatus is the canonical backend decision for every non-Rain issuer, - // with the Bridge endorsement kept as the legacy fallback. - options?.kycStatus === KycStatus.APPROVED || - legacyEndorsement?.status === EndorsementStatus.APPROVED; + // kycStatus is the canonical backend decision for every non-Rain issuer. + // (The retired bridge.xyz "cards" endorsement is deliberately not read: + // it would offer "Activate card" to someone no live issuer approved.) + options?.kycStatus === KycStatus.APPROVED; // Deliberately does NOT repeat the failure reason. `CardStatusBanner` already // carries it — as a headline, with the detail and a support action — and @@ -169,7 +155,6 @@ export function buildCardSteps( : description, completed: kycStepComplete, status: kycStepComplete ? 'completed' : 'pending', - endorsementStatus: legacyEndorsement?.status, buttonText: showHoldStep ? undefined : buttonText, onPress: showHoldStep || isButtonDisabled ? undefined : kycStepOnPress, }; diff --git a/hooks/useCardSteps/types.ts b/hooks/useCardSteps/types.ts index db863509a..f3e9b6ea7 100644 --- a/hooks/useCardSteps/types.ts +++ b/hooks/useCardSteps/types.ts @@ -1,5 +1,3 @@ -import { EndorsementStatus } from '@/components/BankTransfer/enums'; - /** * Stable identifier for a card-activation step. Consumers key off this instead * of the array index or numeric `id`, so steps can appear, disappear or be @@ -22,7 +20,6 @@ export interface Step { buttonText?: string; onPress?: () => void; status?: 'pending' | 'completed'; - endorsementStatus?: EndorsementStatus; /** Renders the step's action as busy, e.g. while its request is in flight. */ isLoading?: boolean; } diff --git a/hooks/useCardSteps/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index 767d4ed84..ef9d37ad5 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -8,7 +8,6 @@ import { path } from '@/constants/path'; import { TRACKING_EVENTS } from '@/constants/tracking-events'; import { useBalances } from '@/hooks/useBalances'; import { CARD_STATUS_QUERY_KEY } from '@/hooks/useCardStatus'; -import { useCustomer, useKycLinkFromBridge } from '@/hooks/useCustomer'; import { useOpenDepositFlow } from '@/hooks/useOpenDepositFlow'; import { useProspectiveCardIssuer } from '@/hooks/useProspectiveCardIssuer'; import { track } from '@/lib/analytics'; @@ -34,31 +33,25 @@ import { buildCardSteps, useCardActivation, useStepNavigation } from './stepHelp export type { Step } from './types'; /** - * Hook that manages the card activation flow steps - * Now uses cards endorsement status as the source of truth for step display + * Hook that manages the card activation flow steps. Every step reads + * `/cards/status` — the bridge.xyz customer and its "cards" endorsement are not + * consulted: Bridge cards are retired, and that lookup now only fails. */ export function useCardSteps( initialKycStatus?: KycStatus, cardStatusResponse?: CardStatusResponse | null, ) { const router = useRouter(); - const { - kycLinkId, - processingUntil, - setProcessingUntil, - clearProcessingUntil, - setKycFlow, - setKycProvider, - } = useKycStore( - useShallow(state => ({ - kycLinkId: state.kycLinkId, - processingUntil: state.processingUntil, - setProcessingUntil: state.setProcessingUntil, - clearProcessingUntil: state.clearProcessingUntil, - setKycFlow: state.setKycFlow, - setKycProvider: state.setKycProvider, - })), - ); + const { processingUntil, setProcessingUntil, clearProcessingUntil, setKycFlow, setKycProvider } = + useKycStore( + useShallow(state => ({ + processingUntil: state.processingUntil, + setProcessingUntil: state.setProcessingUntil, + clearProcessingUntil: state.clearProcessingUntil, + setKycFlow: state.setKycFlow, + setKycProvider: state.setKycProvider, + })), + ); // Consider Rain when the API returns rainApplicationStatus (provider may be // omitted). An external verification link is Rain-only too, so treat it as // the same evidence: if the response carries a resubmission link, the Rain @@ -71,21 +64,10 @@ export function useCardSteps( ? CardProvider.RAIN : (cardStatusResponse?.provider ?? EXPO_PUBLIC_CARD_ISSUER ?? null); - // Get customer data with cards endorsement - const { data: customer } = useCustomer(); - const cardsEndorsement = useMemo( - () => customer?.endorsements?.find(e => e.name === 'cards'), - [customer?.endorsements], - ); - - // Get KYC link status (still needed for redirect flow) - const { data: kycLink } = useKycLinkFromBridge(kycLinkId || undefined); - - // Compute KYC status (for processing window logic) - const kycStatus = useMemo( - () => computeKycStatus(kycLink?.kyc_status, initialKycStatus), - [kycLink?.kyc_status, initialKycStatus], - ); + // Compute KYC status (for processing window logic). Only the status the KYC + // screen returned with: the bridge.xyz KYC link this used to poll belongs to + // the bank-transfer rail now, not to any card application. + const kycStatus = useMemo(() => computeKycStatus(initialKycStatus), [initialKycStatus]); // Manage processing window useProcessingWindow( @@ -94,13 +76,12 @@ export function useCardSteps( processingUntil, setProcessingUntil, clearProcessingUntil, - kycLink, ); // Compute UI KYC status with processing window override (for tracking) const uiKycStatus = useMemo( - () => computeUiKycStatus(processingUntil, kycLink?.kyc_status as KycStatus, kycStatus), - [processingUntil, kycLink?.kyc_status, kycStatus], + () => computeUiKycStatus(processingUntil, kycStatus), + [processingUntil, kycStatus], ); // Card activation state and handlers @@ -213,9 +194,7 @@ export function useCardSteps( track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { action: 'start', kycStatus: uiKycStatus, - kycLinkId, hasProcessingWindow: Boolean(processingUntil), - endorsementStatus: cardsEndorsement?.status, cardIssuer, }); @@ -280,9 +259,8 @@ export function useCardSteps( * already finished. * * `kycStatus`, not `uiKycStatus`: the latter can be an optimistic - * post-submit window, while this needs the backend's own answer — and - * for a Wirex user there is no Bridge kycLink, so it is exactly what - * /cards/status reported. + * post-submit window, while this needs the backend's own answer, which + * is exactly what /cards/status reported. */ if (kycStatus === KycStatus.UNDER_REVIEW) { track(TRACKING_EVENTS.CARD_KYC_FLOW_TRIGGERED, { @@ -318,17 +296,7 @@ export function useCardSteps( } router.push((kycProvider === KycProvider.SUMSUB ? path.SUMSUB_KYC : path.KYC) as any); - }, [ - router, - kycLinkId, - kycStatus, - uiKycStatus, - processingUntil, - cardsEndorsement?.status, - cardIssuer, - setKycFlow, - setKycProvider, - ]); + }, [router, kycStatus, uiKycStatus, processingUntil, cardIssuer, setKycFlow, setKycProvider]); // Rain: KYC step button handler (redirect, contact support, or proceed to KYC) const handleRainKYCPress = useCallback(() => { @@ -384,12 +352,10 @@ export function useCardSteps( handleProceedToKyc, ]); - // Build steps based on endorsement status (Bridge) or Rain KYC status + // Build steps from the card status (Rain application or backend KYC status) const steps = useMemo( () => buildCardSteps( - cardsEndorsement, - customer?.rejection_reasons, cardActivated, // A failure nothing on this screen can clear gates the activate step // exactly like the sticky block does: pressing "Activate card" on an @@ -423,8 +389,6 @@ export function useCardSteps( }, ), [ - cardsEndorsement, - customer?.rejection_reasons, cardActivated, cardStatusResponse?.activationBlocked, cardStatusResponse?.activationFailure, @@ -457,7 +421,6 @@ export function useCardSteps( toggleStep, canToggleStep, activatingCard, - cardsEndorsement, // Exposed so the failure banner can offer the same action the activate // step does, rather than owning a second, divergent route to issuance. pushCardReady, diff --git a/hooks/useCustomer.ts b/hooks/useCustomer.ts index 1783853a0..01dcb4081 100644 --- a/hooks/useCustomer.ts +++ b/hooks/useCustomer.ts @@ -1,12 +1,15 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useQuery } from '@tanstack/react-query'; -import { createKycLink, getCustomerFromBridge, getKycLink, getKycLinkFromBridge } from '@/lib/api'; -import { KycLinkFromBridgeResponse } from '@/lib/types'; +import { getCustomerFromBridge } from '@/lib/api'; import { withRefreshToken } from '@/lib/utils'; -import { isFinalKycStatus } from '@/lib/utils/kyc'; const CUSTOMER = 'customer'; +/** + * The bridge.xyz customer, for the bank-transfer (fiat rail) flow only. Bridge + * cards are retired, so nothing on the card side should read this: every mount + * is a live Bridge API call, and for a card user it only ever failed. + */ export const useCustomer = () => { return useQuery({ queryKey: [CUSTOMER], @@ -14,64 +17,3 @@ export const useCustomer = () => { retry: false, }); }; - -export const useKycLink = (kycLinkId?: string) => { - return useQuery({ - queryKey: [CUSTOMER, 'kycLink', kycLinkId], - queryFn: () => withRefreshToken(() => getKycLink(kycLinkId!)), - enabled: !!kycLinkId, - retry: false, - }); -}; - -export const useKycLinkFromBridge = (kycLinkId?: string) => { - return useQuery({ - queryKey: [CUSTOMER, 'kycLinkFromBridge', kycLinkId], - queryFn: () => withRefreshToken(() => getKycLinkFromBridge(kycLinkId!)), - enabled: !!kycLinkId, - retry: false, - refetchInterval: (query: any) => { - if (!kycLinkId) return false; - - // In our TanStack version, refetchInterval receives the Query instance - const data = query?.state?.data as any; - const status = data?.kyc_status as string | undefined; - const hasData = Boolean(data); - // Poll every 5s while KYC is in a non-final state; stop when final - const next = !hasData ? 5000 : isFinalKycStatus(status) ? false : 5000; - - return next as any; - }, - refetchIntervalInBackground: true, - }); -}; - -export const useCreateKycLink = () => { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: async ({ - fullName, - email, - redirectUri, - endorsements, - }: { - fullName: string; - email: string; - redirectUri: string; - endorsements: string[]; - }) => { - const result = await withRefreshToken(() => - createKycLink(fullName, email, redirectUri, endorsements), - ); - if (!result) { - throw new Error('Failed to create KYC link'); - } - return result; - }, - onSuccess: () => { - // Invalidate customer query to refetch updated data - queryClient.invalidateQueries({ queryKey: [CUSTOMER] }); - }, - }); -}; diff --git a/lib/api.ts b/lib/api.ts index b10f97c23..9231a171e 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -100,7 +100,6 @@ import { KycLink, KycLinkAgreements, KycLinkForExistingCustomer, - KycLinkFromBridgeResponse, LandingPageApyConfig, LayerZeroTransaction, LeaderboardResponse, @@ -782,46 +781,6 @@ export const createKycLink = async ( return response.json(); }; -export const getKycLink = async (kycLinkId: string): Promise => { - const jwt = getJWTToken(); - - const response = await fetch( - `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/cards/kyc/link/${kycLinkId}`, - { - credentials: 'include', - headers: { - ...getPlatformHeaders(), - ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), - }, - }, - ); - - if (!response.ok) throw response; - - return response.json(); -}; - -export const getKycLinkFromBridge = async ( - kycLinkId: string, -): Promise => { - const jwt = getJWTToken(); - - const response = await fetch( - `${EXPO_PUBLIC_FLASH_API_BASE_URL}/accounts/v1/cards/kyc/kyc-link-from-bridge/${kycLinkId}`, - { - credentials: 'include', - headers: { - ...getPlatformHeaders(), - ...(jwt ? { Authorization: `Bearer ${jwt}` } : {}), - }, - }, - ); - - if (!response.ok) throw response; - - return response.json(); -}; - /** Rain KYC: submit Persona inquiry ID after user completes Persona flow. Returns consumerId and kycStatus; only allow card creation when kycStatus === 'approved'. */ export const submitPersonaKyc = async ( personaInquiryId: string, diff --git a/lib/types.ts b/lib/types.ts index c401afb64..9f6092aea 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -352,21 +352,6 @@ export type KycLink = { tosLink: string; }; -export type KycRejectionReason = { reason: string }; - -export type KycLinkFromBridgeResponse = { - id: string; - full_name: string; - email: string; - type: string; - kyc_link: string; - tos_link: string; - kyc_status: string; - rejection_reasons: KycRejectionReason[]; - tos_status: string; - customer_id: string; -}; - export type KycLinkForExistingCustomer = { url: string; }; diff --git a/lib/utils/__tests__/cardFundsAccess.test.ts b/lib/utils/__tests__/cardFundsAccess.test.ts index 2ad285f73..8c705c5cc 100644 --- a/lib/utils/__tests__/cardFundsAccess.test.ts +++ b/lib/utils/__tests__/cardFundsAccess.test.ts @@ -1,9 +1,5 @@ -import { CardProvider, KycStatus } from '@/lib/types'; -import { - canAddFundsToCard, - canWithdrawFromCard, - isCustomerFundsRestricted, -} from '@/lib/utils/cardHelpers'; +import { CardProvider } from '@/lib/types'; +import { canAddFundsToCard, canWithdrawFromCard } from '@/lib/utils/cardHelpers'; /** * These two decide whether the card action row renders Add funds and Withdraw at @@ -13,9 +9,8 @@ import { * split apart. */ describe('card funds access', () => { - const live = { isCardFrozen: false, isCustomerRestricted: false }; - const frozen = { isCardFrozen: true, isCustomerRestricted: false }; - const restricted = { isCardFrozen: false, isCustomerRestricted: true }; + const live = { isCardFrozen: false }; + const frozen = { isCardFrozen: true }; describe('canWithdrawFromCard', () => { it('offers Withdraw on a live card', () => { @@ -28,11 +23,6 @@ describe('card funds access', () => { expect(canWithdrawFromCard(frozen)).toBe(true); }); - it('hides Withdraw for a paused or offboarded customer', () => { - expect(canWithdrawFromCard(restricted)).toBe(false); - expect(canWithdrawFromCard({ isCardFrozen: true, isCustomerRestricted: true })).toBe(false); - }); - it('hides Withdraw on a Wirex card, which holds no balance of its own', () => { // There is no collateral proxy behind a Wirex card and no backend // endpoint that would serve one, so the withdraw screen's Rain-only @@ -58,29 +48,5 @@ describe('card funds access', () => { // Funding a card that can't spend only moves the balance somewhere worse. expect(canAddFundsToCard(frozen)).toBe(false); }); - - it('hides Add funds for a paused or offboarded customer', () => { - expect(canAddFundsToCard(restricted)).toBe(false); - }); - }); - - describe('isCustomerFundsRestricted', () => { - it('restricts paused and offboarded customers', () => { - expect(isCustomerFundsRestricted(KycStatus.PAUSED)).toBe(true); - expect(isCustomerFundsRestricted(KycStatus.OFFBOARDED)).toBe(true); - }); - - it('leaves every other status alone', () => { - expect(isCustomerFundsRestricted(KycStatus.APPROVED)).toBe(false); - expect(isCustomerFundsRestricted(KycStatus.UNDER_REVIEW)).toBe(false); - expect(isCustomerFundsRestricted(KycStatus.REJECTED)).toBe(false); - }); - - it('does not restrict before the customer has loaded', () => { - // An unloaded customer is not a paused one — the row should render its - // actions rather than drop columns and reflow once the query answers. - expect(isCustomerFundsRestricted(undefined)).toBe(false); - expect(isCustomerFundsRestricted(null)).toBe(false); - }); }); }); diff --git a/lib/utils/__tests__/cardReviewState.test.ts b/lib/utils/__tests__/cardReviewState.test.ts index 700359097..3eb7fb57a 100644 --- a/lib/utils/__tests__/cardReviewState.test.ts +++ b/lib/utils/__tests__/cardReviewState.test.ts @@ -1,24 +1,10 @@ /// -import { EndorsementStatus } from '@/components/BankTransfer/enums'; -import { - BridgeCustomerEndorsement, - CardProvider, - CardStatusResponse, - KycStatus, - RainApplicationStatus, -} from '@/lib/types'; +import { CardProvider, CardStatusResponse, KycStatus, RainApplicationStatus } from '@/lib/types'; import { isCardIssuanceUnderReview } from '@/lib/utils/cardReviewState'; -const endorsement = (overrides: { - status: EndorsementStatus; - requirements?: { pending?: string[] }; -}): BridgeCustomerEndorsement => ({ name: 'cards', ...overrides }) as BridgeCustomerEndorsement; - -const isUnderReview = ( - cardStatus: CardStatusResponse | null | undefined, - cardsEndorsement?: BridgeCustomerEndorsement, -) => isCardIssuanceUnderReview({ cardStatus, cardsEndorsement }); +const isUnderReview = (cardStatus: CardStatusResponse | null | undefined) => + isCardIssuanceUnderReview({ cardStatus }); /** * `/card/activate` used to read this from the bridge.xyz endorsement alone — @@ -125,31 +111,4 @@ describe('isCardIssuanceUnderReview', () => { ).toBe(false); }); }); - - describe('the deprecated bridge.xyz/Persona path, unchanged', () => { - it('holds an endorsement with pending requirements', () => { - expect( - isUnderReview( - {}, - endorsement({ - status: EndorsementStatus.INCOMPLETE, - requirements: { pending: ['kyc_with_proof_of_address'] }, - }), - ), - ).toBe(true); - }); - - it('does not hold an incomplete endorsement with nothing pending', () => { - expect( - isUnderReview( - {}, - endorsement({ status: EndorsementStatus.INCOMPLETE, requirements: { pending: [] } }), - ), - ).toBe(false); - }); - - it('does not hold an approved endorsement', () => { - expect(isUnderReview({}, endorsement({ status: EndorsementStatus.APPROVED }))).toBe(false); - }); - }); }); diff --git a/lib/utils/cardHelpers.ts b/lib/utils/cardHelpers.ts index 97f8bddf6..8fce73afa 100644 --- a/lib/utils/cardHelpers.ts +++ b/lib/utils/cardHelpers.ts @@ -16,7 +16,6 @@ import { CashbackType, CryptoTransactionDetails, FreezeInitiator, - KycStatus, } from '@/lib/types'; import type { VirtualAccountProvider } from '@/hooks/useVirtualAccountProvider'; @@ -53,26 +52,10 @@ const canCustomerUnfreezeCard = (cardDetails: FreezeState): boolean => { export const canToggleCardFreeze = (cardDetails: FreezeState): boolean => cardDetails?.status !== CardStatus.FROZEN || canCustomerUnfreezeCard(cardDetails); -/** - * KYC states where we move no money for the customer in either direction. - * - * Paused and offboarded are holds on the person rather than on the card, so they - * are the one thing that stops a withdrawal too — a card-level problem doesn't. - * Anything else, an unresolved status included, leaves both actions offered. - * - * Takes a bare string because that is what `/bridge-customer` is typed as: the - * endpoint passes Bridge's status through unmodelled, and only the two values - * named here are acted on, so an unrecognised one restricts nothing. - */ -export const isCustomerFundsRestricted = (status: string | undefined | null): boolean => - status === KycStatus.PAUSED || status === KycStatus.OFFBOARDED; - /** What the card action row needs to know before offering to move money. */ export interface CardFundsAccess { /** The card is frozen — by the cardholder or by the provider. */ isCardFrozen: boolean; - /** KYC is paused or the customer is offboarded — see `isCustomerFundsRestricted`. */ - isCustomerRestricted: boolean; /** * Which issuer the card is on. `null`/`undefined` while it is still * resolving — treated as Rain, so a slow query never hides an action a Rain @@ -87,10 +70,7 @@ export interface CardFundsAccess { * A frozen card cannot spend, so a deposit onto one only moves the user's * balance somewhere it does less for them. The action waits for the unfreeze. */ -export const canAddFundsToCard = ({ - isCardFrozen, - isCustomerRestricted, -}: CardFundsAccess): boolean => !isCardFrozen && !isCustomerRestricted; +export const canAddFundsToCard = ({ isCardFrozen }: CardFundsAccess): boolean => !isCardFrozen; /** * Whether to offer Withdraw. Deliberately does **not** read `isCardFrozen`. @@ -118,8 +98,8 @@ export const canAddFundsToCard = ({ * is in savings and comes out through the savings withdrawal, so offering this * action was offering a dead end. */ -export const canWithdrawFromCard = ({ isCustomerRestricted, provider }: CardFundsAccess): boolean => - !isCustomerRestricted && cardHoldsBalance(provider); +export const canWithdrawFromCard = ({ provider }: CardFundsAccess): boolean => + cardHoldsBalance(provider); /** * Whether this card can be funded by depositing onto it. diff --git a/lib/utils/cardReviewState.ts b/lib/utils/cardReviewState.ts index 3baf841a8..ecf15c829 100644 --- a/lib/utils/cardReviewState.ts +++ b/lib/utils/cardReviewState.ts @@ -1,26 +1,10 @@ -import { EndorsementStatus } from '@/components/BankTransfer/enums'; -import { BridgeCustomerEndorsement, CardStatusResponse } from '@/lib/types'; +import { CardStatusResponse } from '@/lib/types'; import { blocksCardActivation } from '@/lib/utils/cardActivationRetry'; import { isKycAwaitingDecision } from '@/lib/utils/kyc/verificationProgress'; export interface CardIssuanceReviewInput { /** Latest `/cards/status` response; the issuance screen polls it. */ cardStatus: CardStatusResponse | null | undefined; - /** - * The deprecated bridge.xyz "cards" endorsement, when the user has one. That - * flow never reported a KYC status on `/cards/status`, so its review state is - * only visible here. - */ - cardsEndorsement: BridgeCustomerEndorsement | undefined; -} - -/** The bridge.xyz/Persona review state: an endorsement with pending requirements. */ -function hasBridgeEndorsementUnderReview( - cardsEndorsement: BridgeCustomerEndorsement | undefined, -): boolean { - if (cardsEndorsement?.status !== EndorsementStatus.INCOMPLETE) return false; - const pending = cardsEndorsement.requirements?.pending; - return Array.isArray(pending) && pending.length > 0; } /** @@ -39,14 +23,9 @@ function hasBridgeEndorsementUnderReview( * * Both live issuers are covered by one question — see `isKycAwaitingDecision` — * so Rain/Didit and Wirex/Sumsub need no branch of their own here. The - * endorsement clause is the deprecated bridge.xyz/Persona path, unchanged. + * retired bridge.xyz/Persona endorsement is not consulted. */ -export function isCardIssuanceUnderReview({ - cardStatus, - cardsEndorsement, -}: CardIssuanceReviewInput): boolean { - if (hasBridgeEndorsementUnderReview(cardsEndorsement)) return true; - +export function isCardIssuanceUnderReview({ cardStatus }: CardIssuanceReviewInput): boolean { if (!isKycAwaitingDecision(cardStatus)) return false; // Issuance is blocked (e.g. a provider registration that cannot be retried). diff --git a/playwright/e2e/card-creation.spec.ts b/playwright/e2e/card-creation.spec.ts index 7c06832c5..5273d8336 100644 --- a/playwright/e2e/card-creation.spec.ts +++ b/playwright/e2e/card-creation.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from '@playwright/test'; +import { expect, Page, test } from '@playwright/test'; test.describe('Card Creation Flow', () => { // /card and /card-onboard both used to render the standalone card waitlist @@ -250,31 +250,6 @@ test.describe('Card Creation Flow - With Mocking', () => { }); }); - // Mock Bridge KYC link API (needed for when button is clicked) - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/start', - kyc_status: 'not_started', - }), - }); - }); - - // Mock Bridge customer API (no endorsement yet) - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [], - }), - }); - }); - // Navigate directly to card activation with countryConfirmed=true // This simulates user who just selected a supported country on country_selection page await page.goto('/card/activate?countryConfirmed=true'); @@ -300,1775 +275,59 @@ test.describe('Card Creation Flow - With Mocking', () => { // Just verify no blocking toast appeared }); - test('should show KYC under review status', async ({ page }) => { - // Mock card status API to return 404 (no card) + /** + * The KYC step reads `/cards/status` alone. These used to be driven by the + * bridge.xyz customer's "cards" endorsement, which is retired along with + * Bridge cards; the client no longer asks for it. + */ + const mockCardStatus = async (page: Page, body: Record) => { await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), + body: JSON.stringify({ depositRequired: false, ...body }), }); }); - - // Mock Bridge KYC link API with under_review status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { + await page.route('**/accounts/v1/cards/check-access**', async route => { await route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/123', - kyc_status: 'under_review', - }), + body: JSON.stringify({ hasAccess: true, countryCode: 'US' }), }); }); + }; - // Mock Bridge customer API with incomplete endorsement and pending items - // This is required because the under review state now depends on endorsement.requirements.pending - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['first_name', 'last_name'], - pending: ['document_verification', 'identity_check'], - missing: {}, - issues: [], - }, - }, - ], - }), - }); - }); + test('should show KYC under review status', async ({ page }) => { + await mockCardStatus(page, { kycStatus: 'under_review' }); - // Navigate with kycStatus param to trigger the right state await page.goto('/card/activate?kycStatus=under_review&countryConfirmed=true'); await page.waitForLoadState('networkidle'); - // Verify the under review UI is shown await expect(page.getByText('Your card is on its way!')).toBeVisible({ timeout: 15000 }); await expect(page.getByText(/identity is now being verified/i)).toBeVisible(); - - // Wait so user can see the result - await page.waitForTimeout(3000); }); - test('should show KYC rejected status with retry option', async ({ page }) => { - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with rejected status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/123', - kyc_status: 'rejected', - rejection_reasons: [ - { - type: 'document_invalid', - description: 'Invalid document', - }, - ], - }), - }); - }); - - // Mock Bridge customer API with revoked endorsement and rejection reasons - // This is required because rejection messages now come from customer.rejection_reasons - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'revoked', - }, - ], - rejection_reasons: [ - { - developer_reason: 'document_invalid', - reason: 'Invalid document submitted. Please try again.', - created_at: new Date().toISOString(), - }, - ], - }), - }); - }); + test('should show KYC rejected status without an action', async ({ page }) => { + await mockCardStatus(page, { kycStatus: 'rejected' }); - // Navigate with rejected kycStatus param await page.goto('/card/activate?kycStatus=rejected&countryConfirmed=true'); await page.waitForLoadState('networkidle'); - // Verify "Retry KYC" or "Complete KYC" button is shown (button text depends on implementation) - const retryButton = page.getByRole('button', { name: /Retry KYC|Continue verification/i }); - await expect(retryButton).toBeVisible({ timeout: 15000 }); - - // Verify rejection message is displayed - await expect(page.getByText(/rejected|Invalid document|try again/i)).toBeVisible(); - - // Wait so user can see the result - await page.waitForTimeout(3000); - }); - - test('should show KYC approved status and enable card ordering', async ({ page }) => { - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with approved status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/123', - kyc_status: 'approved', - }), - }); - }); - - // Mock Bridge customer API with approved cards endorsement - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'approved', - }, - ], - }), - }); - }); - - // Navigate with approved kycStatus param - await page.goto('/card/activate?kycStatus=approved&countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - - // Verify step 1 is marked as complete (check mark or completed status) await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // Verify "Activate your card" button is visible and enabled - const orderButton = page.getByRole('button', { name: /Order.*card/i }); - await expect(orderButton).toBeVisible(); - await expect(orderButton).toBeEnabled(); - - // Wait so user can see the result - await page.waitForTimeout(3000); + await expect(page.getByText('Your identity verification was declined.')).toBeVisible(); + await expect(page.getByRole('button', { name: /Continue verification/i })).not.toBeVisible(); + await expect(page.getByRole('button', { name: /Activate card/i })).not.toBeVisible(); }); - test('should redirect to KYC when endorsement is incomplete', async ({ page }) => { - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with approved status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/continue', - kyc_status: 'approved', - }), - }); - }); - - // Mock Bridge customer API with incomplete cards endorsement - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - missing: { - address: 'address is required', - }, - }, - }, - ], - }), - }); - }); - - // Mock KYC link for existing customer (re-enrollment with cards endorsement) - await page.route('**/accounts/v1/bridge-customer/kyc-link**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - url: 'https://bridge.xyz/kyc/cards-endorsement', - }), - }); - }); + test('should show KYC approved status and enable card activation', async ({ page }) => { + await mockCardStatus(page, { kycStatus: 'approved' }); - // Navigate to activate page - await page.goto('/card/activate?countryConfirmed=true'); + await page.goto('/card/activate?kycStatus=approved&countryConfirmed=true'); await page.waitForLoadState('networkidle'); - // Wait for UI to update - await page.waitForTimeout(2000); - - // With approved KYC but incomplete endorsement, step 1 should show Complete KYC await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // The "Order card" button should exist but be disabled (endorsement not approved) - // Note: Button may be hidden/collapsed but exists in DOM as disabled - const orderButton = page.getByRole('button', { name: /Order.*card/i }); - // Just verify Complete KYC is shown - Order button may be collapsed when endorsement is incomplete - - // Wait for visual inspection - await page.waitForTimeout(5000); - }); - - test('should show pending review status when endorsement is pending', async ({ page }) => { - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with approved status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/123', - kyc_status: 'approved', - }), - }); - }); - - // Mock Bridge customer API with incomplete endorsement but pending review - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - pending: ['document_verification'], - }, - }, - ], - }), - }); - }); - - // Navigate to activate page - KYC link will be polled and return approved - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - - // Wait for data to be fetched and UI to update - await page.waitForTimeout(3000); - - // With pending endorsement items, the "under review" UI should be shown - await expect(page.getByText('Your card is on its way!')).toBeVisible({ timeout: 15000 }); - await expect(page.getByText(/being verified/i)).toBeVisible({ timeout: 5000 }); - - // Wait so user can see the result - await page.waitForTimeout(3000); - }); - - test('should show retry option when endorsement is revoked', async ({ page }) => { - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with approved status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/retry', - kyc_status: 'approved', - }), - }); - }); - - // Mock Bridge customer API with revoked cards endorsement - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'revoked', - }, - ], - rejection_reasons: [ - { - reason: 'Unable to verify identity document', - }, - ], - }), - }); - }); - - // Mock KYC link for existing customer (re-enrollment with cards endorsement) - await page.route('**/accounts/v1/bridge-customer/kyc-link**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - url: 'https://bridge.xyz/kyc/cards-endorsement-retry', - }), - }); - }); - - // Navigate to activate page - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - - // Wait for UI to update - await page.waitForTimeout(2000); - - // With approved KYC but revoked endorsement, step 1 should show Complete KYC - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // "Order card" button exists but may be hidden (endorsement revoked) - // Just verify the Complete KYC button is shown for this state - }); - - test('should redirect to KYC when no cards endorsement exists', async ({ page }) => { - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with approved status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/start', - kyc_status: 'approved', - }), - }); - }); - - // Mock Bridge customer API with no cards endorsement - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [], - }), - }); - }); - - // Mock KYC link for existing customer (re-enrollment with cards endorsement) - await page.route('**/accounts/v1/bridge-customer/kyc-link**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - url: 'https://bridge.xyz/kyc/cards-endorsement-new', - }), - }); - }); - - // Navigate to activate page - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - - // Wait for UI to update - await page.waitForTimeout(2000); - - // With approved KYC but no endorsement, step 1 should show Complete KYC - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // The "Order card" button may be hidden when user needs KYC re-enrollment - // Just verify the Complete KYC button is shown for re-enrollment - - // Wait for visual inspection - await page.waitForTimeout(5000); - }); - - test('should create KYC link for existing user when clicking Complete KYC button', async ({ - page, - }) => { - // This test verifies the re-enrollment flow where an existing user - // without the cards endorsement can go through KYC to get the endorsement - - let kycLinkRequestMade = false; - - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with approved status (user has passed basic KYC) - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/completed', - kyc_status: 'approved', - }), - }); - }); - - // Mock Bridge customer API with NO cards endorsement (needs to go through KYC again) - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [], // No cards endorsement - }), - }); - }); - - // Mock IP detection API (ipify) - await page.route('**/api.ipify.org/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ ip: '8.8.8.8' }), // US IP - }); - }); - - // Mock country from IP API (ipapi.co) - await page.route('**/ipapi.co/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - country_code: 'US', - country_name: 'United States', - }), - }); - }); - - // Mock KYC link for existing customer - this is the endpoint that creates - // a new KYC link with the cards endorsement for existing customers - await page.route('**/accounts/v1/bridge-customer/kyc-link**', async route => { - kycLinkRequestMade = true; - - // Verify the request includes the cards endorsement - const url = route.request().url(); - expect(url).toContain('endorsement=cards'); - - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - url: 'https://bridge.xyz/kyc/cards-endorsement-request', - }), - }); - }); - - // Navigate to activate page - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - - // Wait for UI to update - await page.waitForTimeout(2000); - - // Verify the Complete KYC button is visible - const kycButton = page.getByRole('button', { name: /Continue verification/i }); - await expect(kycButton).toBeVisible({ timeout: 15000 }); - - // Click the Complete KYC button to trigger the re-enrollment flow - await kycButton.click(); - - // Verify either the KYC link endpoint was called OR user is redirected to user-kyc-info - if (!kycLinkRequestMade) { - await page.waitForURL('**/user-kyc-info**', { timeout: 10000 }); - } - - // (The flow may go to collect user info first if existing customer link fails) - const currentUrl = page.url(); - const redirectedToKycInfo = currentUrl.includes('user-kyc-info'); - - // Either the API was called or we redirected to collect user info - expect(kycLinkRequestMade || redirectedToKycInfo).toBe(true); - - // Wait for visual inspection - await page.waitForTimeout(5000); - }); - - // ============================================================================ - // Endorsement Status Display Tests - // ============================================================================ - - test('should display user-friendly message for incomplete endorsement with missing address', async ({ - page, - }) => { - // Mock card status API to return 404 (no card) - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - // Mock card access check API (supported country) - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock Bridge KYC link API with approved status - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/continue', - kyc_status: 'approved', - }), - }); - }); - - // Mock Bridge customer API with incomplete endorsement - missing address - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['first_name', 'last_name', 'email'], - pending: [], - missing: { - address: 'Full address is required', - ssn_last_4: 'Last 4 digits of SSN required', - }, - issues: [], - }, - }, - ], - }), - }); - }); - - // Navigate to activate page - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - - // Wait for UI to update - await page.waitForTimeout(2000); - - // Verify that "Complete KYC" step is visible - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // Order card button should NOT be enabled (endorsement not approved) - const orderButton = page.getByRole('button', { name: /Order.*card/i }); - if (await orderButton.isVisible()) { - await expect(orderButton).toBeDisabled(); - } - - await page.waitForTimeout(3000); - }); - - test('should display issues with ID document in endorsement requirements', async ({ page }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/retry', - kyc_status: 'approved', - }), - }); - }); - - // Mock with incomplete endorsement that has issues with ID document - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['first_name', 'last_name'], - pending: [], - missing: {}, - issues: [{ id_front_photo: 'id_expired' }, { id_back_photo: 'image_blurry' }], - }, - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify Complete KYC is shown (user needs to retry) - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - await page.waitForTimeout(3000); - }); - - test('should allow user to retry KYC when endorsement has region restriction issue', async ({ - page, - }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/retry', - kyc_status: 'approved', - }), - }); - }); - - // Mock with revoked endorsement due to region restriction - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'revoked', - requirements: { - complete: [], - pending: [], - missing: {}, - issues: ['endorsement_not_available_in_customers_region'], - }, - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // User should see Retry KYC button and can proceed (user may have another nationality) - await expect(page.getByText('Retry KYC').first()).toBeVisible({ timeout: 15000 }); - - await page.waitForTimeout(3000); - }); - - test('should allow Retry KYC button click for region-restricted user', async ({ page }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/retry', - kyc_status: 'approved', - }), - }); - }); - - // Mock with revoked endorsement due to region restriction - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'revoked', - requirements: { - complete: [], - pending: [], - missing: {}, - issues: ['endorsement_not_available_in_customers_region'], - }, - }, - ], - }), - }); - }); - - // Track if KYC link endpoint is called when button is clicked - let kycLinkRequested = false; - - // Mock IP detection API (ipify) - await page.route('**/api.ipify.org/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ ip: '8.8.8.8' }), // US IP - }); - }); - - // Mock country from IP API (ipapi.co) - await page.route('**/ipapi.co/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - country_code: 'US', - country_name: 'United States', - }), - }); - }); - - await page.route('**/accounts/v1/bridge-customer/kyc-link**', async route => { - kycLinkRequested = true; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - url: 'https://bridge.xyz/kyc/new-attempt', - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify "Retry KYC" button is visible and enabled - const retryButton = page.getByRole('button', { name: /Retry KYC/i }); - await expect(retryButton).toBeVisible({ timeout: 15000 }); - await expect(retryButton).toBeEnabled(); - - // Click the button - should proceed to KYC flow (not blocked by region restriction) - await retryButton.click(); - - // Verify flow proceeded: either KYC link was requested or user was redirected to collect info - if (!kycLinkRequested) { - await page.waitForURL('**/user-kyc-info**', { timeout: 10000 }); - } - - const redirectedToKycInfo = page.url().includes('user-kyc-info'); - expect(kycLinkRequested || redirectedToKycInfo).toBe(true); - }); - - test('should allow Complete KYC button click for incomplete endorsement with region restriction', async ({ - page, - }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/incomplete', - kyc_status: 'not_started', - }), - }); - }); - - // Mock with incomplete endorsement with region restriction AND missing requirements - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['full_name'], - pending: [], - missing: { address: 'Address is required' }, - issues: ['endorsement_not_available_in_customers_region'], - }, - }, - ], - }), - }); - }); - - // Track if KYC link endpoint is called when button is clicked - let kycLinkRequested = false; - - // Mock IP detection API (ipify) - await page.route('**/api.ipify.org/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ ip: '8.8.8.8' }), // US IP - }); - }); - - // Mock country from IP API (ipapi.co) - await page.route('**/ipapi.co/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - country_code: 'US', - country_name: 'United States', - }), - }); - }); - - await page.route('**/accounts/v1/bridge-customer/kyc-link**', async route => { - kycLinkRequested = true; - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - url: 'https://bridge.xyz/kyc/complete-info', - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify "Complete KYC" button is visible and enabled (not blocked by region restriction) - const completeButton = page.getByRole('button', { name: /Continue verification/i }); - await expect(completeButton).toBeVisible({ timeout: 15000 }); - await expect(completeButton).toBeEnabled(); - - // Click the button - should proceed to KYC flow - await completeButton.click(); - - // Verify flow proceeded - if (!kycLinkRequested) { - await page.waitForURL('**/user-kyc-info**', { timeout: 10000 }); - } - - const redirectedToKycInfo = page.url().includes('user-kyc-info'); - expect(kycLinkRequested || redirectedToKycInfo).toBe(true); - }); - - test('should show under review state and no actionable button when endorsement has pending items', async ({ - page, - }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/pending', - kyc_status: 'under_review', - }), - }); - }); - - // Mock with incomplete endorsement with pending review items - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['full_name', 'address'], - pending: ['document_verification', 'selfie_verification'], - missing: {}, - issues: [], - }, - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify under review UI is shown (user should wait, no action required) - await expect(page.getByText('Your card is on its way!')).toBeVisible({ timeout: 15000 }); - await expect(page.getByText(/being verified/i)).toBeVisible({ timeout: 5000 }); - - // Verify there's no "Complete KYC" or "Retry KYC" button (user cannot take action) - await expect(page.getByRole('button', { name: /Continue verification/i })).not.toBeVisible(); - await expect(page.getByRole('button', { name: /Retry KYC/i })).not.toBeVisible(); - }); - - test('should enable Retry KYC button for revoked endorsement without region restriction', async ({ - page, - }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/revoked', - kyc_status: 'rejected', - }), - }); - }); - - // Mock with revoked endorsement (expired, not region restricted) - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'revoked', - requirements: { - complete: ['full_name', 'address'], - pending: [], - missing: {}, - issues: ['id_expired'], - }, - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify "Retry KYC" button is visible and enabled - const retryButton = page.getByRole('button', { name: /Retry KYC/i }); - await expect(retryButton).toBeVisible({ timeout: 15000 }); - await expect(retryButton).toBeEnabled(); - }); - - test('should enable Complete KYC button despite missing requirements', async ({ page }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/incomplete', - kyc_status: 'not_started', - }), - }); - }); - - // Mock with incomplete endorsement with multiple missing requirements - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['full_name'], - pending: [], - missing: { - address: 'Address is required', - ssn_last_4: 'Last 4 digits of SSN required', - date_of_birth: 'Date of birth required', - }, - issues: [], - }, - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify missing requirements are displayed - await expect(page.getByText(/Missing/i)).toBeVisible({ timeout: 15000 }); - - // Verify "Complete KYC" button is visible and enabled (not disabled due to missing fields) - const completeButton = page.getByRole('button', { name: /Continue verification/i }); - await expect(completeButton).toBeVisible(); - await expect(completeButton).toBeEnabled(); - }); - - test('should show verification expired message for revoked endorsement with rejection reasons', async ({ - page, - }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/retry', - kyc_status: 'approved', - }), - }); - }); - - // Mock with revoked endorsement and customer-level rejection reasons - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'revoked', - requirements: { - complete: [], - pending: [], - missing: {}, - issues: [], - }, - }, - ], - rejection_reasons: [ - { - developer_reason: 'kyc_expired', - reason: 'Your verification has expired. Please complete the process again.', - created_at: new Date().toISOString(), - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify Complete KYC is shown for re-enrollment - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - await page.waitForTimeout(3000); - }); - - test('should display pending review when endorsement has pending document verification', async ({ - page, - }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/pending', - kyc_status: 'approved', - }), - }); - }); - - // Mock with incomplete endorsement but pending items (under internal review) - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['first_name', 'last_name', 'email', 'address'], - pending: ['document_verification', 'identity_check'], - missing: {}, - issues: [], - }, - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // With pending endorsement items, the "under review" UI should be shown - await expect(page.getByText('Your card is on its way!')).toBeVisible({ timeout: 15000 }); - await expect(page.getByText(/being verified/i)).toBeVisible({ timeout: 5000 }); - - // The step list is not shown in this state, so no "Order card" button - await page.waitForTimeout(3000); - }); - - test('should enable Order card button only when cards endorsement is approved', async ({ - page, - }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/approved', - kyc_status: 'approved', - }), - }); - }); - - // Mock with APPROVED cards endorsement - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'approved', - requirements: { - complete: ['first_name', 'last_name', 'email', 'address', 'document_verification'], - pending: [], - missing: {}, - issues: [], - }, - }, - ], - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify step 1 is complete - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // Order card button should be visible and enabled - const orderButton = page.getByRole('button', { name: /Order.*card/i }); - await expect(orderButton).toBeVisible(); - await expect(orderButton).toBeEnabled(); - - await page.waitForTimeout(3000); - }); - - test('should display combined missing and issues in incomplete endorsement', async ({ page }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - // Mock KYC link - status doesn't affect step description anymore - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/retry', - kyc_status: 'approved', - }), - }); - }); - - // Mock with incomplete endorsement that has both missing fields and issues - // Customer rejection_reasons take priority for display in step description - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['first_name', 'last_name'], - pending: [], - missing: { - address: 'Address is required', - }, - issues: [{ selfie_photo: 'face_not_detected' }], - }, - }, - ], - // Customer-level rejection reasons take priority for display - rejection_reasons: [ - { - developer_reason: 'selfie_failed', - reason: 'We could not detect a face in your selfie. Please retake the photo.', - created_at: new Date().toISOString(), - }, - ], - }), - }); - }); - - // Mock KYC link for existing customer - await page.route('**/accounts/v1/bridge-customer/kyc-link**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - url: 'https://bridge.xyz/kyc/cards-endorsement-retry', - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify Complete KYC step title is shown - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // Verify CUSTOMER rejection reasons are displayed in the step description - // (Customer rejection_reasons take priority over endorsement issues) - await expect( - page.getByText(/We could not detect a face in your selfie. Please retake the photo/i), - ).toBeVisible({ timeout: 5000 }); - - // Verify "Complete KYC" button is shown (for incomplete status with issues) - const kycButton = page.getByRole('button', { name: /Continue verification/i }); - await expect(kycButton).toBeVisible({ timeout: 5000 }); - - // Order card button should not be visible/enabled (endorsement not approved) - const orderButton = page.getByRole('button', { name: /Order.*card/i }); - if (await orderButton.isVisible()) { - await expect(orderButton).toBeDisabled(); - } - - await page.waitForTimeout(3000); - }); - - test('should display missing requirements when no rejection reasons', async ({ page }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/continue', - kyc_status: 'approved', - }), - }); - }); - - // Mock with incomplete endorsement that has missing fields but NO rejection reasons - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['first_name', 'last_name'], - pending: [], - missing: { - address: 'Address is required', - ssn_last_4: 'Last 4 digits of SSN required', - }, - issues: [], - }, - }, - ], - // No rejection_reasons - should fall back to showing missing fields - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify Complete KYC is shown - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // Verify missing fields are displayed in user-friendly format - // formatCodeToReadable converts "address" -> "Address", "ssn_last_4" -> "Ssn Last 4" - await expect(page.getByText(/Missing/i)).toBeVisible({ timeout: 5000 }); - await expect(page.getByText(/Address/i)).toBeVisible({ timeout: 5000 }); - - await page.waitForTimeout(3000); - }); - - test('should display endorsement issues when no rejection reasons', async ({ page }) => { - await page.route('**/accounts/v1/cards/status', async route => { - await route.fulfill({ - status: 404, - contentType: 'application/json', - body: JSON.stringify(null), - }); - }); - - await page.route('**/accounts/v1/cards/check-access**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - hasAccess: true, - countryCode: 'US', - }), - }); - }); - - await page.route('**/accounts/v1/cards/kyc/kyc-link-from-bridge/**', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'kyc_link_123', - kyc_link: 'https://bridge.xyz/kyc/retry', - kyc_status: 'approved', - }), - }); - }); - - // Mock with incomplete endorsement that has issues but NO rejection reasons - await page.route('**/accounts/v1/bridge-customer/get-customer-from-bridge', async route => { - await route.fulfill({ - status: 200, - contentType: 'application/json', - body: JSON.stringify({ - id: 'cust_123', - endorsements: [ - { - name: 'cards', - status: 'incomplete', - requirements: { - complete: ['first_name', 'last_name'], - pending: [], - missing: {}, - issues: [{ id_front_photo: 'id_expired' }, { selfie_photo: 'face_not_detected' }], - }, - }, - ], - // No rejection_reasons - should fall back to showing formatted issues - }), - }); - }); - - await page.goto('/card/activate?countryConfirmed=true'); - await page.waitForLoadState('networkidle'); - await page.waitForTimeout(2000); - - // Verify Complete KYC is shown - await expect(page.getByText('Complete KYC').first()).toBeVisible({ timeout: 15000 }); - - // Verify endorsement issues are displayed in user-friendly format - // formatEndorsementIssue converts { id_front_photo: "id_expired" } -> "Id Front Photo: Id Expired" - await expect(page.getByText(/Id Front Photo/i)).toBeVisible({ timeout: 5000 }); - await expect(page.getByText(/Id Expired/i)).toBeVisible({ timeout: 5000 }); - - await page.waitForTimeout(3000); + const activateButton = page.getByRole('button', { name: /Activate card/i }); + await expect(activateButton).toBeVisible(); + await expect(activateButton).toBeEnabled(); }); });