带试用期的Stripe订阅支付失败仍进trial状态及流程优化咨询
Stripe Payment Elements 订阅迁移问题及解决方案
问题1:支付失败时订阅直接进入试用期而非incomplete状态
当前配置了trial_period_days:30,Stripe默认逻辑是:当订阅带有试用期时,即使初始的支付方式验证(Setup Intent)失败,订阅仍会自动进入trialing状态,而非incomplete,这和无试用期时的行为不一致,不符合需求。
问题2:流程被迫先创建用户,希望支付成功后再创建
现在流程是先创建数据库用户再生成订阅,导致支付失败时用户已存在。希望回到之前的逻辑:支付成功后再创建用户,避免在redirect_url中传递大量参数。
当前代码
后端代码(tRPC 方法)
createSubscriptionAndUser: protectedProcedure .input(inputSchemaCreateSubscription) .mutation(async ({ ctx, input }) => { const planPriceIds = { Silver: env.STRIPE_PRICE_ID_SILVER, Gold: env.STRIPE_PRICE_ID_GOLD, Platinum: env.STRIPE_PRICE_ID_PLATINUM, }; const clerkUser = ctx.auth.userId ? await clerkClient.users.getUser(ctx.auth.userId) : null; const gymsToConnect = input.gyms.map((gym) => ({ id: gym.value })); const trainingToConnect = input.training.map((training) => ({ id: training.value, })); const selectedPlanFromUser: keyof typeof planPriceIds = input.plan; let customer; const email = clerkUser?.emailAddresses[0]?.emailAddress ?? ""; const customers = await ctx.stripe.customers.list({ email }); let customerId; if (customers?.data?.[0]?.id) { customerId = customers.data[0].id; } if (customerId) { customer = await ctx.stripe.customers.update(customerId, { name: `${clerkUser?.firstName ?? input.firstName} ${ clerkUser?.lastName ?? input.lastName }`, metadata: { userId: ctx.auth.userId, }, }); } else { console.error("something bad happened"); throw new TRPCError({ code: "BAD_REQUEST" }); } const priceId = planPriceIds[selectedPlanFromUser]; if (!clerkUser?.firstName || !clerkUser?.lastName) { await clerkClient.users.updateUser(ctx.auth.userId, { firstName: input.firstName, lastName: input.lastName, }); } const doesUserAlreadyExist = await ctx.prisma.personalTrainerUser.findFirst({ where: { id: ctx.auth.userId, }, }); if ( doesUserAlreadyExist?.stripeSubscriptionId && doesUserAlreadyExist?.subscriptionStatus === "incomplete" ) { const subscription = await ctx.stripe.subscriptions.retrieve( doesUserAlreadyExist.stripeSubscriptionId, { expand: ["latest_invoice.payment_intent", "pending_setup_intent"], } ); const subscriptionPayload = getSubscriptionPayload(subscription); return { user: doesUserAlreadyExist, subscriptionPayload }; } const user = await ctx.prisma.personalTrainerUser.create({ data: { id: ctx.auth.userId, stripeCustomerId: customer.id, avatar: clerkUser?.imageUrl, email, firstName: clerkUser?.firstName ?? input.firstName, lastName: clerkUser?.lastName ?? input.lastName, gender: input.gender, gyms: { connect: gymsToConnect, }, expertise: { connect: trainingToConnect, }, longitude: input.longitude, latitude: input.latitude, locationName: input.locationName, membershipLevel: input.plan, holiday: false, }, }); const subscription = await ctx.stripe.subscriptions.create({ customer: customer.id, items: [ { price: priceId, }, ], expand: ["latest_invoice.payment_intent", "pending_setup_intent"], payment_behavior: "default_incomplete", payment_settings: { save_default_payment_method: "on_subscription" }, trial_period_days: 30, metadata: { userId: ctx.auth.userId, }, }); const subscriptionPayload = getSubscriptionPayload(subscription); return { user, subscriptionPayload }; }),
前端提交代码
const onSubmit = async (data: FormInputs) => { if (currentStep === 1) { setCurrentStep(2); } else { if (!stripe || !elements) { return; } setPaymentError(null); // Reset payment error state before processing const { error: submitError } = await elements.submit(); if (submitError?.message) { setPaymentError(submitError.message); return; } const redirectUrl = window.location.origin + `/payment`; try { const { subscriptionPayload } = await createUserAndSubscriptionMutation( { plan: data.plan, gender: data.gender.value, longitude: data.location.longitude, latitude: data.location.latitude, locationName: data.location.locationName, gyms: data.gyms, training: data.training, firstName: user?.firstName ?? data.firstName, lastName: user?.lastName ?? data.lastName, } ); setPaymentLoading(true); const confirmIntent = subscriptionPayload.type === "setup" ? stripe.confirmSetup.bind(stripe) : stripe.confirmPayment.bind(stripe); const { error } = await confirmIntent({ elements, clientSecret: subscriptionPayload.clientSecret as string, confirmParams: { return_url: redirectUrl, }, }); if (error) { setPaymentLoading(false); console.error("Stripe error:", error.message); setPaymentError( error.message || "An error occurred while confirming payment." ); setPaymentLoading(false); } } catch (error: unknown) { if (error instanceof TRPCClientError) { setPaymentError(error.message); console.error("tRPC error:", error.message); console.error("Error shape:", error.shape); } else if (error instanceof Error) { console.error("Some other error:", error.message); } else { console.error("Unknown error:", error); } } } };
解决方案
针对问题1:让支付失败时订阅保持incomplete状态
原因是Stripe在订阅带有trial_period_days时,会跳过支付验证直接进入试用期。要改变这个行为,需要先验证支付方式,再创建带试用期的订阅:
- 先创建一个Setup Intent,让用户确认支付方式,确保支付方式有效;
- 只有当Setup Intent成功后,再创建带有试用期的订阅;
- 如果Setup失败,不创建订阅,自然不会进入trial状态。
新增后端createSetupIntent方法:
createSetupIntent: protectedProcedure .input(z.object({ email: z.string() })) .mutation(async ({ ctx, input }) => { let customer; const customers = await ctx.stripe.customers.list({ email: input.email }); if (customers.data[0]) { customer = customers.data[0]; } else { customer = await ctx.stripe.customers.create({ email: input.email }); } const setupIntent = await ctx.stripe.setupIntents.create({ customer: customer.id, payment_method_types: ["card", "apple_pay", "google_pay"], metadata: { userId: ctx.auth.userId } }); return { clientSecret: setupIntent.client_secret, customerId: customer.id }; });
针对问题2:支付成功后再创建用户
调整整体流程为:
- 用户填写个人信息和支付信息;
- 前端调用后端
createSetupIntent获取clientSecret; - 前端调用
stripe.confirmSetup验证支付方式; - 只有当验证成功后,前端再调用后端
createUserAndSubscription创建数据库用户和订阅; - 后端创建订阅时,因为已经验证过支付方式,订阅会直接进入trial状态,无需处理incomplete状态。
修改前端提交逻辑:
const onSubmit = async (data: FormInputs) => { if (currentStep === 1) { setCurrentStep(2); return; } if (!stripe || !elements) return; setPaymentError(null); setPaymentLoading(true); try { const email = user?.emailAddresses[0]?.emailAddress ?? data.email; const { clientSecret, customerId } = await createSetupIntentMutation({ email }); const { error: setupError } = await stripe.confirmSetup({ elements, clientSecret, confirmParams: { return_url: window.location.origin + `/payment/success` } }); if (setupError) { setPaymentError(setupError.message || "支付方式验证失败"); setPaymentLoading(false); return; } await createUserAndSubscriptionMutation({ ...data, customerId }); window.location.href = "/payment/success"; } catch (error) { if (error instanceof TRPCClientError) { setPaymentError(error.message); } else if (error instanceof Error) { setPaymentError(error.message); } else { setPaymentError("未知错误"); } setPaymentLoading(false); } };
修改后端createUserAndSubscription方法:
createUserAndSubscription: protectedProcedure .input(inputSchemaCreateSubscription.extend({ customerId: z.string() })) .mutation(async ({ ctx, input }) => { const planPriceIds = { Silver: env.STRIPE_PRICE_ID_SILVER, Gold: env.STRIPE_PRICE_ID_GOLD, Platinum: env.STRIPE_PRICE_ID_PLATINUM, }; const clerkUser = await clerkClient.users.getUser(ctx.auth.userId); const gymsToConnect = input.gyms.map((gym) => ({ id: gym.value })); const trainingToConnect = input.training.map((training) => ({ id: training.value })); const priceId = planPriceIds[input.plan]; if (!clerkUser.firstName || !clerkUser.lastName) { await clerkClient.users.updateUser(ctx.auth.userId, { firstName: input.firstName, lastName: input.lastName, }); } const user = await ctx.prisma.personalTrainerUser.create({ data: { id: ctx.auth.userId, stripeCustomerId: input.customerId, avatar: clerkUser.imageUrl, email: clerkUser.emailAddresses[0].emailAddress, firstName: clerkUser.firstName ?? input.firstName, lastName: clerkUser.lastName ?? input.lastName, gender: input.gender, gyms: { connect: gymsToConnect }, expertise: { connect: trainingToConnect }, longitude: input.longitude, latitude: input.latitude, locationName: input.locationName, membershipLevel: input.plan, holiday: false, }, }); const subscription = await ctx.stripe.subscriptions.create({ customer: input.customerId, items: [{ price: priceId }], trial_period_days: 30, payment_settings: { save_default_payment_method: "on_subscription" }, metadata: { userId: ctx.auth.userId }, }); await ctx.prisma.personalTrainerUser.update({ where: { id: ctx.auth.userId }, data: { stripeSubscriptionId: subscription.id, subscriptionStatus: subscription.status, }, }); return { user, subscription }; }),
内容的提问来源于stack exchange,提问作者zak
相关产品推荐
相关产品推荐

