アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

Stripe のサブスクリプションについて調べてみた

morishitaです。

年初にいこーよに新しいサービスを追加しました。
その名も「いこーよプレミアム」です。

iko-yo.net

f:id:HeRo:20210228153316p:plain
いこーよプレミアム - プレミアムクーポン

簡単に紹介すると、いこーよの有料会員サービスです。
登録するといこーよプレミアムクーポンが利用できるようになり、 お得にお出かけ施設を利用できるというサービスです。
まだ iOS アプリでしか利用できなかったり対象施設が少ないのですが、 今後 Android アプリにも対応しますし対象施設も増えていく予定です。
更にプレミアムクーポン以外の機能も追加するかもしれません。
そんな「いこーよプレミアム」が 550 円/月でご利用可能です。
いま登録すると 5 月分まで 110 円/月になる割引キャンペーン中ですのでぜひ登録ください!

と宣伝はこれくらいにして、本題にいきましょう。

「いこーよプレミアム」はサブスクリプションサービス!

「いこーよプレミアム」は月額の利用料をいただく有料サービスです。
月額の利用料…そうです、サブスクリプションサービス。略してサブスクです。

サブスクでは毎月料金をいただく際に決済が必要になります。 1回の決済処理でさえ実装するのは面倒なのに毎月定期的に実行する必要があるなんて…。
しかも、成功すればいいけれど登録されたクレジットカードが解約されてたりしてエラーになるかもしれません。
なんて考え始めると「うわーめっちゃ大変やん!」となりそうです。

いこーよでは決済処理に Stripe を利用しています。もちろんいこーよプレミアムでも利用しています。
そして Stripe を使えば前述の「うわーめっちゃ大変やん!」のかなりの部分をやってくれます。

本エントリではいこーよプレミアムの開発過程で調べた Stripe のサブスクリプションについてざっくり紹介します。

サブスクリプションではないワンショットの決済については次のエントリで書いたのでそちらもどうぞ!

tech.actindi.net

Stripe のサブスクリプション

Stripe のサブスクリプション機能の特徴をざっと箇条書きにしてみると次のとおりです。

  • 請求サイクル毎に自動で料金を請求してくれる
  • 日次、週次、月次、年次の請求サイクルを設定できる
  • 請求に失敗した場合、リトライもしてくれる
  • リトライにも失敗したら自動的にサブスクリプションを終了することもできる
  • 試用期間を設定したり、期間限定の割引を適用することもできる
  • 支払いやサブスクリプションの状態変更を Webhook を通じて通知してくれる

もちろん、Stripe がやってくれるのは決済に関わる部分だけです。 サブスクで提供する自社サービスの認可管理は独自に実装する必要があるでしょう。

サブスクリプションに関連するリソース

サブスクリプションに関連する Stripe 上のリソースは結構多いです。 それらをざっと紹介します。

リソース 概要
顧客(Customer) Subscruotion の購入者。
カード番号を保持するのに必須で、Subscription を作るのに必須。
製品(Products) 販売するサブスクリプション。
名称や料金など設定する。料金の保存場所的なリソース。
料金(Price) サブスクリプションの販売価格。
Products に対して複数設定可能で請求金額と請求間隔を設定する。Subscription を作るのに必須。
サブスクリプション(Subscription) 販売するサブスクリプション。
状態を持ち支払いに関連して状態遷移する。
請求書(Invoice) サブスクリプションに対する請求。
顧客にサブスクリプション料金を請求するタイミングで自動的に作られる。明細、税率、支払いの合計金額が記載される。
PaymentIntents 顧客の支払い処理。
請求に対する顧客側から見た記録を保持する。状態を持つ。
税率(TaxRate) 請求に加算する税率。Subscription を作成するときに default_tax_rates で指定してやると適用される。
クーポン(Coupons) 割引クーポン。
Subscription を作成するとき coupon に指定すると適用され、割り引かれる。定額または割引率で設定する。割引期間も設定できる。税率を設定している場合、割引後の請求額で税金が計算される。
Charge と Transaction Invoice に対して、支払いが発生すると、ワンショットの決済の様に Charge が作成される。Charge の balance_transaction を見ると、その決済手数料がわかる。

これらの関係をざっくり図示すると次の様になります。

f:id:HeRo:20210228153600p:plain
Subscription 関連リソース

Subscription の作成

さて概要を説明したところで、実際に Subscription を作ってみます。
Subscription を作成する前に次のリソースを作成しておく必要があります。

  • Product
  • Price
  • TaxRate
  • Coupon(割引販売する場合)

これらを用意した上で、Subscription を作成する Ruby のコードは次のとおりです。

# 購入者となる Customer を作成する
customer = Stripe::Customer.create(
  source: stripe_token 
)

# Subscription の作成
Stripe::Subscription.create(
  { customer: customer,
    items: [
      { price: 'price_XXXXXXXXXXXXXXXXXXXXXXXX' }
    ],
    default_tax_rates: [
      'txr_xxxxxxxxxxxxxxxxxxxxxxxx', # 税率 TaxRate を指定する
    ],
    coupon: 'XXXXXXXX' }, # 必要に応じて割引クーポンを指定する
  idempotency_key: 'XXXXXXXXXXXXXXXXXXXXXXXXXX')
)

ワンショットの決済では任意だった Customer は Subscription を作成するときには必須となります。
stripe_token は購入者に入力してもらったクレジットカード情報をもとに生成されるものです。作りかたはワンショットの決済(Charge)の場合と同じです1

Subscription を作成時に必要なのは customeritems[].price のみです。
default_tax_ratescoupon は任意で設定します。 idempotency_key も任意に指定もので冪等キーと呼ばれるものです( Charge を作る場合と同様の働きをします)。

たったこれだけです。簡単ですね。

Subscription の状態

Subscription は次の図に示すような状態を持ちます。
作成された Subscription はユーザの操作や Stripe による自動処理によりこれらの状態を遷移します。

f:id:HeRo:20210228153647p:plain
Subscription の状態遷移

状態 概要
trialing Subscription 試用期間。まだ請求してない。
active Subscription が有効な状態。直近の請求に対する請求が成功している。
incomplete Subscription 作成時の請求が失敗した状態。
incomplete_expired 作成時の請求に失敗し、その後 23 時間以内に支払われなかった状態。
past_due 直近の請求が失敗している状態。リトライを試みている。
canceled Subscription がキャンセルされた状態。自動的な定期請求が止まる。
unpaid 直近の請求が失敗し、リトライも成功しなかった状態。請求書は作成されるが、自動的には請求されない。

これらの状態遷移はイベントとして後述する Webhook で受信できます。

請求のリトライ

請求に対する決済が失敗した場合、Stripe で自動的に請求をリトライしてくれます。

リトライ機能は Smart Retryカスタムリトライを選択できます。
Smart Retry は機械学習的な手法を使って指定した期間の中で最適なタイミングで再請求を試みてくれる機能です。
カスタムリトライは何日後にリトライするのか指定します。

リトライの設定は Subscription ごとに設定できるのではなく、Stripe アカウントに対する設定となります。しかも、テスト環境と本番環境で別々の設定があるのではなく共通なのでテスト環境と思ってうっかり変更すると本番も変わってしまうので要注意です。

払い戻し

Subscription を直接払い戻すこと(Refund)はできません。
Subscription に対してできるのは cancel (API 的には delete)できるだけです。 cancel というのは Subscripion を停止し、今後の請求が発生しなくなるだけということに注意が必要です。
すでに支払い済みの請求を払い戻すには別途処理が必要となります。

Stripe の Web コンソールで定期支払をキャンセルする場合には最後の請求に対する返金も指定できるのでそれを利用して、キャンセルと同時に返金も可能です。

SDK を利用して払い戻す際には次の操作が必要です。

  1. Subscripion から latest_invoice を取得
  2. その latest_invoice に紐づく charge あるいは payment_intent 取得
  3. その charge あるいは payment_intent を Refund。
    • chargepayment_intent のどちらかを Refund すればよく結果は同じです。

なお、払い戻しの金額はもちろん税金を含んだ金額となります。

また、Subscription を cancel した際には自身のサービス側でもユーザのサービス利用の認可状態も更新する必要があるでしょう。

Webhook のレシーバの実装が(ほぼ)必須

定期的な決済は Stripe が自動的にやってくれますが、その結果を自分のサービスに反映する必要があります。
決済が成功すれば特にすることはないかもしれないですが、失敗すればそれをユーザに通知したり、サブスクの利用を停止したりする必要があります。
前述してように Subscription には状態があり、何らかの原因で決済できない場合、決済遅延(past_due)という状態になり設定次第で一定期間リトライしてくれます。
リトライで決済成功したら active 状態に戻りますし、決済失敗が続けば canceledunpaid になります。
Invoice も決済の成否で状態遷移します。

まあ、それらの状態を定期的にポーリングして確認する処理も実装できはするのですが、Webhook を利用したほうが圧倒的に楽だと思います。
Webhook は Stripe で管理しているデータにイベントが発生した際にそれを登録した URL に対して通知してくれる機能です。
例えば、Subscription の Invoise に対する請求決済が失敗したり、成功したりするとそれらをイベントとして通知してくれます。

Webhook endopoint を Rails のコントローラとして実装すると次の様なコードになります。

class WebhookController < ApplicationController
  skip_before_action :verify_authenticity_token

  SIGNING_SECRET = ENV['SIGNING_SECRET']

  def endpoint
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    event = Stripe::Webhook.construct_event(
      request.body.string,
      sig_header,
      SIGNING_SECRET,
    )
    # 何らかのビジネスロジック。非同期処理が望ましい。
    # リクエスト受信に成功したらビジネスロジックの成否に
    # 関わらず 200 を返すべき。
    head 200
  rescue JSON::ParserError => e
    # Stripe のリクエストがうまくパースできないエラー。
    # 何らかのエラー処理
    head 400
  rescue Stripe::SignatureVerificationError => e
    # Signing secret によるリクエスト検証でエラーが発生した。
    # 何らかのエラー処理
    head 400
  rescue StandardError => e
    # 何らかのエラー処理
    # ビジネスロジック上のエラーではなく、
    # 受信自体のエラー発生時に 500 を返すべき。
    head 500
  end
end

必須ではないのですが、上記コードでは Signing secret によりリクエストを検証しています。Webhook を Stripe の Web 管理画面で登録するときに Signing secret が発行されます。 それをキーとして、タイムスタンプやリクエストボディをハッシュ化した値が HTTP_STRIPE_SIGNATURE リクエストヘッダに付与されています。一方、SIGNING_SECRET には Signing secret をセットしており、それを使って再計算したハッシュ値と HTTP_STRIPE_SIGNATURE を比べることで受信したリクエストが正しいことを検証する仕組みです。
不正な Webhook endpoint へのリクエストを処理してしまわないようにこの検証の仕組みは利用したほうがいいでしょう。

Webhook で通知されるデータにはイベントの結果、変更が生じた属性と変更前の値が含まれています。これを利用して例えば状態が past_due から active に変わった場合にのみ処理を実行するといった実装が可能です。

すべてのイベント通知がこのエンドポイントに POST されてきます。if 文や case 文を使って、イベントごとの処理分岐を書くとコードが煩雑になると思います。
いこーよプレミアムではこの部分の実装にコマンドパターンを使いました。
各イベント毎にそれを処理するハンドラークラスを実装し、リクエストを受信したらイベントに対応したハンドラークラスのインスタンスを作り、それに処理させます。対応するハンドラークラスがないイベントを受信した際には単に sutatus: 200 をレスポンスするだけのハンドラークラスで処理します。受信するイベントを追加する際にはハンドラークラスを追加するだけです。

Webhook に通知されるイベント

Webhook で通知されるイベントにはむちゃくちゃたくさん種類があります。 Stripe の管理リソースの種類それぞれに createdupdated があり、削除できるものについては deleted イベントがあります。
更に Invoice などはユーザからの支払い操作を通知するイベントが定義されていたりします。

すべてのイベントのリストは次のドキュメントに記載されています。

Stripe API Reference - Types of events

これらの中で Subscription に関連するイベントは Stripe のドキュメント Subscription events に示されています。

ざっくり説明すると次のとおりです。

イベント名 説明
customer.created Customer の作成に成功した時に送信される
invoice.upcoming Subscription の更新の数日前に発生する。この時点では Invoice は作成されていないことに注意。
invoice.created Subscription 期間の終了時に送信される。Invoice は Draft 状態で Invoice に追加の請求項目を追加したり、税率を変更したりできる。
invoice.finalized Invoice が確定したときに送信される。Invoice の変更ができなくなる。請求処理を開始する。
invoice.paid ユーザの支払いが完了。対応する顧客のサブスクリプションの期限を延ばして OK
invoice.payment_failed 決済に失敗した。PaymentIntent のステータスが requires_payment_method となる
payment_intent.created 決済を試みるときに送信される
payment_intent.succeeded 決済成功時に送信される
invoice.payment_action_required 決済するのに顧客のアクションが必要。PaymentIntent のステータスが requires_action となる
customer.subscription.created Subscription が作成された時に送信される。
customer.subscription.updated Subscription の更新時に送信される。有効期間や status の変更が通知される
customer.subscription.deleted Subscription が cancel された時に送信される

上記イベントのうち、どのイベントを受信し処理するかはそれぞれのサービスの要件次第だと思います。Webhook の設定でどのイベントを通知するのか設定できるので、必要なものだけ設定すると良いと思います。

最低限必要になるのは Subscription の請求サイクル毎に作成される Invoice に対する支払いの成否に対応した処理かと思います。一般的には請求に成功したらサブスクリプションのサービス有効期限を延ばしたり、失敗したらサービスの利用を制限したりする必要があると思います。

また、customer.subscription.deleted に対する処理も実装しておくのをお勧めします。これを受信した時の処理としては自社サービス側のサブスクリプション利用状況も終了させることになると思います。
このイベントは Stripe の管理画面で Subscription をキャンセルしても発生します。したがってイレギュラーな運用が発生して Stripe 管理画面で Subscription をキャンセルしても、自社サービス側の管理状態にも自動的に反映できて運用上とても便利です。

イベント受信時の処理は冪等に実装する

Webhook エンドポイントの実装で大事なことは冪等な処理を実装することです。
というのも、通信状態や Stripe 側の問題で Webhook へのリクエストに対するレスポンスをきっちり送受信できなかった場合、再送されてくることがあるからです。 したがって、同じイベント通知を2回以上受信しても結果が変わらない様にしておくことが必要です。
2回目を受信したらエラーを返すような実装だと、Stripe が再送を繰り返してきたりするので要注意です。

自社サービス側でのユーザ操作等で例えばサブスクの解約処理で自社サービス側の管理データを更新して、Stripe の Subscription の cancel 処理もすると思います。その場合も customer.subscription.deleted イベントが通知されてくるので実装する場合には自社サービス側の管理データを更新処理の冪等性に注意が必要です。

イベント処理でハマったところ

Webhook の受信処理の実装でハマったところを紹介します。

customer.subscription.updated イベント

Subscription が更新された際には customer.subscription.updated が通知されてきます。
Sabscription には current_period_startcurrent_period_end という属性があり、現在の請求期間の開始日時、終了日時が記録されます。自社サービス側のサブスクの管理データの有効期限を更新するのに使えると思って、customer.subscription.updated を受信した際に current_period_end で上書きしていました。

しかし、動作確認してみると、請求が失敗してもサブスクの有効期限が延長されるのに気づきました。おかしいなと思って調べてみると、これらの属性は請求の成否に関わらず Invoice が発行された時点で更新されていました。
そのため、この請求成功の invoice.paid イベント受信時に Subscription の current_period_end 参照して更新するよう変更しました。
一方、customer.subscription.updated イベントは Subscription の状態変更時に処理するために利用しています。

Subscription が Unpaid にならない

Subscription への請求が失敗すると、まず past_due 状態となります。請求のリトライにも失敗すると Unpaid になると思っていました。
それで、customer.subscription.updated をイベントを受けて、 unpaid になったらユーザに通知して、いこーよプレミアムは自動解約する実装していました。
しかし、試してみると unpaid にならず cancel となりました。
調べると、この状態に遷移するかどうかは Settings Subscriptions and emails > Subscription statusの設定によるのでした。Stripe の Web 管理画面で変更できるのですが「mark the subscription as unpaid」を選択したときに unpaid になります。「cancel the subscription」を選択した場合は cancel 状態になます。
いこーよプレミアムでは unpaid になるように設定変更しました。
これにより請求に対して最終的に支払ってもらえない場合にサービス要件通りに処理できるようになりました。
また、ユーザや管理者の操作で cancel した場合と自動解約した場合が区別しやすいです。

ここで Tips を1つ。
作成時点で決済に失敗すると Subscription が作れないので、そもそも請求失敗のテストをするのは難しいです。でも試用期間とテスト用のカード番号 4000 0000 0000 0341 を組み合わせると試せます2。試用期間の Subscription 対して請求されないので、作成は成功します。試用期間が切れると請求され、失敗して past_due になり、試行されるリトライも失敗すると unpaid になります。
次のドキュメントにやり方が載っています。
Testing Stripe Billing

Stripe CLI

Webhook を実装する場合に Stripe CLI を使うと便利です。
Homebrew が使える環境なら次のコマンドで簡単にインストールできます。

$ brew install stripe/stripe-cli/stripe

この CLI を使うと、Webhook をローカル開発環境で動かしている webhook endpoint で受けることが可能になります。実際に自分のアプリケーションを操作しながら通知されてくる Webhook を確認できるので開発が捗ります。

参考: Test a webhook endpoint

この CLI によりイベント自体を生成できるもの便利です。便利なのですが裏側でテンポラリな Product や Price などが作成されてしまうので要注意です。あまり使いすぎると管理画面がテンポラリに作られたデータであふれかえるので使用は程々にしておいたほうがいいと思います。

また、VSCode を使っているなら Stripe 社謹製の Stripe拡張 を使うとちょっと幸せになれるかもしれません。

まとめ

いこーよプレミアムの開発の際に調査した Stripe のサブスクリプションついて紹介しました。
自前でこれらを堅牢に実装するのは大変だと思います。
うまく使ってサービスそのものの開発に集中したほうがいいと思いました。

なお、本エントリは Stripe を使ったサブスクリプションを検討されている方の参考になればと思い私が調べ、理解したことについて記述しました。
しかし間違い、勘違い、理解不足が一切ないことを保証することはできません。この点をご理解お願いします。

実際の開発においては Stripe 社の公式ドキュメントをよく読み、十分な確認と検証をされるようお願いします。

Stripe 社の公式ドキュメント

最後に

アクトインディではエンジニアを募集しています。 actindi.net


  1. 以前のエントリ「Stripeを使った決済処理を調べてみた。」で紹介しているのでそちらを参照ください。

  2. Customer への追加はできるけど決済は失敗する番号です。詳しくはこちら。Testing for specific responses and errors