2023年如何在Stripe定期订阅支付中集成3D Secure认证
Stripe定期订阅集成3D Secure(SCA)解决方案
核心问题分析
你遇到的3D Secure attempt incomplete错误,本质是因为当前代码使用了Stripe旧版的Tokens/Sources API,不支持SCA(强客户认证)规范。真实银行卡需要3D Secure验证时,旧API无法触发完整的验证流程,导致支付失败。
2023年Stripe订阅处理3D Secure的常规方式
- 切换到Payment Methods API:替代旧的Tokens/Sources,这是Stripe支持SCA的基础。
- 自动生成PaymentIntent:订阅创建/升级涉及即时扣费(如Pro方案首月费用)时,Stripe会自动创建PaymentIntent;若需3D Secure验证,会返回
next_action字段。 - 前端完成验证:后端将PaymentIntent的
client_secret返回给前端,前端调用Stripe的confirmCardPayment完成3D Secure流程。 - Webhook确认状态:通过
payment_intent.succeeded、subscription.updated等Webhook事件确认最终状态,避免依赖前端返回结果。
代码改造步骤
一、前端重构(使用最新Stripe React库)
首先安装依赖:
npm install @stripe/react-stripe-js @stripe/stripe-js
1. 重构StripeCheckoutForm组件
import React, { useState } from 'react'; import { CardElement, useStripe, useElements } from '@stripe/react-stripe-js'; import { Stack, PrimaryButton, Spinner, Text } from 'office-ui-fabric-react'; const createOptions = () => ({ style: { base: { fontSize: '16px', color: '#424770', '::placeholder': { color: '#aab7c4', }, }, invalid: { color: '#c23d4b', }, }, }); interface StripeCheckoutFormProps { confirmMessage?: string; onPaymentConfirm: () => Promise<string>; handlePaymentSuccess: () => void; } const StripeCheckoutForm: React.FC<StripeCheckoutFormProps> = ({ confirmMessage, onPaymentConfirm, handlePaymentSuccess, }) => { const stripe = useStripe(); const elements = useElements(); const [isHandling, setIsHandling] = useState(false); const [errorMessage, setErrorMessage] = useState<string | undefined>(); const handleSubmit = async (ev: React.FormEvent) => { ev.preventDefault(); if (!stripe || !elements) return; setIsHandling(true); setErrorMessage(undefined); try { // 调用后端获取PaymentIntent的client_secret const clientSecret = await onPaymentConfirm(); // 自动处理3D Secure验证 const { error, paymentIntent } = await stripe.confirmCardPayment(clientSecret, { payment_method: { card: elements.getElement(CardElement)!, }, }); if (error) { setErrorMessage(error.message); } else if (paymentIntent.status === 'succeeded') { handlePaymentSuccess(); } } catch (e: any) { setErrorMessage(e.message); } finally { setIsHandling(false); } }; return ( <form style={{ width: 280 }} onSubmit={handleSubmit}> <div className="split-form"> <label style={{ minHeight: 45, minWidth: 200 }}> <Text variant="large">Card number</Text> <CardElement {...createOptions()} onChange={({ error }) => error && setErrorMessage(error.message)} /> </label> </div> <div className="error" role="alert"> {errorMessage} </div> <Stack styles={{ root: { marginTop: 10 } }}> {isHandling ? ( <PrimaryButton disabled> <Spinner></Spinner> </PrimaryButton> ) : ( <PrimaryButton type="submit" style={{ width: '100%' }}> {confirmMessage || 'Pay'} </PrimaryButton> )} </Stack> </form> ); }; export default StripeCheckoutForm;
2. 重构SubscribePay组件
import React from 'react'; import { connect } from 'dva'; import { State as ReduxState } from '../../../store/reducer'; import { loadStripe } from '@stripe/stripe-js'; import { Elements } from '@stripe/react-stripe-js'; 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'; // 初始化Stripe实例 const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY); interface Props { dispatch: Dispatch<any>; selectedPlans: Plan[]; goCompleted: any; confirmMessage: string; } class SubscribePay extends React.Component<Props> { // 调用后端创建/升级订阅,获取PaymentIntent的client_secret onPaymentConfirm = async () => { const response = await fetch('/httpOnly/stripe/subscriptions/upgrade', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${localStorage.getItem('token')}` // 带上用户登录凭证 }, body: JSON.stringify({ plans: this.props.selectedPlans, }), }); const data = await response.json(); if (!response.ok) throw new Error(data.message); return data.client_secret; }; handlePaymentSuccess = () => { this.props.goCompleted(); }; render() { const { selectedPlans, confirmMessage } = this.props; if (!selectedPlans.length) { return <div>There is no selectedPlans. Go Back</div>; } return ( <div style={{ display: 'flex', justifyContent: 'center', marginTop: "10px" }}> <Elements stripe={stripePromise}> <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 } }}> <StripeCheckoutForm confirmMessage={confirmMessage} onPaymentConfirm={this.onPaymentConfirm} handlePaymentSuccess={this.handlePaymentSuccess} /> </Card.Item> </ContentCard> </div> </Elements> </div> ); } } export default connect((state: ReduxState) => ({ plans: selectors.subscription.getPlans(state), }))(SubscribePay);
二、后端代码改造
router.post( "/httpOnly/stripe/subscriptions/upgrade", loginCheckCallback, plansCheckCallback, async (req, res) => { const user = res.locals.user; const plans = res.locals.plans; try { const customer = await getOrCreateCustomer(user); // 获取用户活跃订阅 const subscriptions = await stripe.subscriptions.list({ customer: customer.id, status: 'active', }); let subscription; let clientSecret; // 查找标记为"Store"的订阅 subscription = subscriptions.data.find((sub) => { return sub.metadata.label === "Store"; }); if (subscription === undefined) { // 创建新订阅(Pro方案) const subscriptionResult = await stripe.subscriptions.create({ customer: customer.id, items: plans.map(x => ({ price: x.id })), // 使用Price ID替代旧版Plan ID payment_behavior: 'default_incomplete', // 关键:生成未完成的PaymentIntent等待验证 expand: ['latest_invoice.payment_intent'], // 展开获取PaymentIntent信息 metadata: { label: 'Store' }, }); subscription = subscriptionResult; clientSecret = subscriptionResult.latest_invoice.payment_intent.client_secret; } else { // 升级现有订阅到Pro方案 const subscriptionResult = await stripe.subscriptions.update(subscription.id, { items: plans.map(x => ({ id: subscription.items.data[0].id, price: x.id })), billing_cycle_anchor: 'now', proration_behavior: 'none', cancel_at_period_end: false, payment_behavior: 'default_incomplete', // 升级费用需验证 expand: ['latest_invoice.payment_intent'], }); subscription = subscriptionResult; clientSecret = subscriptionResult.latest_invoice.payment_intent.client_secret; } res.json({ subscription, client_secret: clientSecret, created: true }); } catch (e) { console.log(e); res .status(e.statusCode || 500) .json({ message: "error when create subscriptions: " + e.message }); } } );
关键注意事项
- Webhook配置:必须监听以下Stripe事件确保流程可靠:
payment_intent.succeeded:确认付款成功subscription.updated:确认订阅状态更新payment_intent.payment_failed:处理付款失败
- 测试3D Secure:使用Stripe测试卡
4000000000003220可触发验证流程 - Price ID替代Plan ID:新版Stripe API推荐使用Price ID(每个Plan对应多个Price,如不同货币/周期),确保
plans中的id为Price ID - SCA自动豁免:Stripe会自动判断是否可豁免3D Secure,无需手动配置
针对你的问题的直接回答
更新订阅时在哪里创建PaymentIntent?
无需手动创建。调用stripe.subscriptions.update并设置payment_behavior: 'default_incomplete'时,Stripe会自动为升级费用生成PaymentIntent,通过expand参数获取其client_secret返回给前端即可。2023年启用3D Secure的常规方式?
切换到Payment Methods API,订阅操作时使用payment_behavior: 'default_incomplete',让Stripe自动生成PaymentIntent,前端通过confirmCardPayment完成验证,同时依赖Webhook确认最终状态。
内容的提问来源于stack exchange,提问作者SoftTimur
相关产品推荐
相关产品推荐

