LIVESENSE ENGINEER BLOG

リブセンスエンジニアの活動や注目していることを発信しています

Stripeサブスクリプションで口コミ定期購読を実装する

f:id:mnmandahalf:20211201184648p:plain

自己紹介

こんにちは。Livesense Advent Calender 2021 1日目を担当しますmnmandahalfと申します。

普段は主に転職会議のエンジニア組織のマネージャーとして、1on1や評価・育成、採用活動などを行っています。

マネジメントについての記事を EMアドベントカレンダーに記載させていただく予定なので、そちらもご覧いただけますと嬉しいです。(宣伝)

はじめに

転職会議は、ユーザーの口コミ投稿によって成り立っているサイトです。

口コミを全件閲覧するのに口コミ投稿をしていただく必要があるのですが、なんらかの事情で口コミを書けない方のために口コミパス購入という商品を用意しています。

以前は購入画面で決済代行サービスへのドメイン遷移や別途のメールアドレス取得などを行なっていることによって、購入画面の改善がしづらく、メールアドレスを忘れてしまったなどの問い合わせも発生していました。

また継続購入がもともと割合として非常に多かったというのもあり、商品のサブスクリプション化を行う話が持ち上がったタイミングで決済代行サービスのStripeへの移行を同時に行うことにしました。

今回はRailsアプリケーションにて決済機能を構築したため、RubyのSDKのコードを載せながら実装した内容を解説していきたいと思います。

やったこと

Stripeサブスクリプションに関連するリソースやサブスクリプションのライフサイクルについては、公式ドキュメントにて確認できるためこちらでは割愛します。

クレジットカード登録

カード登録画面を表示するためにStripe::SetupIntent.createを実行しpayment intentを発行します。

まずはCustomerの登録が必要だったため、カードの紐付けに成功した後にCustomerの作成を行いました。

※ 図はメソッドの引数やレスポンスのデータ形式を簡略化しています

f:id:mnmandahalf:20211201161037p:plain また、このタイミングで会員登録に使用しているメールアドレスをStripeに受け渡すことにしたため、サイトの利用規約や個人情報の取扱い、特定商取引法の記載などの文書も変更し告知を行いました。

サブスクリプション決済

こちらはシンプルです。

会員情報に紐付けてDBに保存したStripeのCustomer情報を取得し、Stripe::Subscription.createを実行。Stripe APIのレスポンスで決済の成功/失敗が取得できるため、失敗した場合はエラー画面を表示します。

またこのタイミングで後述するWebhookエンドポイントに決済成功/失敗のイベントもPOSTされるようになっています。

工夫したポイントとしては、多重購入を防止するための処理をフロント・API双方に入れています。

サブスクリプションの停止

Stripe::Subcription.deleteを呼び出します。

ポイントとしては、すでに支払い済みのサブスクリプションの停止を行なった場合も決済から30日間は商品の提供(口コミの閲覧)自体は行うため、サブスクリプションのステータスとは別に支払い済みなのかどうか判別できるフラグをDBに持たせて更新しています。

なお、この情報ももちろんStripe APIから取得できますが、口コミ閲覧判定ロジックが走るたびにStripeにリクエストしたくなかったため、機能として最低限必要かつStripeからキャッシュしておきたいデータをメディアのDBに持たせるという切り分け方をしています。

実際にどのデータをDBに持たせたか、については後述します。

クレジットカードの変更(カードエラーからのリカバリ)

自発的なカードの変更も想定されますが、多くの場合は決済エラー時のメールに誘導されてのカードの変更となると思われます。 決済エラーの中でもカードエラーからのリカバリの場合、PaymentIntentの確定を行う必要があったため、下記のような実装にしています。

# カードのアタッチ、デタッチ、デフォルトの支払い方法変更など...

# カードエラーから復活の場合は、再度請求が走るようPaymentIntentの確定を行う
stripe_subscription = Stripe::Subscription.retrieve(id: jobtalk_subscription.stripe_subscription_id, expand: ["latest_invoice.payment_intent"])
Stripe::PaymentIntent.confirm(stripe_subscription.latest_invoice.payment_intent.id, { payment_method: payment_method_params })

オーソリ、認証エラーなどその他各種エラーからのリカバリはドキュメントにて確認できます。

会員メールアドレスの変更、退会

Stripeに会員のメアドを連携しているため、会員のメアド変更や退会に伴ってStripeのメアド変更やサブスクリプション/顧客データの削除も行う必要があります。

こちらはサイトを退会したはずなのに請求が来てしまうなどの最悪の事態を避けるため、DBの更新とStripeの更新を同一のトランザクション内で処理しています。

f:id:mnmandahalf:20211201161142p:plain

Webhook

商品の仕様上判別したいイベントとしては

  1. 決済の成功
  2. 決済の失敗
  3. サブスクリプションのキャンセル(支払い済みサブスクリプションのユーザーによるキャンセル。キャンセル後も口コミ閲覧できる)
  4. サブスクリプションのキャンセル(決済失敗が続いた場合の自動キャンセル)
  5. サブスクリプションのキャンセル(重複購入を想定、管理画面からのキャンセル。キャンセル後はそのサブスクリプションは無効)

の5つが必要だったため、下記のイベントをリッスンすることにしました。

  • invoice.payment_succeeded → 1
  • invoice.payment_failed → 2
  • customer.subscription.deleted → 3 4 5
  • invoice.updated → 5

当初はcustomer.subscription.updatedで大部分が対応できるのではないかと思いましたが、こちらのイベントはinvoiceの発行時にトリガーされ、その後1時間程度経過してから課金がかかるようなので、invoiceがどうなったかまでは見ないようでした。

そのためinvoiceの支払い成功/失敗をフックに転職会議ではDBを更新しています。

返金対応

万が一バグなどが原因で管理画面からサブスクリプションのキャンセルを行うことを想定し、Webhookで管理画面からのキャンセルを判別できるようinvoice.updatedのイベントをリッスンしています。

ここが若干気持ち悪い部分になるのですが、3のパターンも5のパターンも決済が成功したサブスクリプションのキャンセルには変わりないため、Stripe APIで取得できるデータで両者を判別できませんでした。

両者の違いとしては5のパターンのみinvoice.updatedイベントが発行されるため、こちらを利用して区別するという小技を使っています。もっとスマートな方法をご存知の方がいらっしゃったらご教示願いたいです...。

メール

Stripeの強みでもあるメール送信機能を利用したかったため、前述の通りサイトの会員のメールアドレスを連携しそちらにメール送信される形をとっています。

お悩みポイント

各種リソースのデータをどこまでメディアのDBに保持するか

サブスクリプションを構成するリソースが多岐にわたっているため、どこまで保持するか悩ましかったです。

極論Stripe APIで全てのデータが取得できるため、一切のデータをDBに持たせないという判断も可能といえば可能でしたが、公式も推奨している通りAPIリクエストを減らすために自前でデータをキャッシュしておくことも必要かと思います。

今回は前述の通り機能として最低限必要かつStripeからキャッシュしておきたいデータをメディアのDBに持たせるという切り分け方をしています。

特に会員向けに複数商品追加の予定もなかったため、メディアのDBに保持しておくデータは

  • Stripeの顧客ID(customer.id
  • サブスクリプションのステータス(subscription.status
  • サブスクリプション開始日時(subscription.current_period_start
  • サブスクリプション終了日時(subscription.current_period_start
  • 支払い済みフラグ(invoice.paid

としました。

また、Stripeのダッシュボードの機能が充実しているため分析目的のデータの保持は今回はあまり考慮していない旨を添えておきます。

商品の請求サイクル見直し

当初は商品としてわかりやすい内容にしたかったため請求サイクルを1ヶ月(1ヶ月口コミ見放題)に設定する計画をしていました。

しかし売上の計上を行う際に商品提供期間が月を跨ぐ場合、日割りした金額をそれぞれの月の売上として計上する必要がありました。

Stripeの管理画面から発行するレポートのデータをインプットにしてシステム上で日割り計算を行うのですが、サブスクリプションの期間と決済・返金のデータを紐付けるレポートは出力できず、その部分のシステム開発をするコストも大きかったため、請求サイクルを決済成功から30日で固定することで期間の連携を不要とする形で決着しました。

よかったこと

ドキュメントやSDKが充実していて非常に開発しやすかったです。

カード画面については数行のコードを記述するだけでリッチなUI(トップ画像のVISAのロゴやバリデーションなど)を実装できた点は非常に素晴らしい体験でした。

またAPIに関連リソースをexpandして取得するオプションがあったため、無駄なリクエストの往復を減らすことができました。

ドキュメント読んでよくわからなかった点についても、24時間サポートチャットが利用できるため、その場で簡単な英語で問い合わせをして素早い問題解決に繋げられました。

日本のサポート担当の方もついてくださるため、日本語メールを書くことで営業時間内にやりとりすることも可能でした。

余談:Checkoutを利用するか、サーバを構築するか

基本的にCheckoutを使うことによって決済画面の開発が不要になるため、画面にこだわりがないのならばCheckoutを利用していただくのが非常に手軽で良いと思います。

一方で後からカード情報を変更したりサブスクリプションを停止するといった行動をユーザーに許可する場合は画面とサーバの構築が必要になってきます。

こちらはオペレーションコストとのトレードオフになってくるかと思います。

なお画面を構築せずCheckoutを利用する場合も、日本で税率を使用するにはStripe APIの利用が必須となるため、Checkout Sessionを生成するAPIの構築が必要となります。

We are hiring!

リブセンスではエンジニアの仲間を募集しています。

React, Rails, Go, AWSを中心にスキルを伸ばしたい方、エンジニアからもプロダクトの改善に携わることができる環境をお求めの方、ぜひ一緒に働きましょう。

Meetyにてカジュアル面談も行なっているため、気軽にお声かけください!