基于Next.js+Supabase+Stripe的订阅支付流程优化问询
我正在用Next.js、Supabase和Stripe搭建订阅制SaaS产品,核心功能已完成,但支付流程卡壳了。
产品有两个订阅方案:月度5欧元、年度50欧元,属于同一个Stripe Product下的两个Price,功能完全一致,仅年度方案年成本更低。两个方案都设置了30天试用期,试用结束后订阅转为active状态。
我通过Webhook把Stripe的products、prices、customers、subscriptions数据同步到Supabase数据库,完全遵循官方模板。同时做了两种可随时(试用或活跃订阅阶段)使用的促销码:
- 永久免费:用来给亲友用
- X个月免费:用来招募测试用户收集反馈
当前流程
- 用户在/register页面选订阅计划,priceId存入URL后显示注册表单(只有邮箱和密码)
- 用户注册时,通过Stripe API静默创建Customer和带30天试用期的Subscription,不需要支付信息,相关数据通过Webhook同步到数据库
- 用户能在/account/billing页面随时用促销码,也能进入客户门户添加支付信息
现有问题
- 「永久免费」促销码能直接结束试用并转为带优惠券的年度订阅,逻辑正常
- 「X个月免费」促销码处理起来麻烦:我想把下次(或试用期间的首次)付款推迟X个月,但没法直接修改current_period_end或billing_cycle为任意值
我尝试用subscription schedules实现:设置两个阶段,第一阶段从trial_ends(试用中)或current_period_end(活跃中)开始,应用优惠券免费X个月;第二阶段恢复原订阅计划收费。但这个方案在用户中途换订阅计划时会变得复杂,虽然有拦截subscription.update事件的临时解决办法,但太繁琐。我不想把trialing状态用户和带优惠券的active状态用户混在一起,所以也不想用修改/延长试用期的方式。
咨询点
- 当前流程要不要调整?
- 有没有更优的实现方案?
- 有没有其他方法能在使用促销码时把下次付款推迟X个月?
相关代码
export const addPromoCode = async (code: string) => { const annualPriceId = PRICES.ANNUAL; const monthlyPriceId = PRICES.MONTHLY; let coupon = undefined; try { coupon = await getPromoDetails(code); } catch (error: any) { return { status: FORM_STATUS.ERROR, message: error.message }; } const { percent_off, id: couponId, duration, duration_in_months, } = coupon || {}; const isFreeForever = percent_off === 100 && duration === "forever"; const { id, trial_end, current_period_end, customer } = await createOrRetrieveSubscription( isFreeForever ? annualPriceId : monthlyPriceId ); if (isFreeForever) { await stripe.subscriptions.update(id, { discounts: [{ coupon: couponId }], trial_end: "now", }); } else if (duration_in_months) { await stripe.subscriptionSchedules.create({ customer: typeof customer === "string" ? customer : customer.id, start_date: trial_end || current_period_end, end_behavior: "release", phases: [ { items: [{ price: monthlyPriceId, quantity: 1 }], iterations: duration_in_months, billing_cycle_anchor: "phase_start", proration_behavior: "none", discounts: [{ coupon: couponId }], }, { items: [{ price: monthlyPriceId, quantity: 1 }], }, ], }); } revalidatePath(LINKS.BILLING); return { status: FORM_STATUS.SUCCESS, message: "Promo code applied successfully!", }; };
1. 当前流程是否需要调整?
不需要大改,核心流程(注册时创建带试用期的订阅、Webhook同步数据、客户门户管理)是合理的,问题仅出在「X个月免费」促销码的实现逻辑上,针对性调整即可。
2. 更优的实现方案:优惠券+订阅周期锚点调整
Subscription Schedules确实会在用户更换订阅时增加复杂度,推荐用以下方式实现:
- 针对「X个月免费」的促销码,创建100%折扣、duration为
once、duration_in_months为X的优惠券(这里用once是为了只覆盖免费期的费用,后续通过锚点调整实现延期) - 应用促销码时分两种场景处理:
- 试用阶段用户:
- 结束试用期(
trial_end: "now"),将订阅转为active状态 - 给订阅添加该优惠券
- 调整订阅的
billing_cycle_anchor为当前时间 + X个月(注意需符合Stripe周期规则,比如月度订阅锚点要保持每月同一天,建议用日期库精确计算)
- 结束试用期(
- 活跃订阅用户:
- 给订阅添加优惠券
- 调整
billing_cycle_anchor为current_period_end + X个月,同时设置proration_behavior: "none"避免产生额外计费
- 试用阶段用户:
这种方式无需依赖Subscription Schedules,用户更换订阅时逻辑和正常场景一致,不会增加复杂度,同时能清晰区分trialing和带优惠券的active状态。
3. 其他推迟下次付款的方法
- 匹配订阅周期的重复优惠券:如果X个月和订阅周期对齐(比如月度订阅给3个月免费),可以创建
duration: "repeating"、duration_in_months: X的100%折扣优惠券,应用后订阅会自动在X个月后开始收费,无需调整周期锚点。但仅适用于免费时长和订阅周期匹配的场景。 - 手动调整发票到期日:如果用户还未生成首次发票(试用阶段),可以修改即将生成的Invoice的
due_date为试用结束后X个月;但活跃订阅的后续发票需要手动干预每一期,扩展性差,不推荐。
调整后的代码示例
export const addPromoCode = async (code: string) => { const annualPriceId = PRICES.ANNUAL; const monthlyPriceId = PRICES.MONTHLY; let coupon; try { coupon = await getPromoDetails(code); } catch (error: any) { return { status: FORM_STATUS.ERROR, message: error.message }; } const { percent_off, id: couponId, duration, duration_in_months, } = coupon; const isFreeForever = percent_off === 100 && duration === "forever"; const isXMonthsFree = duration_in_months && percent_off === 100; const subscription = await createOrRetrieveSubscription( isFreeForever ? annualPriceId : monthlyPriceId ); const { id: subId, trial_end, current_period_end, customer } = subscription; if (isFreeForever) { await stripe.subscriptions.update(subId, { discounts: [{ coupon: couponId }], trial_end: "now", }); } else if (isXMonthsFree) { const now = Math.floor(Date.now() / 1000); let newBillingAnchor; // 计算X个月后的时间(建议用date-fns/moment处理精确日期,避免每月天数差异) const addMonths = (timestamp: number, months: number) => { const date = new Date(timestamp * 1000); date.setMonth(date.getMonth() + months); return Math.floor(date.getTime() / 1000); }; if (trial_end && trial_end > now) { // 试用中:结束试用,设置新锚点为当前时间+X个月 newBillingAnchor = addMonths(now, duration_in_months); await stripe.subscriptions.update(subId, { trial_end: "now", discounts: [{ coupon: couponId }], billing_cycle_anchor: newBillingAnchor, proration_behavior: "none", }); } else { // 活跃订阅:锚点设为当前周期结束+X个月 newBillingAnchor = addMonths(current_period_end, duration_in_months); await stripe.subscriptions.update(subId, { discounts: [{ coupon: couponId }], billing_cycle_anchor: newBillingAnchor, proration_behavior: "none", }); } } revalidatePath(LINKS.BILLING); return { status: FORM_STATUS.SUCCESS, message: "Promo code applied successfully!", }; };
内容的提问来源于stack exchange,提问作者guille

