Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/(protected)/(tabs)/card/activate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export default function ActivateMobile() {
isCardBlocked,
isUnderReview,
activationBlockedReason,
activationFailure,
steps,
activeStepId,
isStepButtonEnabled,
Expand Down Expand Up @@ -54,6 +55,7 @@ export default function ActivateMobile() {
isPending={isCardPending}
isBlocked={isCardBlocked}
blockedReason={activationBlockedReason}
failure={activationFailure}
/>
<CardActivationStepsList
steps={steps}
Expand Down
8 changes: 8 additions & 0 deletions app/(protected)/(tabs)/card/ready.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,9 +293,17 @@ export default function CardReady() {
router.replace(path.CARD_ACTIVATE);
} catch (error) {
console.error('Error activating card:', error);
// The backend records why, and the issuance screen renders the full
// explanation from it. Refetch so that reason is there the moment the user
// looks, rather than a poll-interval later.
queryClient.invalidateQueries({ queryKey: [CARD_STATUS_QUERY_KEY] });
Toast.show({
type: 'error',
text1: 'Error activating card',
// Now reaches the real reason — "Cards are not available in Bangladesh
// (BD) yet." rather than the generic line users quoted back to support —
// because the API client throws an ApiError carrying the server message
// instead of the bare Response, which is never an `Error`.
text2: error instanceof Error ? error.message : 'Something went wrong. Please try again.',
props: { badgeText: '' },
});
Expand Down
96 changes: 92 additions & 4 deletions components/Card/ActivateCard/CardStatusBanner.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,91 @@
import { View } from 'react-native';
import { Pressable, View } from 'react-native';

import { Text } from '@/components/ui/text';
import { openSupportDrawer } from '@/store/useSupportDrawerStore';

import type { CardActivationFailure } from '@/lib/types';

interface CardStatusBannerProps {
isPending: boolean;
isBlocked: boolean;
blockedReason: string;
/** The classified failure, when the server could name one. */
failure?: CardActivationFailure;
}

export function CardStatusBanner({ isPending, isBlocked, blockedReason }: CardStatusBannerProps) {
/**
* A blocker the user is expected to fix themselves rather than escalate.
*
* Everything else — an unsupported country, an issuer decline, a name the
* issuer cannot read — is ours to resolve, so the support action is the point
* of the card rather than a footnote.
*/
const SELF_SERVICE_CODES = new Set(['ACTIVATION_PENDING', 'TEMPORARY_FAILURE']);

/**
* Fallback explanations, used only when the server sent a reason with no detail.
*
* The server writes the full explanation and this screen renders it, so these
* are not the normal path — they cover rows recorded before failures were
* classified, which carry prose and nothing else. Duplicating the detail here
* would print the same sentence twice.
*/
const FALLBACK_DETAIL_BY_CODE: Record<string, string> = {
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 (
<View
accessibilityRole="alert"
className={`mb-4 rounded-xl border p-4 ${
selfService ? 'border-yellow-500/30' : 'border-red-500/30'
} bg-[#1C1C1C]`}
>
<Text className="text-base font-semibold text-white">{failure.reason}</Text>

{!!detail && <Text className="mt-2 text-sm leading-5 text-white/70">{detail}</Text>}

{!selfService && (
<Pressable
accessibilityRole="button"
accessibilityLabel="Contact support about card activation"
onPress={() => openSupportDrawer()}
className="mt-4 self-start rounded-lg bg-white/10 px-4 py-2"
>
<Text className="text-sm font-semibold text-white">Contact support</Text>
</Pressable>
)}

{/* The code is what support asks for first; showing it saves a round trip. */}
<Text className="mt-3 text-xs text-white/40">Reference: {failure.code}</Text>
</View>
);
}

if (isPending) {
return (
<View className="mb-4 rounded-xl border border-yellow-500/30 bg-[#1C1C1C] p-4">
Expand All @@ -22,9 +99,20 @@ export function CardStatusBanner({ isPending, isBlocked, blockedReason }: CardSt

if (isBlocked) {
return (
<View className="mb-4 rounded-xl border border-red-500/30 bg-[#1C1C1C] p-4">
<View
accessibilityRole="alert"
className="mb-4 rounded-xl border border-red-500/30 bg-[#1C1C1C] p-4"
>
<Text className="text-base font-semibold text-white">Card activation rejected</Text>
<Text className="mt-2 text-sm text-white/70">{blockedReason}</Text>
<Text className="mt-2 text-sm leading-5 text-white/70">{blockedReason}</Text>
<Pressable
accessibilityRole="button"
accessibilityLabel="Contact support about card activation"
onPress={() => openSupportDrawer()}
className="mt-4 self-start rounded-lg bg-white/10 px-4 py-2"
>
<Text className="text-sm font-semibold text-white">Contact support</Text>
</Pressable>
</View>
);
}
Expand Down
5 changes: 5 additions & 0 deletions hooks/useActivateCard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -132,6 +136,7 @@ export function useActivateCard() {
isCardBlocked,
isUnderReview,
activationBlockedReason,
activationFailure,
// Step management
steps,
activeStepId,
Expand Down
42 changes: 39 additions & 3 deletions hooks/useCardSteps/__tests__/buildCardSteps.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,22 @@ import { CardProvider, KycStatus, RainApplicationStatus } from '@/lib/types';

const noop = () => {};

type Options = Parameters<typeof buildCardSteps>[8];
type Options = Parameters<typeof buildCardSteps>[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
Expand Down Expand Up @@ -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();
});
});
9 changes: 7 additions & 2 deletions hooks/useCardSteps/stepHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ export function buildCardSteps(
customerRejectionReasons: BridgeRejectionReason[] | undefined,
cardActivated: boolean,
activationBlocked: boolean | undefined,
activationBlockedReason: string | undefined,
handleProceedToKyc: () => void,
pushCardReady: () => void,
pushCardDetails: () => void,
Expand Down Expand Up @@ -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 =
Expand Down
11 changes: 8 additions & 3 deletions hooks/useCardSteps/useCardSteps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -460,7 +465,7 @@ export function useCardSteps(
customer?.rejection_reasons,
cardActivated,
cardStatusResponse?.activationBlocked,
cardStatusResponse?.activationBlockedReason,
cardStatusResponse?.activationFailure?.terminal,
cardStatusResponse?.rainApplicationStatus,
cardStatusResponse?.kycStatus,
cardStatusResponse?.kycWarnings,
Expand Down
12 changes: 10 additions & 2 deletions lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
};
Expand Down Expand Up @@ -1050,7 +1053,12 @@ export const createCard = async (): Promise<CardResponse> => {
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 : <fallback>`, 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();
};
Expand Down
26 changes: 26 additions & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) */
Expand Down
31 changes: 31 additions & 0 deletions lib/utils/__tests__/cardReviewState.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading