Reputation: 5540
I'm developing payment system of our web application. We propose a free Basic plan (0 USD/month) and a paid Pro plan (5.99 USD/month). People need to pay for the first month when they subscribe to the Pro plan, then pay every following month. The following code works with the stripe test card 4242424242424242. But with a real bank card in live mode, it raised an error of incomplete subscription update 3D Secure attempt incomplete
and The cardholder began 3D Secure authentication but has not completed it.
. So I need to integrate SCA exemptions and 3D Secure authentication into the subscription payment.
I have found some youtube videos like this. They propose to create a paymentIntent
in the backend, then send a client secret to the frontend, then launch confirmCardPayment
in the frontend. But for subscription, e.g., in my backend code, where should I create such a paymentIntent for subscription update?
Does anyone know what's the conventional way to enable 3D Secure authentication in stripe subscription recurrent payment in 2023?
Code:
This is the code in the backend when a user subscribes to a Pro plan. We find the subscription item with the Basic plan, then we update the subscription with the Pro plan:
router.post(
"/httpOnly/stripe/subscriptions/upgrade",
loginCheckCallback,
plansCheckCallback,
async (req, res) => {
const user = res.locals.user;
const plans = res.locals.plans;
const source = req.body.source;
try {
const customer = await getOrCreateCustomer(user);
// Retrieve the customer's active subscription
const subscriptions = await stripe.subscriptions.list({
customer: customer.id,
status: 'active',
});
let subscription;
// Find the subscription with the desired metadata label value
subscription = subscriptions.data.find((sub) => {
return sub.metadata.label === "Store";
});
if (subscription === undefined) {
// Create a new subscription with the Pro plan
subscription = await stripe.subscriptions.create({
customer: customer.id,
items: plans.map(x => ({ plan: x.id })),
source,
metadata: { label: 'Store' },
});
} else {
// Update the customer's payment method
await stripe.customers.update(customer.id, {
source: source,
});
const subscriptionItem = subscription.items.data.find((item) => {
return [
getPriceID("Basic, 0.00 USD/month")
].includes(item.price.id);
});
// Update the existing subscription to the Pro plan
subscription = await stripe.subscriptions.update(subscription.id, {
items: plans.map(x => ({ id: subscriptionItem.id, plan: x.id })),
billing_cycle_anchor: 'now', // Set the new billing cycle to start from the date of the upgrade
proration_behavior: 'none',
cancel_at_period_end: false,
});
}
res.json({ subscription, created: true });
} catch (e) {
console.log(e);
res
.status(e.statusCode || 500)
.json({ message: "error when create subscriptions: " + e.message });
}
}
);
This is StripeCheckoutForm/index.tsx
in the frontend:
import React, { Component } from 'react';
import {
injectStripe,
ReactStripeElements,
CardCVCElement,
CardExpiryElement,
CardNumberElement,
} from 'react-stripe-elements';
import { Stack, PrimaryButton, Spinner, Text } from 'office-ui-fabric-react';
const createOptions = () => {
return {
style: {
base: {
fontSize: '16px',
color: '#424770',
'::placeholder': {
color: '#aab7c4',
},
},
invalid: {
color: '#c23d4b',
},
},
};
};
interface IState {
isHandling: boolean;
errorMessage?: string;
}
interface StripeProps extends ReactStripeElements.StripeProps {
handleCardAction(clientSecret: string, options?: any): Promise<any>;
}
interface InjectedStripeProps extends ReactStripeElements.InjectedStripeProps {
confirmMessage?: string;
handleToken(token: stripe.Token): void | Promise<void>;
stripe: StripeProps | null;
}
class StripeCheckoutForm extends Component<InjectedStripeProps, IState> {
readonly state: IState = {
isHandling: false,
errorMessage: undefined,
};
handleChange = ({ error }: { error?: stripe.Error | undefined }) => {
if (error) {
this.setState({ errorMessage: error.message });
}
};
handleSubmit = async (ev: any) => {
ev.preventDefault();
this.setState({ isHandling: true }, async () => {
if (!this.props.stripe) {
console.log('nostripe');
return;
}
try {
const { token, error } = await this.props.stripe.createToken();
if (error) {
this.setState({ errorMessage: error.message });
} else {
if (token) {
await this.props.handleToken(token);
} else {
this.setState({ errorMessage: 'no token created' });
}
}
} catch (e: any) {
this.setState({ errorMessage: e.message });
}
this.setState({ isHandling: false });
});
};
render() {
const { isHandling } = this.state;
return (
<form style={{ width: 280 }} onSubmit={this.handleSubmit.bind(this)}>
<div className="split-form">
<label style={{ minHeight: 45, minWidth: 200 }}>
<Text variant="large">Card number</Text>
<CardNumberElement {...createOptions()} onChange={this.handleChange} />
</label>
</div>
<Stack className="split-form" horizontal>
<label style={{ minHeight: 45, minWidth: 140 }}>
<Text variant="large">Expiration date</Text>
<CardExpiryElement {...createOptions()} onChange={this.handleChange} />
</label>
<label style={{ marginLeft: 40, minHeight: 45, minWidth: 80 }}>
<Text variant="large">CVC</Text>
<CardCVCElement {...createOptions()} onChange={this.handleChange} />
</label>
</Stack>
<div className="error" role="alert">
{this.state.errorMessage}
</div>
<Stack styles={{ root: { marginTop: 10 } }}>
{isHandling ? (
<PrimaryButton disabled>
<Spinner></Spinner>
</PrimaryButton>
) : (
<PrimaryButton type="submit" style={{ width: '100%' }}>
{this.props.confirmMessage || 'Pay'}
</PrimaryButton>
)}
</Stack>
</form>
);
}
}
export default injectStripe(StripeCheckoutForm);
And SubscribePay/index.tsx
in the frontend:
import React from 'react';
import { connect } from 'dva';
import { State as ReduxState } from '../../../store/reducer';
import { StripeProvider, Elements } from 'react-stripe-elements';
import { ContentCard } from '../common';
import { Card } from '@uifabric/react-cards';
import StripeCheckoutForm from '../StripeCheckoutForm';
import { Dispatch } from 'redux';
import selectors from '../../../selectors';
import { Plan } from '../../../services/subscription';
interface Props {
dispatch: Dispatch<any>;
selectedPlans: Plan[];
goCompleted: any;
confirmMessage: string;
}
interface State {
}
class SubscribePay extends React.Component<Props, State> {
onToken = async (plans: Plan[], token: stripe.Token) => {
this.props.dispatch({
type: 'subscription/subscribe',
payload: {
plans,
source: token.id,
},
});
this.props.goCompleted();
};
render() {
const { selectedPlans, confirmMessage } = this.props;
if (!selectedPlans.length) {
return <div>There is no selectedPlans. Go Back</div>; //TODO: error handle component
}
return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: "10px" }}>
<StripeProvider apiKey={STRIPE_PUBLISHABLE_KEY}>
<div>
<div style={{ width: '100%', textAlign: 'center', marginBottom: '10px' }}>
<span style={{
fontSize: '20px',
fontFamily: '"Segoe UI", "Segoe UI Web (West European)", "Segoe UI", -apple-system, BlinkMacSystemFont, Roboto, "Helvetica Neue", sans-serif',
}}>Pay with a bank card</span>
</div>
<ContentCard>
<Card.Item styles={{ root: { margin: 'auto', paddingTop: 10, paddingBottom: 10 } }}>
<Elements>
<StripeCheckoutForm
elements={null}
stripe={null}
confirmMessage={confirmMessage}
handleToken={(token: any) => this.onToken(selectedPlans, token)}
/>
</Elements>
</Card.Item>
</ContentCard>
</div>
</StripeProvider>
</div>
);
}
}
export default connect((state: ReduxState) => ({
plans: selectors.subscription.getPlans(state),
}))(SubscribePay);
Upvotes: 1
Views: 1280
Reputation: 622
You should not create a new Payment Intent for this. In Stripe Subscriptions generate Invoices for each billing period, and those Invoices generate Payment Intents to process their payments. If a Subscription payment failed due to a challenge needing to be completed, then you can use the Payment Intent's client secret from the most recent Invoice to execute confirmCardPayment
to trigger the challenge flow. To find that secret you will want to check the Subscriptions latest_invoice
, which has a pointer to its payment_intent
, where you can find the client_secret
. You can likely leverage expand
to retrieve that client secret in a single request with only the Subscription's ID.
Stripe will try to predict whether 3DS will be required for future transactions when saving payment method details if you indicate you plan to use that payment for future off-session payments, and will try to minimize the likelihood that you will need to handle 3DS challenges on Subscription renewals. You may want to review your flow for collecting payment method details to ensure you're setting those up for future usage.
setup_future_usage
and set it to off_session
as shown here: https://stripe.com/docs/payments/save-during-paymentUpvotes: 0