almost 3 years ago

Stripe subscription 的實作筆記

最近用 Stripe 實作使用者訂閱的功能,基本上就是類似 Evernote, Dropbox 這種服務會在每個月的同一天 Charge 使用者一個固定金額的功能,第一次實作所以留下一些記錄。

Rails Version: rails4.0.
Ruby Version: 2.2.2

1. 基本的 Stripe API 和 Subscribe

在此省略 Stripe 的基本設定部分,直接跳到實作訂閱功能的地方

Stripe 的做法是先建立 Plan, Plan 會有一個 id 是自己設定的字串,之後我們要靠這個 id 來 subscribe。

基本上每個 Stripe 的 Customer (User) 都可以 Subscribe Plan, Subscribe 之後會有 Subscription 的 object 產生,想知道 subscription object 有哪些 attributes 可以去後台看看 subscription 的 object 或是參考 library doc 就可以略知一二。

進入正題

首先我們需要有方法讓我們可以很快速的從我們自己的 user object 拿到 stripe 的 customer object,假設 User Table 內有一個欄位叫做 stripe_customer_token 。

app/models/user.rb
# ...

def stripe_customer
  if stripe_customer_token.blank?
    customer = Stripe::Customer.create(:email => email)
    update(stripe_customer_token: customer.id)
  else
    customer = Stripe::Customer.retrieve(stripe_customer_token)
  end

  customer
end
# ...

藉由 User#stripe_customer 這個 method 我們可以很容易拿到 stripe customer object,接下來就是訂閱 plan, 這部分其實也很簡單,官方範例如下:

customer = Stripe::Customer.retrieve({CUSTOMER_ID})
customer.subscriptions.create({:plan => PLAN_ID})

但是這樣直接 call 很難處理 error, 所以可以把它包進 service object 裡面,在 app/services 路境內建立 stripe_subscribe_service.rb 的檔案。

app/services/stripe_subscribe_service.rb
class StripeSubscribeService
  def initialize(user)
    @user = user
  end

    # @param plan_id [String] Stripe Plan ID.

    # @param trial_end [Integer] UNIX Timestamp, set custom trial to plan.

    # @param tax_percent [Integer] 0~100

    # @param metadata [Hash] store any data with key-value

  def subscribe!(plan_id, trial_end: nil, tax_percent: nil, metadata: {})
    @user.stripe_customer.subscriptions.create(
      plan: plan_id,
      trial_end: trial_end,
      tax_percent: tax_percent,
      metadata: metadata
    )
  rescue Stripe::StripeError => e
    raise GatewayError.new(e)
  end

  class Error < StandardError; end
  class GatewayError < Error; end
end

如此一來以後使用者要訂閱的時候就只需要 call StripeSubscribeService.new(user).subscribe!("plan_id_here") 就可以了,也可以做基本的錯誤處理,rescue StripeSubscribeService::Error 來救援。

2. 建立 Stripe Webhook 用的 Endpoint

上一章節講到可以 StripeSubscribeService.new(user).subscribe!("plan_id_here") 訂閱 Plan, 這章則是要講如何做 Plan 成功 Subscribe 之後的處理。

根據 Stripe 的解釋,訂閱成功後會觸發一系列的 Event, 而我們也需要藉由這些 Event 了解這個使用者每次的付款狀況如何,舉例說明我們可能希望等 Stripe 端確認成功跟使用者收款後再真正 activate 使用者的 Plan,或是有些使用者的 credit card charge failed 了我們要 cancel 他的 plan 之類的情況,這些就要靠在 Stripe 設定 Webhook (我們的 API endpoint) 來得知。

簡單來說就是要開一個 api 給 Stripe 呼叫,這裏用 Grape 實作

當我們 subscribe 成功後, Stripe 會依序發送 Event:

  • customer.subscription.created
  • charge.succeeded
  • invoice.created
  • invoice.payment_succeeded or invoice.payment_failed

除了 subscribe 成功外,每個月付款成功也會依序觸發以下 events:

  • invoice.created
  • customer.subscription.updated
  • 約一小時後 > charge.succeeded
  • invoice.payment_succeeded or invoice.payment_failed
  • invoice.updated

我們只需要針對 invoice.payment_succeededinvoice.payment_failed 做處理即可,因為 invoice 代表使用者的付款狀態,是我們唯一有興趣的 object。因此我們可以在 api 端接收這兩個 events:

app/api/stripe_webhooks/invoice_api.rb
module StripeWebhookAPI
  class InvoiceAPI < Grape::API
    format :json
    post :memberships do
      case params[:type]
      when "invoice.payment_succeeded"
        # 付款成功,將使用者訂閱的資訊標記為 active, 並更新 end_time

      when "invoice.payment_failed"
        # 付款失敗,寄信給使用者告知錯誤訊息,並標記訂閱為 Inactive

      end

      status 200 # Stripe 只把 200~299 當作送達 event,所以回傳 200 (or 201) 給 Stripe

    end
  end
end

3. 設定 Stripe Webhook 讓 local 可以接收到資料

Stripe 在每個 Event 發生時都會藉由我們設定的 Webhook 送 event object 的 data 過來,首先我們要在 Stripe 設定 Webhook 打到 local, 可以藉由 ngrok 做到。

Download ngrok 後 cd 到 ngrok 所處的資料夾打 ./ngrok -help 可以知道有哪些指令可以用,基本上 ngrok 的功用就是讓我們的本機 server 有個網址可以接收外網來的資料,我們要做的就是打開 rails server 和 ngrok ./ngrok http 3000

本機設定好之後就是到 Stripe 的後台點選右上角的 Your Account > Account Setting > Webhooks > Add

之後隨便找個 Event Retry 一下確認 local server 有收到

如果沒有收到也可以試試 RequestBin 確認一下是 Stripe 還是 ngrok 沒設定好。

參考資料:

To be continue:

收到 Webhook 資料後續處理 (Webhook 來的資料如何包裝)
Rspec 測試 Webhook
其他 API 中難發現的小事 (例:Stripe Subscription 只能用使用者 default 的信用卡結賬)

← Rails RESTful API Ruby attr_accessor vs attr_reader vs attr_writer →
 
comments powered by Disqus