我最近从条纹卡元素迁移到新的 PaymentElements,这样我就可以支持 Apple Pay 和 Google Pay 以及我的订阅卡。我一直在浏览 stripes 文档来了解如何正确设置订阅。
现在,我正在用户旅程的一开始就设置一个客户,然后让他们填写信息和付款信息,然后在我的表单提交中,我提交用户,后端创建用户,然后创建订阅,webhooks 完成其余的工作。我遇到的问题是,当卡付款失败时,付款状态会直接进入我的网络钩子中的尾部。尽管我猜设置意图在技术上是失败的。我怎么能让它不完整,就像没有免费试用期一样。
我也必须改变我的整个流程,如果付款失败时没有在我的数据库中创建用户(像以前一样),我会更喜欢它,因为我可以在我的后端确认付款,这很重要更好的是,现在我无论如何都必须创建一个用户,并在订阅不完整时检索订阅。真的感觉我现在把我的东西搞砸了,如果有一种方法在我确认它有效后创建一个用户那就更好了。无需使用用户参数填充整个redirect_url。
后端代码。
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);
}
}
}
};
对于自定义元素流(您首先创建订阅,然后收集付款方式以及第一个意图),这是一个已知的限制。
据我所知,控制这一点的唯一功能是
trial_settings.missing_payment_method=cancel
,但这仍然不是你想要的:
trial_settings.missing_payment_method=cancel
不会这样做,但如果第一张付款发票没有收取任何方法,它会自动取消订阅。更让您沮丧的是,Stripe 允许这样做,但使用 Checkout - 使用
payment_method_collection=always
,除非客户完成会话 - 这需要收集有效的付款方式,否则不会创建订阅。我建议解决这个问题的方法是放弃新的流程(首先创建订阅,然后收集付款方式以及第一个意图),而使用更老式的流程,在其中收集首先使用付款方式,然后创建您的订阅。正如您所说,这将是一个很好的“在确认有效后创建用户的方法”。
如果成功:
default_payment_method=setup_intent.payment_method
off_session=true
off_session
论点与试验并不真正相关 - 避免首次付款要求身份验证很有用,但如果您没有首次付款,这自然不是问题。