You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

带试用期的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时,会跳过支付验证直接进入试用期。要改变这个行为,需要先验证支付方式,再创建带试用期的订阅:

  1. 先创建一个Setup Intent,让用户确认支付方式,确保支付方式有效;
  2. 只有当Setup Intent成功后,再创建带有试用期的订阅;
  3. 如果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:支付成功后再创建用户

调整整体流程为:

  1. 用户填写个人信息和支付信息;
  2. 前端调用后端createSetupIntent获取clientSecret;
  3. 前端调用stripe.confirmSetup验证支付方式;
  4. 只有当验证成功后,前端再调用后端createUserAndSubscription创建数据库用户和订阅;
  5. 后端创建订阅时,因为已经验证过支付方式,订阅会直接进入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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.23 18:04:54