Hegwin.Me

寓形宇内复几时?曷不委心任去留?胡为乎遑遑欲何之?

Stripe大冒险:利用 Subscription Schedule 更新订阅

Utilizing Subscription Schedules in Stripe: A Journey of Discovery

使用 Stripe API 更新 Subscription 算是一个常规操作,其 API 使用也不困难;但有时由于账单日期或产品订阅类型的原因,我们无法立即更新一个Subscription,这就需要利用 Subscription Schedule 的功能来在未来的某个时间创建或更新订阅。

Subscription Schedule是一个非常强大的功能。然而,就我个人的开发体验而言,最初我在使用这个功能时遇到很多迷惑之处。虽然 Stripe 提供了详细的 API 文档Use Cases 来描述Subscription Schedule,但由于 Stripe 的 API 不断发展,一些新的对象被引入,一些字段会被弃用,而最新文档并不会告诉我们它的演化过程。另外,Use Case 与 API 文档存在一些小的差距,比如 Price 取代了 Plan,或者在 Phase Items 中弃用的 coupon字段。

在本文中,我将分享一个我自己实际使用的案例——在未来的某个时间安排更新单个 Subscription Item 的 quantity,同时保持本身 coupon 的 duration。我还将讨论我在过程中遇到的挑战和解决方案。

场景设定

我们当前这样一个 Subscription,里面有一个 Subscription Item - SaaS Member Fee,单价为 $10,数量为5;同时它附带一个三个月的免费 coupon。我们需要在9月1日将其数量更新为10,即实现如下的效果:

Subscription-Schedule.jpg

TL; DR 可以直接跳到 解决方案 这一节看代码。

Stripe 自己是怎么做的

纵使有 API 文档Use Cases,第一次对接这个 API 时还是有些困惑。我知道我需要创建一个 Subscription Schedule,可是参数怎么传还是比较疑惑。

针对创建 Schedule 的API POST /v1/subscription_schedules,文档中给出的用例是这样的:

Stripe::SubscriptionSchedule.create({
  customer: 'cus_NcI8FsMbh0OeFs',
  start_date: 1680716828,
  end_behavior: 'release',
  phases: [
    {
      items: [
        {
          price: 'price_1Mr3YcLkdIwHu7ixYCFhXHNb',
          quantity: 1,
        },
      ],
      iterations: 12,
    },
  ],
})

但是,总觉并不适用我的 case ,因为我需要修改一个已经存在的 Subscription,那么我至少应该把 Subscription ID 传过去吧。读了下文档,有一个参数叫 from_subscription,其接受一个 existing subscription ID,文档中写着:

When using this parameter, other parameters (such as phase values) cannot be set. To create a subscription schedule with other modifications, we recommend making two separate API calls.

这意思是说,如果我设置了 from_subscription 那么其他的参数就不能设置了,而是要进行两次 API 调用,那该怎么办?

我们也知道手动在 Stripe 里也是可以进行 Schedule Update,这个操作也是调用的 Stripe 自己的 API。让我打开 Chrome 的 dev tool,看看 Stripe 自己是怎么进行的。Stripe 进行了两次 API 调用(省略其他参数):

第一次 API 请求:

POST /v1/subscription_schedules

Request Body:
{
  from_subscription: "sub_1PeCNNLiQSlGYPd5Wpmvb1n6"
}

Response:
{
  id: "sub_sched_1PeH6LLiQSlGYPd599aOVdoW",
  # ... other values omitted
}

第二次 API 请求:

POST /v1/subscription_schedules/sub_sched_1PeH6LLiQSlGYPd599aOVdoW

Request Body:
{
  proration_behavior: "none",
  phases: [
    {
      start_date: 1721378477,
      end_date: "now",
      iterations: "",
      discounts: [
        {
          coupon: "EDHtWtdk"
        }
      ],
      default_tax_rates: "",
      automatic_tax: {
        enabled: false
      },
      items: [
        {
          price: "price_1PeCLRLiQSlGYPd50cFz00Na",
          quantity: 5
        }
      ],
      collection_method: "charge_automatically"
    },
    {
      start_date: "now",
      end_date: 1725217200,
      discounts: [
        {
          coupon: "EDHtWtdk"
        }
      ],
      default_tax_rates: "",
      items: [
        {
          quantity: 5,
          tax_rates: "",
          price: "price_1PeCLRLiQSlGYPd50cFz00Na"
        }
      ]
    },
    {
      start_date: 1725217200,
      discounts: [
        {
          coupon: "EDHtWtdk"
        }
      ],
      default_tax_rates: "",
      items: [
        {
          quantity: 10,
          tax_rates: "",
          price: "price_1PeCLRLiQSlGYPd50cFz00Na"
        }
      ],
      proration_behavior: "none",
      collection_method: "charge_automatically",
      invoice_settings: {
        description: "Thank you for your business!"
      }
    }
  ],
  end_behavior: "release"
}

第一个 API 请求是创建一个 Subscription Schedule,只传递 from_subscription 这个参数;在拿到创建出的 Schedule ID之后,第二个 API 对这个 Schedule 进行修改,并且传入了一大批参数。这其中涉及了很多概念,比如 Phase,Proration,我们来具体看看他们的含义。

什么是 Phase

Phase 代表 Subscription Schedule 中的一段特定时间,在这段时间内会应用我们所指定的 Price、Charge、Tax等参数。也就是说,我们可以指定一组phases,每个phase有自己的计费参参数。一个 Phase 具有以下关键参数:

  1. start_date 和 end_date:标识每个 phase 的开始日期和结束日期,一个 schedule 可以有多个 phase,但是它们的 start_date 和 end_date 要能保持首尾相连
  2. price 和 quantity:我们可以在每个阶段为订阅项目指定不同的价格和数量,这样就可以实现这个场景中定期更改 quantity 的目标
  3. discounts:在这个phase使用的特定的折扣和优惠券
  4. tax_rates 和 automatic_tax:税率相关参数
  5. collection_method 等

从上述的第二个请求来看,Stripe 设定了3个Phase:

  1. Phase#0,start_date 为原先subscription的起始时间,end_date是 now,同时 discounts 包含了原先的 coupon 代码,以及 items 中有一个对象,其中有 price 和 quantity 参数,值和当前 subscription 一致。这个phase的意义在于立即终止当前的订阅。
  2. Phase#1,start_date 为 now,end_date 是我们预期的时间,items 和 Phase#0保持一致;
  3. Phase#2,start_date 是我们预期的时间,end_date为空,items[0] 中的 quantity 更新为10。

不是很明白为什么要用3个Phases,2个行不行?其实两个也是可以的。

什么是 proration 和 proration_behavior

当订阅的计划发生变化,比如我们这里对 quantity 进行了修改,这个修改可能是发生在一个账单周期之中,那就可能需要按照比例来调整账单的金额,也是 proration。

Stripe 中的 proration

我们对 Subscription 进行更改时,默认会自动处理按比例折算,即 proration 会发生。我们可以使用 proration_behavior 来指定它的行为,这个参数有以下三个值

  • create_prorations: 默认值,在计费周期发生变化时创建按比例调整
  • none: 计费周期发生变化时,不进行 proration。
  • always_invoice: 立即创建按比例调整账单

举个例子来说明一下。假设客户订购了一项每月 100 美元的基础计划服务,预收费,计费周期从每月 1 日开始。而在当月 15 日,客户决定升级到高级计划,每月费用为 200 美元。

如果选择 create_prorations,那么会有如下计算:

  • 初始订购费用 100(本月1号收费)
  • 15 日升级,则本月剩余天数: 16 天
  • 高级计划每日费用:200 / 30 ≈ 6.67 / day
  • 基本计划每日费用:100 / 30 ≈ 3.33 day
  • 按比例折算剩余16天差价:(6.67 - 3.33) * 16 = 53.44
  • 即下个月1号需要付的账单为:200(高级计划费用)+ 53.44 = 253.44

如果选择 none,则是:

  • 初始订购费用:100(本月1号收费)
  • 15 日升级,不进行 proration
  • 下一张账单(下月 1 日)仅需要支付高级计划费用 200

什么是 end_behavior

end_behavior 指的是 Schedule 结束(即到达最后一个phase的end_date)时,相关联的 Subscription 应该进行什么操作。end_behavior 的枚举值是 releasecancel,它们的区别在于:

  • release:默认值,当 Schedule 结束时,Subscription 保持那一刻的状态继续存续
  • cancel:当 Schedule 结束时,Subscription 直接结束(取消)

同时,Subscription Schedule 也提供2个对应的 API,其含义是一致的:

  • POST /v1/subscription_schedules/:id/cancel
  • POST /v1/subscription_schedules/:id/release

这一点十分重要,因为 Stripe 只允许一个 Subscription 最多有一个激活状态的 Schedule。如果你在调试代码时,创建 schedule遇到了这个错误 “You cannot migrate a subscription that is already attached to a schedule”,这意味着当前的 Subscription 已经有一个 Schedule。对于这个 Schedule,我们要么 update,要么 release/cancel。如果此时选择 cancel,你会发现与之关联的 Subscription 直接消失了。不过是从业务方面还是从调试代码的方面来看,这都很不妙,需要避免。

为什么 item 里是 price 而不是 plan

如果仔细观察创建 SubscriptionSchedule 返回的结果,我们会注意到 item 是这样的一个呈现方式:

{
  :billing_thresholds => false,
  :discounts => [], 
  :metadata => {},
  :plan => "price_1PU2ARLiQSlGYPd5Agu2y15G",
  :price => "price_1PU2ARLiQSlGYPd5Agu2y15G",
  :quantity => 5, 
  :tax_rates => []
}

看到这里不免会有疑惑,为什么会同时有 priceplan 两个字段,而且他们的值都是一样的 price_xxx

Plan 是早期版本的 Stripe Subscription系统中的核心概念,它用于定义Subscription的基本参数;而 Price 是 Stripe 新版中的改进概念,提供了更灵活和细粒度的控制,用于替代Plan。所以,在使用 Subscription Schedule功能时,我们更倾向于使用价格 Price 而不是计划 Plan。

解决方案

给定一个 subscription,想要在 time 修改其 item 的 quantity。我们只使用2个phase,代码如下:

schedule = Stripe::SubscriptionSchedule.create from_subscription: subscription.id

phase0 = schedule.phases[0]
phase0_item = phase0.items[0]

original_quantity = phase0_item.quantity
price_id = phase0_item.price
discounts = phase0.discounts.collect(&:to_hash)

phases = [
  # Phase 0 - 修改现有订阅阶段,结束日期为 `time`
  {
    start_date: phase0.start_date,
    end_date: time.to_i,
    proration_behavior: 'none',
    discounts: discounts,
    items: [
      {
        price: price_id,
        quantity: original_quantity,
      }
    ]
  },
  # Phase 1 - 设置一个新的订阅阶段,开始日期为 `time`,数量为 `quantity`
  {
    start_date: time.to_i,
    end_date: time.to_i + 60,
    proration_behavior: 'none',
    discounts: discounts,
    items: [
      {
        price: price_id,
        quantity: quantity,
      }
    ]
  }
]

Stripe::SubscriptionSchedule.update schedule.id, phases: phases, proration_behavior: 'none'

解释一下之前没覆盖到的部分:

  1. 更新SubscriptionSchedule时,如果没有明确传递 discounts参数,现有 coupon 将被取消。要保留任何当前discount 或 coupon,需要将它们包含在更新的phase中。
  2. Item 对象中的 coupon 参数已被弃用,取而代之的是在外层传入 discounts 数组。
  3. 最好为最后一个阶段设置一个结束日期,以确保Schedule自动release。这种做法可以避免触及只能有一个active Schedule的数量限制

结语

利用 Stripe 的 Subscription Schedule 功能可以大大简化订阅管理过程。虽然最初可能会遇到一些困惑,但通过仔细阅读文档和实践,我们可以充分发挥这个强大工具的潜力。希望本文对大家理解和使用 Subscription Schedule 有所帮助。如果有任何问题或需要进一步的解释,请随时联系我。

< Back