From f9572ef548da7846c2f47aad91c242cc9c24908a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:33:49 +0000 Subject: [PATCH 1/2] fix(card): show why activation failed on /card/activate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two separate reasons a user never learned why their card would not activate. First, `createCard` and `submitCardConsents` threw the bare `Response` on a non-2xx. The activation handler renders `error instanceof Error ? error.message : 'Something went wrong. Please try again.'`, and a Response is not an Error — so the backend's reason was discarded unread on every single failure, and everyone saw the fallback. That string is what users quoted back to support verbatim. Both now throw `toApiError`, which already exists for exactly this and carries `status`/`statusCode` so token refresh is unaffected. Second, /card/activate could only render `activationBlockedReason`, which the backend sends only alongside the sticky `activationBlocked` flag. Real issuance failures do not set that flag, so the screen showed nothing and the steps list fell back to "There was an issue activating your card. Please contact support." The backend now classifies each failure and returns `activationFailure` {code, reason, detail, terminal}. This renders it as a proper error state: the reason as a headline, the detail explaining what happened and what to do, a Contact support action, and the code as a reference support can quote back. `terminal` drives the difference that matters — an unsupported document country or an issuer decline stops offering the "Activate card" button, since pressing it again can only reproduce the same failure, while a provisioning delay or an upstream blip leaves it alone because retrying is the right move there. It also stops "your card is on its way" from covering a failure that no amount of waiting will clear. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qyutjUxPkoGD5qv8n4ZNP --- app/(protected)/(tabs)/card/activate.tsx | 2 + app/(protected)/(tabs)/card/ready.tsx | 8 ++ .../Card/ActivateCard/CardStatusBanner.tsx | 96 ++++++++++++++++++- hooks/useActivateCard.ts | 5 + hooks/useCardSteps/useCardSteps.ts | 13 ++- lib/api.ts | 12 ++- lib/types.ts | 26 +++++ lib/utils/__tests__/cardReviewState.test.ts | 31 ++++++ lib/utils/cardReviewState.ts | 8 ++ 9 files changed, 193 insertions(+), 8 deletions(-) diff --git a/app/(protected)/(tabs)/card/activate.tsx b/app/(protected)/(tabs)/card/activate.tsx index a747df2d0..a36c803e0 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 8adb5a3ae..28086612d 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/useCardSteps.ts b/hooks/useCardSteps/useCardSteps.ts index 7beb4c442..08b157500 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -434,8 +434,15 @@ 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, + cardStatusResponse?.activationFailure?.reason ?? + cardStatusResponse?.activationBlockedReason, handleProceedToKyc, pushCardReady, pushCardDetails, @@ -461,6 +468,8 @@ export function useCardSteps( cardActivated, cardStatusResponse?.activationBlocked, cardStatusResponse?.activationBlockedReason, + cardStatusResponse?.activationFailure?.terminal, + cardStatusResponse?.activationFailure?.reason, cardStatusResponse?.rainApplicationStatus, cardStatusResponse?.kycStatus, cardStatusResponse?.kycWarnings, diff --git a/lib/api.ts b/lib/api.ts index 4eee82488..60b5c05cd 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 ce93e4d28..a103996a0 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 fbd89ccab..700359097 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 1b35d6fa7..f29dcecba 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. From c9654829134aa3f146b764eb07e36bc45d49ec0b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 14 Sep 2026 13:48:23 +0000 Subject: [PATCH 2/2] fix(card): stop repeating the activation failure reason on the steps list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seer flagged the reason rendering twice on /card/activate, and it is right — and it predates this branch for the `activationBlocked` path. `CardStatusBanner` renders the reason as a headline with the detail and a support action. `buildCardSteps` then set that same string as the activate step's description, and `useStepNavigation` auto-expands the first incomplete step — which for a blocked applicant IS the activate step. So the sentence met the user twice on first paint, once as a headline and once four rows down. The banner owns the explanation. The step now carries a short line pointing at it, and `activationBlockedReason` is dropped from `buildCardSteps` rather than left as a parameter nothing reads. Its only other consumer, useHomeSetupSteps, writes its own descriptions and never read it. Adds coverage for all three behaviours of a blocked activation: no duplicated reason, no activate button (so it cannot reproduce the failure), and the button still offered when nothing is blocking. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_015qyutjUxPkoGD5qv8n4ZNP --- .../__tests__/buildCardSteps.test.ts | 42 +++++++++++++++++-- hooks/useCardSteps/stepHelpers.ts | 9 +++- hooks/useCardSteps/useCardSteps.ts | 4 -- 3 files changed, 46 insertions(+), 9 deletions(-) diff --git a/hooks/useCardSteps/__tests__/buildCardSteps.test.ts b/hooks/useCardSteps/__tests__/buildCardSteps.test.ts index 7f1139983..41cee84f8 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 e0d94ac7d..f6a93ecec 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 08b157500..dccb28310 100644 --- a/hooks/useCardSteps/useCardSteps.ts +++ b/hooks/useCardSteps/useCardSteps.ts @@ -441,8 +441,6 @@ export function useCardSteps( // failures (an issuer blip, provisioning still running) leave the button // alone on purpose — there, retrying is the right move. cardStatusResponse?.activationBlocked || cardStatusResponse?.activationFailure?.terminal, - cardStatusResponse?.activationFailure?.reason ?? - cardStatusResponse?.activationBlockedReason, handleProceedToKyc, pushCardReady, pushCardDetails, @@ -467,9 +465,7 @@ export function useCardSteps( customer?.rejection_reasons, cardActivated, cardStatusResponse?.activationBlocked, - cardStatusResponse?.activationBlockedReason, cardStatusResponse?.activationFailure?.terminal, - cardStatusResponse?.activationFailure?.reason, cardStatusResponse?.rainApplicationStatus, cardStatusResponse?.kycStatus, cardStatusResponse?.kycWarnings,