diff --git a/app/(protected)/(tabs)/card/activate.tsx b/app/(protected)/(tabs)/card/activate.tsx index a747df2d..a36c803e 100644 --- a/app/(protected)/(tabs)/card/activate.tsx +++ b/app/(protected)/(tabs)/card/activate.tsx @@ -20,6 +20,7 @@ export default function ActivateMobile() { isCardBlocked, isUnderReview, activationBlockedReason, + activationFailure, steps, activeStepId, isStepButtonEnabled, @@ -54,6 +55,7 @@ export default function ActivateMobile() { isPending={isCardPending} isBlocked={isCardBlocked} blockedReason={activationBlockedReason} + failure={activationFailure} /> = { + COUNTRY_NOT_SUPPORTED: + 'Our card issuer does not operate in the country your verified documents are registered in. Retrying will not change this, and your savings are unaffected.', + DOCUMENT_COUNTRY_MISMATCH: + 'The country on your verified documents decides card eligibility, and it cannot be changed by re-selecting a country in the app. Your savings are unaffected.', + VERIFICATION_REQUIRED: + 'Our card issuer needs a further verification step before it can open a card account.', + MISSING_VERIFIED_NAME: 'This has to be corrected on our side — retrying will not fix it.', + INVALID_PROFILE_DATA: 'This has to be corrected on our side — retrying will not fix it.', + ISSUER_DECLINED: + 'This is a problem at our card issuer, not with your account or your verification. We are already tracking it.', + ACTIVATION_PENDING: 'This page updates itself — there is no need to keep retrying.', + TEMPORARY_FAILURE: 'Please try again in a few minutes.', +}; + +export function CardStatusBanner({ + isPending, + isBlocked, + blockedReason, + failure, +}: CardStatusBannerProps) { + // A recorded failure outranks the pending state: a card that is "on its way" + // and an issuance that just failed are the same screen, and the failure is + // the newer fact. + if (failure) { + const selfService = SELF_SERVICE_CODES.has(failure.code); + const detail = failure.detail || FALLBACK_DETAIL_BY_CODE[failure.code]; + + return ( + + {failure.reason} + + {!!detail && {detail}} + + {!selfService && ( + openSupportDrawer()} + className="mt-4 self-start rounded-lg bg-white/10 px-4 py-2" + > + Contact support + + )} + + {/* The code is what support asks for first; showing it saves a round trip. */} + Reference: {failure.code} + + ); + } + if (isPending) { return ( @@ -22,9 +99,20 @@ export function CardStatusBanner({ isPending, isBlocked, blockedReason }: CardSt if (isBlocked) { return ( - + Card activation rejected - {blockedReason} + {blockedReason} + openSupportDrawer()} + className="mt-4 self-start rounded-lg bg-white/10 px-4 py-2" + > + Contact support + ); } diff --git a/hooks/useActivateCard.ts b/hooks/useActivateCard.ts index 8adb5a3a..28086612 100644 --- a/hooks/useActivateCard.ts +++ b/hooks/useActivateCard.ts @@ -36,6 +36,10 @@ export function useActivateCard() { const activationBlockedReason = cardStatusResponse?.activationBlockedReason || 'There was an issue activating your card. Please contact support.'; + // The classified failure, when the server could name one. Present for the + // failures that never set the sticky `activationBlocked` flag — which is most + // of them, and exactly the ones this screen used to say nothing about. + const activationFailure = cardStatusResponse?.activationFailure; // Whether verification is in and a decision is pending, for either live // issuer — Rain/Didit's application status and the backend kycStatus the @@ -132,6 +136,7 @@ export function useActivateCard() { isCardBlocked, isUnderReview, activationBlockedReason, + activationFailure, // Step management steps, activeStepId, diff --git a/hooks/useCardSteps/__tests__/buildCardSteps.test.ts b/hooks/useCardSteps/__tests__/buildCardSteps.test.ts index 7f113998..41cee84f 100644 --- a/hooks/useCardSteps/__tests__/buildCardSteps.test.ts +++ b/hooks/useCardSteps/__tests__/buildCardSteps.test.ts @@ -3,21 +3,22 @@ import { CardProvider, KycStatus, RainApplicationStatus } from '@/lib/types'; const noop = () => {}; -type Options = Parameters[8]; +type Options = Parameters[7]; const build = ({ cardActivated = false, + activationBlocked, options, }: { cardActivated?: boolean; + activationBlocked?: boolean; options?: Options; } = {}) => buildCardSteps( undefined, // cardsEndorsement undefined, // customerRejectionReasons cardActivated, - undefined, // activationBlocked - undefined, // activationBlockedReason + activationBlocked, noop, // handleProceedToKyc noop, // pushCardReady noop, // pushCardDetails @@ -238,3 +239,38 @@ describe('buildCardSteps - deposit-and-hold step', () => { expect(steps.map(s => s.key)).toEqual(['deposit', 'kyc', 'activate', 'spend']); }); }); + +describe('buildCardSteps - a blocked activation', () => { + it('does not repeat the failure reason in the step description', () => { + // CardStatusBanner renders the reason directly above this list, and + // useStepNavigation auto-expands the first incomplete step — the activate + // step — so carrying the reason here showed it twice on first paint. + const activate = build({ + activationBlocked: true, + options: { depositRequired: false, kycStatus: KycStatus.APPROVED }, + }).find(s => s.key === 'activate'); + + expect(activate?.description).toBe( + 'On hold — see the message above for what happened and what to do next.', + ); + }); + + it('withdraws the activate action, so the button cannot reproduce the failure', () => { + const activate = build({ + activationBlocked: true, + options: { depositRequired: false, kycStatus: KycStatus.APPROVED }, + }).find(s => s.key === 'activate'); + + expect(activate?.buttonText).toBeUndefined(); + expect(activate?.onPress).toBeUndefined(); + }); + + it('still offers the action when nothing is blocking', () => { + const activate = build({ + options: { depositRequired: false, kycStatus: KycStatus.APPROVED }, + }).find(s => s.key === 'activate'); + + expect(activate?.buttonText).toBe('Activate card'); + expect(activate?.onPress).toBeDefined(); + }); +}); diff --git a/hooks/useCardSteps/stepHelpers.ts b/hooks/useCardSteps/stepHelpers.ts index e0d94ac7..f6a93ece 100644 --- a/hooks/useCardSteps/stepHelpers.ts +++ b/hooks/useCardSteps/stepHelpers.ts @@ -29,7 +29,6 @@ export function buildCardSteps( customerRejectionReasons: BridgeRejectionReason[] | undefined, cardActivated: boolean, activationBlocked: boolean | undefined, - activationBlockedReason: string | undefined, handleProceedToKyc: () => void, pushCardReady: () => void, pushCardDetails: () => void, @@ -97,8 +96,14 @@ export function buildCardSteps( options?.kycStatus === KycStatus.APPROVED || cardsEndorsement?.status === EndorsementStatus.APPROVED; + // Deliberately does NOT repeat the failure reason. `CardStatusBanner` already + // carries it — as a headline, with the detail and a support action — and + // /card/activate is the only screen that renders these descriptions, always + // with that banner directly above this list. Worse, `useStepNavigation` + // auto-expands the first incomplete step, which for a blocked applicant IS + // this one, so the same sentence met the user twice on first paint. const orderCardDesc = activationBlocked - ? activationBlockedReason || 'There was an issue activating your card. Please contact support.' + ? 'On hold — see the message above for what happened and what to do next.' : 'All is set! Click on "Activate card" to review the agreements and issue your new card.'; const kycStepOnPress = diff --git a/hooks/useCardSteps/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index 7beb4c44..dccb2831 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -434,8 +434,13 @@ export function useCardSteps( cardsEndorsement, customer?.rejection_reasons, cardActivated, - cardStatusResponse?.activationBlocked, - cardStatusResponse?.activationBlockedReason, + // A terminal issuance failure gates the activate step exactly like the + // sticky block does: pressing "Activate card" on an unsupported country + // or an issuer decline can only reproduce the same failure, and that + // retry loop is what the support tickets are made of. Non-terminal + // failures (an issuer blip, provisioning still running) leave the button + // alone on purpose — there, retrying is the right move. + cardStatusResponse?.activationBlocked || cardStatusResponse?.activationFailure?.terminal, handleProceedToKyc, pushCardReady, pushCardDetails, @@ -460,7 +465,7 @@ export function useCardSteps( customer?.rejection_reasons, cardActivated, cardStatusResponse?.activationBlocked, - cardStatusResponse?.activationBlockedReason, + cardStatusResponse?.activationFailure?.terminal, cardStatusResponse?.rainApplicationStatus, cardStatusResponse?.kycStatus, cardStatusResponse?.kycWarnings, diff --git a/lib/api.ts b/lib/api.ts index 4eee8248..60b5c05c 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -663,7 +663,10 @@ export const submitCardConsents = async (consents: { body: JSON.stringify(consents), }); - if (!response.ok) throw response; + // Same reason as `createCard` below: this runs inside the same activation + // try-block, so a bare Response here is rendered as the generic + // "Something went wrong. Please try again." too. + if (!response.ok) throw await toApiError(response, 'Failed to record your card agreements'); return response.json(); }; @@ -1050,7 +1053,12 @@ export const createCard = async (): Promise => { credentials: 'include', }); - if (!response.ok) throw response; + // Throwing the bare Response here is why every activation failure reached the + // user as "Something went wrong. Please try again.": the caller renders + // `error instanceof Error ? error.message : `, and a Response is not + // an Error, so the backend's reason — "Cards are not available in Bangladesh + // (BD) yet.", "KYC is not approved." — was discarded unread every time. + if (!response.ok) throw await toApiError(response, 'Failed to activate your card'); return response.json(); }; diff --git a/lib/types.ts b/lib/types.ts index ce93e4d2..a103996a 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -543,11 +543,37 @@ export interface KycWarning { node_id?: string; } +/** + * Why the last card activation attempt did not produce a card. + * + * `activationBlockedReason` only ever arrived alongside the sticky + * `activationBlocked` flag, so the failures that do NOT set it — an unsupported + * document country, a name the issuer could not read, an issuer-side decline — + * reached this client as nothing at all, and the card screen fell back to + * "There was an issue activating your card. Please contact support." + */ +export interface CardActivationFailure { + /** Server-side `CardActivationBlockCode`. Branch on this, not on prose. */ + code: string; + /** One-line headline, already written for the user. */ + reason: string; + /** What happened and what to do about it. */ + detail?: string; + /** False when retrying is genuinely worth a try; true when it cannot help. */ + terminal: boolean; + occurredAt?: string | null; +} + export interface CardStatusResponse { status?: CardStatus; activationBlocked?: boolean; activationBlockedReason?: string; activationFailedAt?: string; + /** + * The last activation failure, classified. Present whether or not + * `activationBlocked` is set — see {@link CardActivationFailure}. + */ + activationFailure?: CardActivationFailure; /** Set by backend when available; used to branch Bridge vs Rain flows */ provider?: CardProvider; /** Internal KYC status (covers Didit rejection before Rain is reached) */ diff --git a/lib/utils/__tests__/cardReviewState.test.ts b/lib/utils/__tests__/cardReviewState.test.ts index fbd89cca..70035909 100644 --- a/lib/utils/__tests__/cardReviewState.test.ts +++ b/lib/utils/__tests__/cardReviewState.test.ts @@ -84,6 +84,37 @@ describe('isCardIssuanceUnderReview', () => { ).toBe(false); }); + it('yields to a terminal issuance failure, which no waiting will clear', () => { + // Most of these never set `activationBlocked` — that flag is sticky and + // raised by hand — so without this the reason would sit behind "your card + // is on its way" indefinitely. + expect( + isUnderReview({ + kycStatus: KycStatus.UNDER_REVIEW, + activationFailure: { + code: 'COUNTRY_NOT_SUPPORTED', + reason: 'Cards are not available in Bangladesh (BD) yet.', + terminal: true, + }, + }), + ).toBe(false); + }); + + it('still holds while a non-terminal failure is retryable', () => { + // An issuer blip or provisioning still running: the card really is on its + // way, and the screen should keep saying so. + expect( + isUnderReview({ + kycStatus: KycStatus.UNDER_REVIEW, + activationFailure: { + code: 'TEMPORARY_FAILURE', + reason: 'Our card issuer is not responding right now.', + terminal: false, + }, + }), + ).toBe(true); + }); + it('yields to an application parked on its deposit', () => { // "Top up and hold your $X" is the user's move; it lives on the steps list. expect( diff --git a/lib/utils/cardReviewState.ts b/lib/utils/cardReviewState.ts index 1b35d6fa..f29dcecb 100644 --- a/lib/utils/cardReviewState.ts +++ b/lib/utils/cardReviewState.ts @@ -53,6 +53,14 @@ export function isCardIssuanceUnderReview({ // its way would never go looking for it. if (cardStatus?.activationBlocked) return false; + // An issuance attempt that failed for a reason nothing on this screen can + // clear — an unsupported document country, an issuer decline. Same argument + // as the blocked case above, and it needs saying separately because most of + // these never set `activationBlocked`: that flag is sticky and is only ever + // raised by hand, so a terminal failure would otherwise be hidden behind + // "your card is on its way" indefinitely. + if (cardStatus?.activationFailure?.terminal) return false; + // Verification passed but the application is parked because the deposit is no // longer held. The "top up and hold" step is the user's move, and it lives on // the steps list.