SaaS 产品集成 Stripe 支付的一些坑

3 天前
 xiaohanyu

SaaS 产品集成 Stripe 支付的一些坑

最近在给自己的 SaaS 产品 PPResume 集成支付,调研了很久,最终还是决定用 Stripe ,集成过程中又发现了一些坑,分享出来,供后来者参考。

四种集成方式的选择

Stripe 官方提供四种主流的集成方式

Next.js 官方有一个 demo,可以直观对比下 payment links 、checkout 和 elements 三种方式的区别

我个人建议:

Pricing Table 的问题

Stripe 提供一个 no code 的 pricing table,可以方便的在自己的网站上嵌入一个 pricing table 。但是 Stripe 的 pricing table 有一个问题,就是默认点击 Subscribe button 的时候,是直接定向到 stripe 的 checkout 页,而对一般的 SaaS 产品而言,Pricing Table 的 CTA button 针对用户是否已经登录,要定向到不同的页面:

Stripe 的 Pricing Table 另一个问题就是 UI 的可定制性还是差了一些,没有提供单独的 CSS 定制,可能和产品本身的 UI 风格有较大差异。

海外有一个专门的 Pricing Table SaaS 产品,部分解决了这个问题,可以参考一下。实在不行,Pricing Table 就还是自己写的,不难的。

Checkout 最好关联 Customer

Stripe Checkout Session创建 时,接收一个 customer 参数,该参数是 stripe 内建的 customer 的 id ,如果创建 Stripe Checkout Session 时(stripe.checkout.sessions.create),没有提供这个 customer 参数,那么 Stripe 的后台会将所有 email/credit card/phone 相同的 customer 统一归组成一个 guest customer ,这个 guest customer 仅仅在 Stripe Dashboard 中可见,而在 Stripe API 中是不可见的,并且 guest customer 没有办法对接 Customer Portal (关于 Customer Portal ,见后文)。

我个人建议:

/**
 * Retrieves or creates a Stripe customer with logto user's email.
 *
 * This function first attempts to fetch the user's context from the Logto
 * client using the provided request.
 *
 * Then checks if a customer already exists in Stripe with that email, if
 * existed, return that stripe customer, otherwise a new customer is created in
 * Stripe.
 *
 * @param {NextRequest} request - The incoming request object from Next.js.
 *
 * @returns {Promise<Stripe.Customer | null>} A promise that resolves to the
 * Stripe Customer object if found or created, otherwise null.
 */
export async function getOrCreateCustomer(
  request: NextRequest
): Promise<StripeServer.Customer | null> {
  const stripe = getStripeServer()

  try {
    // find a way to get a customer email from your auth system
    const customerEmail = 'customer@ppresume.com'

    // This is not likely to happen as all logto users should have a valid email
    if (!customerEmail) {
      return null
    }

    const customers = await stripe.customers.list({
      email: customerEmail,
      limit: 1,
    })

    if (customers.data.length === 0) {
      return await stripe.customers.create({
        email: customerEmail,
        metadata: {
          logtoUserId: user?.claims!.sub,
        },
      })
    } else {
      return customers.data[0]
    }
  } catch (err) {
    return null
  }
}

Customer Portal 集成的两种方式

Stripe 提供一个 no code 的 Customer Portal,可以让用户自行管理支付相关的数据,比如支付方式,订阅记录,invoices 等等,这些东西自己实现,又繁琐又无聊,因此如果支付系统能提供相应的解决方案,还是很方便的。

据我所知,Stripe 和 LemonSqueezy 是提供 customer portal 的,而 Paddle 是不提供的(有一个专门的 SaaS 产品就是给 Paddle 提供一个 Customer Portal )。

集成 Stripe Customer Portal 有两种方式:

/**
 * Creates a Stripe customer portal session and redirects user to that portal
 *
 * It first retrieves or creates a Stripe customer based on the incoming
 * request, then generates a session for the Stripe Billing Portal, and finally
 * redirects the user to the Stripe Billing Portal.
 *
 * The major benefit of using API to create a customer portal with customer
 * attached over plain customer portal link provided from stripe dashboard is,
 * user won't need to manually sign in for API created customer portal.
 * Basically, when you create a customer portal with API, it will generate a
 * short-lived URL for user and this URL do not need user to sign in.
 *
 * From [Stripe]( https://docs.stripe.com/api/customer_portal/sessions):
 *
 * ```
 * A portal session describes the instantiation of the customer portal for a
 * particular customer. By visiting the session’s URL, the customer can manage
 * their subscriptions and billing details. For security reasons, sessions are
 * short-lived and will expire if the customer does not visit the URL. Create
 * sessions on-demand when customers intend to manage their subscriptions and
 * billing details
 * ```
 *
 * @param {NextRequest} request - The incoming request object from Next.js.
 *
 * @returns {Promise<Response>} A promise that resolves to a redirect response
 * to the Stripe Billing Portal, or a JSON response indicating that the customer
 * was not found.
 */
export async function GET(request: NextRequest) {
  const stripe = getStripeServer()
  const customer = await getOrCreateCustomer(request)

  if (!customer) {
    return Response.json(
      {
        message: 'Customer not found',
      },
      { status: 404 }
    )
  }

  const customerPortalSession = await stripe.billingPortal.sessions.create({
    customer: customer.id,
    return_url: getAbsoluteUrlWithDefaultBase(routes.pages.settings.billing),
  })

  return Response.redirect(customerPortalSession.url, 303)
}

第二种方式最大的好处就是,用户不必通过验证码来登录 customer portal ,而是可以直接访问,用户体验要好太多

Stripe Webhook 实现

用户下单之后,系统需要满足用户的下单需求,比如如果是实物电商,那么商家需要打包发货,而如果是 SaaS 这种虚拟服务,则一般需要赋予用户相应的 Pro 权限,这个过程叫做 order fulfillment

Order fulfillment 需要实现 webhook:

Webhooks are required

You can’t rely on triggering fulfilment only from your Checkout landing page, because your customers aren’t guaranteed to visit that page. For example, someone can pay successfully in Checkout and then lose their connection to the internet before your landing page loads.

Set up a webhook event handler to get Stripe to send payment events directly to your server, bypassing the client entirely. Webhooks are the most reliable way to know when you get paid. If webhook event delivery fails, we retry several times.

所谓 webhook ,简单讲,就是在你的系统中向公网暴露出一个 API endpoint ,当用户通过 Stripe 下单成功后,Stripe 给这个 API endpoint 发送一个 POST 请求,请求 event 里包括详细的下单付款信息,你的系统收到这个信息后决定怎样给用户赋予相应的权限等等。

Webhook 实现中有一个小坑,就是 Stripe 不保证 event 的时序性,也不保证请求的唯一性——事实上,公网的网络请求中也是没有办法做到这一点的,因此你的 Webhook 实现中,看需求,如果对数据一致性要求比较高,最好实现幂等性。具体细节不在这里讲啦。


后话:集成 Stripe 花了小一个月的时间,Stripe 的开发体验非常不错,但是产品矩阵很庞大,新手往往比较容易困惑,再就是有一些隐藏的坑,我遇到了一些,随手记了下,发上来当一个备份吧。

集成支付系统还是蛮耗体力的一个活,后面有时间的话,打算写一篇《 Stripe 101:The Missing Tutorial for Indie Makers 》。

最后打个小广告哈,我的 SaaS 产品 PPResume 去年上线后又经过一年的打磨,最近终于要推出付费计划啦,所有在支付正式上线之前注册的用户,都将获得一年内 50% off 的折扣,欢迎注册体验。

1405 次点击
所在节点    Stripe
13 条回复
falcon05
3 天前
学习了
drymonfidelia
3 天前
我强烈推荐不是巨型公司都用 hosted checkout ,在你自己的域名下面填信用卡信息和在 stripe 的域名下面填信用卡信息用户信任级别不是一个量级的,欧美盗刷信用卡严重,经常有在输入信用卡信息前确认域名的宣传,他们才不懂什么是 iframe ,在你域名下输入信用卡信息就是你在收集信用卡信息。
drymonfidelia
3 天前
@drymonfidelia 这也就是为什么很多欧美产品试用页都要加一个 No credit card required ,虽然他们被盗刷是银行承担损失,但处理被盗刷的事情也需要浪费很多时间
xiaohanyu
3 天前
@drymonfidelia 嗯,信任度方面,用 hosted checkout 确实是一个合理的考量。但是 stripe embedded checkout 当初发布的时候,反响还是非常不错的: https://x.com/stripe/status/1714296703426392282 。我打算还是先用 embedded checkout 试试看,如果有问题,再切回 hosted checkout 。
HaroldFinchNYC
3 天前


建议:address 干脆去掉,或者设置为 optional ,
address 是隐私,不易大肆公开
xiaohanyu
3 天前
xiaohanyu
3 天前
Preview:

![Optional Address Line in PPResume]( https://imgur.com/a/E9nD9F9)
xiaohanyu
3 天前
@HaroldFinchNYC >_: V2EX 评论区不知道咋贴图片……哈哈
gogogo1203
3 天前
我上个 saas 用的 subscription, 这次换成了 one-time. 劈里啪啦调 stripe 又花了一天多时间。webhook +edge fn 看这个 repo https://github.com/vercel/nextjs-subscription-payments
drymonfidelia
3 天前
@xiaohanyu #4 这个 tweet 底下明显都是开发者反馈,没有最终用户反馈,我的是用户回访的经验
xiaohanyu
3 天前
@drymonfidelia 感谢感谢,回头我也做下回访看看。
billccn
2 天前
@drymonfidelia 不对哦,被盗刷是扣商户的钱退回持卡人,银行才不会亏这个钱。

@HaroldFinchNYC 在很多国家第一行地址必须要正确才能刷卡,虽然网络实际就验证里面那个数字。
drymonfidelia
2 天前
@billccn 如果是银行责任(验证不严)就是银行承担损失

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/1073328

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX