V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
xiaohanyu
V2EX  ›  Stripe

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

  •  
  •   xiaohanyu · 2 天前 · 1361 次点击

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

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

    四种集成方式的选择

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

    • payment links:最简单的一种,集成成本最低,时间最快,但是可定制化程度最低,比较适合简单的业务
    • checkout ,又分为 hosted checkout 和 embedded checkout 两种:
      • hosted checkout:用户在 checkout 时,需要重定向到 stripe 的网站,好比说你在淘宝买东西付款时,需要重定向到支付宝或者微信支付的网站,用户流程更长一些,集成成本也不算高,比较简单
      • embedded checkout:stripe 提供一个内嵌的 form ,可以直接嵌入到你的网站中,这样用户在 checkout 时,不需要重定向到 stripe ,用户体验会更好一些,集成成本比 hosted checkout 高很多,我个人开发过程中的一个 demo
    • elements:定制化最强的一种,同时也是成本最高的,所有的元素,诸如信用卡输入框这些均可定制,适合高度复杂的业务,不建议普通用户使用

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

    我个人建议:

    • 如果是极其简单的业务,或者开发时间、能力有限,用 payment links 就行,比如说你在网上卖电子书、软件 license ,放个 payment links ,收到 payment 后,给 customer 自动或者手动发送电子书、软件 license 就行了; payment links 还有一个显著的优点,就是因为 payment links 本身提供的是一个 URL ,你可以把这个 URL 分享到各种平台,就如我们日常在用的二维码收款码一样
    • 如果略复杂一些的业务,比如有多种 pricing plan ,可以用 checkout ,想要最佳的用户体验,又能承担高一些的开发成本,可以用 embedded checkout ,但是 checkout 有个关联 customer 的小坑,后面说
    • elements 不建议采用,开发成本过高了

    Pricing Table 的问题

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

    • 如果用户尚未注册登录,则直接重定向到用户的注册登录页,引导用户注册登录
    • 如果用户已经登录,则按照正常的流程,重定向用 subscribe 付款页面

    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 ,见后文)。

    我个人建议:

    • 在创建 stripe checkout session 时,先创建 stripe customer ,但是不要用一个 customer email 创建多个 Stripe customer ,最后确保一个 customer email 创建并且仅创建一个 Stripe Customer ,code 类似于这种:
    /**
     * 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 = '[email protected]'
    
        // 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 有两种方式:

    • 一种是通过 no-code 的方式,在 Stripe Dashboard 中直接 activate customer link ,拿到一个 URL ,将这个 URL 放到你的网站中,用户访问这个 URL ,就可以用验证码登录来访问或管理支付相关的信息
    • 另一种是通过 API 的方式,在用户访问 customer portal 时,通过 API 临时创建一个 customer portal 的 URL ,然后把这个临时 URL 返回给用户,用户通过这个临时生成的 URL 访问 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 的折扣,欢迎注册体验。

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


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

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

    @HaroldFinchNYC 在很多国家第一行地址必须要正确才能刷卡,虽然网络实际就验证里面那个数字。
    drymonfidelia
        13
    drymonfidelia  
       1 天前
    @billccn 如果是银行责任(验证不严)就是银行承担损失
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   实用小工具   ·   1377 人在线   最高记录 6679   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 23ms · UTC 23:46 · PVG 07:46 · LAX 16:46 · JFK 19:46
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.