Skip to content

Commit 8fc859d

Browse files
committed
Add optimistic auth pending state, refactor code
1 parent eb0193c commit 8fc859d

8 files changed

Lines changed: 90 additions & 39 deletions

File tree

src/ONYXKEYS.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,9 @@ const ONYXKEYS = {
556556
/** Stores iframe link to verify 3DS flow for subscription */
557557
VERIFY_3DS_SUBSCRIPTION: 'verify3dsSubscription',
558558

559+
/** Screen name that initiated the current 3DS verification, so only that screen reacts to the link by navigating */
560+
VERIFY_3DS_SUBSCRIPTION_SOURCE: 'verify3dsSubscriptionSource',
561+
559562
/** Holds the checks used while transferring the ownership of the workspace */
560563
POLICY_OWNERSHIP_CHANGE_CHECKS: 'policyOwnershipChangeChecks',
561564

@@ -570,6 +573,9 @@ const ONYXKEYS = {
570573
/** Indicates whether ClearOutstandingBalance is pending */
571574
SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING: 'subscriptionRetryBillingStatusPending',
572575

576+
/** Indicates whether a VerifySetupIntent request is in flight (gates the Authenticate payment button) */
577+
SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING: 'subscriptionVerifySetupIntentPending',
578+
573579
/** Stores info during review duplicates flow */
574580
REVIEW_DUPLICATES: 'reviewDuplicates',
575581

@@ -1556,6 +1562,7 @@ type OnyxValuesMapping = {
15561562
[ONYXKEYS.MY_DOMAIN_SECURITY_GROUPS]: Record<string, string>;
15571563
[ONYXKEYS.RAM_ONLY_DOMAIN_MEMBERS_SELECTED_FOR_MOVE]: string[];
15581564
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION]: string;
1565+
[ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE]: string;
15591566
[ONYXKEYS.PREFERRED_THEME]: ValueOf<typeof CONST.THEME>;
15601567
[ONYXKEYS.SIGN_IN_HIGH_CONTRAST_INTENT]: boolean;
15611568
[ONYXKEYS.MAPBOX_ACCESS_TOKEN]: OnyxTypes.MapboxAccessToken;
@@ -1595,6 +1602,7 @@ type OnyxValuesMapping = {
15951602
[ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: boolean;
15961603
[ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: boolean;
15971604
[ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING]: boolean;
1605+
[ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING]: boolean;
15981606
[ONYXKEYS.NVP_TRAVEL_SETTINGS]: OnyxTypes.TravelSettings;
15991607
[ONYXKEYS.REVIEW_DUPLICATES]: OnyxTypes.ReviewDuplicates;
16001608
[ONYXKEYS.ADD_NEW_COMPANY_CARD]: OnyxTypes.AddNewCompanyCardFeed;

src/hooks/useNavigateToCardAuthenticationOnLink/index.tsx

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import {useIsFocused} from '@react-navigation/native';
1+
import {useIsFocused, useRoute} from '@react-navigation/native';
22
import {useEffect, useEffectEvent, useRef} from 'react';
33
import useOnyx from '@hooks/useOnyx';
44
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
@@ -8,22 +8,37 @@ import {DYNAMIC_ROUTES} from '@src/ROUTES';
88

99
/**
1010
* Navigates to the 3DS card-authentication screen when the backend issues a fresh SCA URL
11-
* (Onyx key `verify3dsSubscription`). Skips the mount-time invocation so a stale Onyx value
12-
* left over from a previous SCA attempt — or a truthy value present at mount during a racy
13-
* navigation — never triggers an auto-navigate; only subsequent changes to `authenticationLink`
14-
* (the genuine "user just submitted the card form, backend returned a fresh URL" flow) fire navigation.
11+
* (Onyx key `verify3dsSubscription`). Navigation fires only on a genuine change to the URL, never on
12+
* the value already present at mount. Skipping that mount-time run keeps two false positives inert:
13+
* a stale URL left over from a previous SCA attempt, and a truthy URL that just happens to be present
14+
* at mount during a racy navigation. Only a subsequent "backend just returned a fresh URL" change
15+
* triggers navigation.
16+
*
17+
* `verify3dsSubscription` is a single global Onyx key, but several screens mount this hook. To stop
18+
* one screen from hijacking a link another screen produced (e.g. the Subscription "Authenticate
19+
* payment" link opening over the add-payment-card page), the screen that kicks off a request passes
20+
* its own `route.name` to the producing action, which records it in `verify3dsSubscriptionSource`. A
21+
* mounted hook only reacts to a link when its own focused screen matches that source, so a link can
22+
* never be consumed by an unrelated screen the user has since navigated to.
1523
*/
16-
function useNavigateToCardAuthenticationOnLink(shouldNavigate = true) {
24+
function useNavigateToCardAuthenticationOnLink() {
1725
const [authenticationLink] = useOnyx(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION);
26+
const [source] = useOnyx(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE);
1827
const isFocused = useIsFocused();
28+
const route = useRoute();
1929
const firstRenderRef = useRef(true);
2030

2131
const navigateToCardAuthentication = useEffectEvent(() => {
32+
// The concrete reason for the mount-skip: on a reload/deep-link reconstruction the parent
33+
// remounts with the URL already in Onyx, so this effect's mount run would fire
34+
// createDynamicRoute() at the same moment React Navigation is still opening the screen. Those
35+
// two competing navigations left the stack in a broken state. Skipping the mount run lets the
36+
// reconstruction settle and only navigates on a later, genuine change of the link.
2237
if (firstRenderRef.current) {
2338
firstRenderRef.current = false;
2439
return;
2540
}
26-
if (!isFocused || !shouldNavigate || !authenticationLink) {
41+
if (!isFocused || route.name !== source || !authenticationLink) {
2742
return;
2843
}
2944
Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.CARD_AUTHENTICATION.path));

src/libs/actions/PaymentMethods.ts

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ function addSubscriptionPaymentCard(
224224
currency: ValueOf<typeof CONST.PAYMENT_CARD_CURRENCY>;
225225
},
226226
fundList: OnyxEntry<FundList>,
227+
source?: string,
227228
) {
228229
const {cardNumber, cardYear, cardMonth, cardCVV, addressName, addressZip, currency} = cardData;
229230

@@ -264,7 +265,7 @@ function addSubscriptionPaymentCard(
264265
];
265266

266267
if (CONST.SCA_CURRENCIES.has(currency)) {
267-
addPaymentCardSCA(parameters, {optimisticData, successData, failureData});
268+
addPaymentCardSCA(parameters, {optimisticData, successData, failureData}, source);
268269
} else {
269270
API.write(WRITE_COMMANDS.ADD_PAYMENT_CARD, parameters, {
270271
optimisticData,
@@ -279,11 +280,25 @@ function addSubscriptionPaymentCard(
279280
}
280281
}
281282

283+
/**
284+
* Records which screen initiated a 3DS verification request, so only that screen's mounted
285+
* useNavigateToCardAuthenticationOnLink hook reacts to the resulting link by navigating. Callers pass their own
286+
* `route.name`; passing nothing (e.g. the card-authentication screen re-verifying its own iframe in place) leaves
287+
* the existing source untouched, so a stray re-verify never clobbers the screen that originally opened the flow.
288+
*/
289+
function setVerify3dsSubscriptionSource(source?: string) {
290+
if (!source) {
291+
return;
292+
}
293+
Onyx.merge(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION_SOURCE, source);
294+
}
295+
282296
/**
283297
* Calls the API to add a new SCA (GBP or EUR) card.
284298
* Updates verify3dsSubscription Onyx key with a new authentication link for 3DS.
285299
*/
286-
function addPaymentCardSCA(params: AddPaymentCardParams, onyxData: OnyxData<typeof ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM> = {}) {
300+
function addPaymentCardSCA(params: AddPaymentCardParams, onyxData: OnyxData<typeof ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM> = {}, source?: string) {
301+
setVerify3dsSubscriptionSource(source);
287302
API.write(WRITE_COMMANDS.ADD_PAYMENT_CARD_SCA, params, onyxData);
288303
}
289304

@@ -307,20 +322,35 @@ function clearPaymentCardFormErrorAndSubmit() {
307322
});
308323
}
309324

310-
/**
311-
* Clear 3ds flow - when verification will be finished
312-
*
313-
*/
314-
function clearPaymentCard3dsVerification() {
315-
Onyx.set(ONYXKEYS.VERIFY_3DS_SUBSCRIPTION, '');
316-
}
317-
318325
/**
319326
* Properly updates the nvp_privateStripeCustomerID onyx data for 3DS payment
320327
*
321328
*/
322-
function verifySetupIntent(accountID: number, isVerifying = true) {
323-
API.write(WRITE_COMMANDS.VERIFY_SETUP_INTENT, {accountID, isVerifying});
329+
function verifySetupIntent(accountID: number, isVerifying = true, source?: string) {
330+
setVerify3dsSubscriptionSource(source);
331+
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING>> = [
332+
{
333+
onyxMethod: Onyx.METHOD.MERGE,
334+
key: ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING,
335+
value: true,
336+
},
337+
];
338+
const successData: Array<OnyxUpdate<typeof ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING>> = [
339+
{
340+
onyxMethod: Onyx.METHOD.MERGE,
341+
key: ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING,
342+
value: false,
343+
},
344+
];
345+
const failureData: Array<OnyxUpdate<typeof ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING>> = [
346+
{
347+
onyxMethod: Onyx.METHOD.MERGE,
348+
key: ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING,
349+
value: false,
350+
},
351+
];
352+
353+
API.write(WRITE_COMMANDS.VERIFY_SETUP_INTENT, {accountID, isVerifying}, {optimisticData, successData, failureData});
324354
}
325355

326356
/**
@@ -618,9 +648,9 @@ export {
618648
clearAddPaymentMethodError,
619649
clearWalletError,
620650
setPaymentMethodCurrency,
621-
clearPaymentCard3dsVerification,
622651
clearWalletTermsError,
623652
verifySetupIntent,
624653
addPaymentCardSCA,
654+
setVerify3dsSubscriptionSource,
625655
setInvoicingTransferBankAccount,
626656
};

src/libs/actions/Policy/Policy.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1445,6 +1445,7 @@ function addBillingCardAndRequestPolicyOwnerChange(
14451445
addressZip: string;
14461446
currency: string;
14471447
},
1448+
source?: string,
14481449
) {
14491450
if (!policyID) {
14501451
return;
@@ -1502,7 +1503,7 @@ function addBillingCardAndRequestPolicyOwnerChange(
15021503
currency: currency as ValueOf<typeof CONST.PAYMENT_CARD_CURRENCY>,
15031504
isP2PDebitCard: false,
15041505
};
1505-
PaymentMethods.addPaymentCardSCA(params);
1506+
PaymentMethods.addPaymentCardSCA(params, undefined, source);
15061507
} else {
15071508
const params: AddBillingCardAndRequestWorkspaceOwnerChangeParams = {
15081509
policyID,
@@ -1523,7 +1524,8 @@ function addBillingCardAndRequestPolicyOwnerChange(
15231524
* Properly updates the nvp_privateStripeCustomerID onyx data for 3DS payment
15241525
*
15251526
*/
1526-
function verifySetupIntentAndRequestPolicyOwnerChange(policyID: string, currentUserAccountID: number, currentUserEmail: string) {
1527+
function verifySetupIntentAndRequestPolicyOwnerChange(policyID: string, currentUserAccountID: number, currentUserEmail: string, source?: string) {
1528+
PaymentMethods.setVerify3dsSubscriptionSource(source);
15271529
const optimisticData: Array<OnyxUpdate<typeof ONYXKEYS.COLLECTION.POLICY>> = [
15281530
{
15291531
onyxMethod: Onyx.METHOD.MERGE,

src/pages/settings/Subscription/CardSection/CardSection.tsx

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {useRoute} from '@react-navigation/native';
12
import React, {useCallback, useEffect, useMemo, useState} from 'react';
23
import {View} from 'react-native';
34
import MenuItem from '@components/MenuItem';
@@ -46,6 +47,7 @@ import type {BillingStatusResult} from './utils';
4647
import CardSectionUtils from './utils';
4748

4849
function CardSection() {
50+
const route = useRoute();
4951
const {translate} = useLocalize();
5052
const styles = useThemeStyles();
5153
const expensifyIcons = useMemoizedLazyExpensifyIcons(['History', 'Bill', 'Close']);
@@ -60,14 +62,11 @@ function CardSection() {
6062
const hasTeam2025Pricing = useHasTeam2025Pricing();
6163
const subscriptionPlan = useSubscriptionPlan();
6264
const [subscriptionRetryBillingStatusPending] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_PENDING);
65+
const [isVerifyingSetupIntent] = useOnyx(ONYXKEYS.SUBSCRIPTION_VERIFY_SETUP_INTENT_PENDING);
6366
const [subscriptionRetryBillingStatusSuccessful] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL);
6467
const [subscriptionRetryBillingStatusFailed] = useOnyx(ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED);
6568
const {isOffline} = useNetwork();
6669
const defaultCard = useMemo(() => Object.values(fundList ?? {}).find((card) => card.accountData?.additionalData?.isBillingCard), [fundList]);
67-
const hasFailedLastBilling = useMemo(
68-
() => purchaseList?.[0]?.message.billingType === CONST.BILLING.TYPE_STRIPE_FAILED_AUTHENTICATION || purchaseList?.[0]?.message.billingType === CONST.BILLING.TYPE_FAILED_2018,
69-
[purchaseList],
70-
);
7170
const [firstDayFreeTrial] = useOnyx(ONYXKEYS.NVP_FIRST_DAY_FREE_TRIAL);
7271
const [lastDayFreeTrial] = useOnyx(ONYXKEYS.NVP_LAST_DAY_FREE_TRIAL);
7372
const [billingDisputePending] = useOnyx(ONYXKEYS.NVP_PRIVATE_BILLING_DISPUTE_PENDING);
@@ -182,11 +181,10 @@ function CardSection() {
182181
clearOutstandingBalance();
183182
};
184183

185-
const shouldNavigateToCardAuthentication = privateStripeCustomerID?.status === CONST.STRIPE_SCA_AUTH_STATUSES.CARD_AUTHENTICATION_REQUIRED || hasFailedLastBilling;
186-
useNavigateToCardAuthenticationOnLink(shouldNavigateToCardAuthentication);
184+
useNavigateToCardAuthenticationOnLink();
187185

188186
const handleAuthenticatePayment = () => {
189-
verifySetupIntent(session?.accountID ?? CONST.DEFAULT_NUMBER_ID, false);
187+
verifySetupIntent(session?.accountID ?? CONST.DEFAULT_NUMBER_ID, false, route.name);
190188
};
191189

192190
const handleBillingBannerClose = () => {
@@ -251,7 +249,7 @@ function CardSection() {
251249
<CardSectionButton
252250
text={translate('subscription.cardSection.authenticatePayment')}
253251
isDisabled={isOffline || !billingStatus?.isAuthenticationRequired}
254-
isLoading={subscriptionRetryBillingStatusPending}
252+
isLoading={isVerifyingSetupIntent}
255253
onPress={handleAuthenticatePayment}
256254
style={[styles.w100, styles.mt5]}
257255
large

src/pages/settings/Subscription/DynamicCardAuthenticationPage/index.tsx renamed to src/pages/settings/Subscription/DynamicCardAuthenticationPage.tsx

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import useLocalize from '@hooks/useLocalize';
88
import Navigation from '@libs/Navigation/Navigation';
99
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
1010
import type {AuthScreensParamList} from '@libs/Navigation/types';
11-
import {clearPaymentCard3dsVerification, verifySetupIntent} from '@userActions/PaymentMethods';
11+
import {verifySetupIntent} from '@userActions/PaymentMethods';
1212
import {verifySetupIntentAndRequestPolicyOwnerChange} from '@userActions/Policy/Policy';
1313
import {DYNAMIC_ROUTES} from '@src/ROUTES';
1414
import type SCREENS from '@src/SCREENS';
@@ -28,11 +28,7 @@ function DynamicCardAuthenticationPage({route}: DynamicCardAuthenticationPagePro
2828
}
2929
verifySetupIntent(currentUserAccountID, true);
3030
};
31-
// Mirrors the original CardAuthenticationModal.onModalClose semantic:
32-
// user-driven close discards VERIFY_3DS_SUBSCRIPTION so the next mount
33-
// of an entry screen is fresh.
3431
const onClose = () => {
35-
clearPaymentCard3dsVerification();
3632
Navigation.goBack(backPath);
3733
};
3834

src/pages/settings/Subscription/PaymentCard/index.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,9 +91,9 @@ function AddPaymentCard() {
9191
addressZip: values.addressZipCode,
9292
currency: values.currency ?? CONST.PAYMENT_CARD_CURRENCY.USD,
9393
};
94-
addSubscriptionPaymentCard(accountID ?? CONST.DEFAULT_NUMBER_ID, cardData, fundList);
94+
addSubscriptionPaymentCard(accountID ?? CONST.DEFAULT_NUMBER_ID, cardData, fundList, route.name);
9595
},
96-
[accountID, fundList],
96+
[accountID, fundList, route.name],
9797
);
9898

9999
const [formData] = useOnyx(ONYXKEYS.FORMS.ADD_PAYMENT_CARD_FORM);

src/pages/workspace/members/WorkspaceOwnerPaymentCardForm.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import {useRoute} from '@react-navigation/native';
12
import React, {useCallback, useEffect, useState} from 'react';
23
import {View} from 'react-native';
34
import type {OnyxEntry} from 'react-native-onyx';
@@ -27,6 +28,7 @@ type WorkspaceOwnerPaymentCardFormProps = {
2728
};
2829

2930
function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormProps) {
31+
const route = useRoute();
3032
const {translate} = useLocalize();
3133
const theme = useTheme();
3234
const styles = useThemeStyles();
@@ -72,9 +74,9 @@ function WorkspaceOwnerPaymentCardForm({policy}: WorkspaceOwnerPaymentCardFormPr
7274
addressZip: values.addressZipCode,
7375
currency: values.currency,
7476
};
75-
addBillingCardAndRequestPolicyOwnerChange(policyID, currentUserAccountID, currentUserEmail, cardData);
77+
addBillingCardAndRequestPolicyOwnerChange(policyID, currentUserAccountID, currentUserEmail, cardData, route.name);
7678
},
77-
[currentUserAccountID, currentUserEmail, policyID],
79+
[currentUserAccountID, currentUserEmail, policyID, route.name],
7880
);
7981
const icons = useMemoizedLazyExpensifyIcons(['Checkmark']);
8082

0 commit comments

Comments
 (0)