FirebaseとStripe Billingを使ったサブスクリプションの支払いエラー対応

# 3行で

- Stripe Billingの「クレジットカード払い」で発生する支払いエラーへの対応について書きます
- この記事では、支払いエラーを「予防・検知・解消」の３つに分けて説明します
- 支払いエラーは、Stripe Webhook、Firebase Cloud Functions、Stripeのスマートリトライ機能を使うと安全に処理できます

# 支払いエラーの対応を3つに分ける

私は今年の9月に月額課金をもつサービスをリリースしました[^1]。
そこで発生するクレジットカードの支払いエラーは、残高不足、カードの有効期限切れなど理由は様々です。

しかし、支払いエラーの理由はあまり重要ではありません。
エラーが発生しても、お客様のカードや口座情報の詳細を知るのは、セキュリティ的に難しいからです[^2]。

ほぼ全てのケースでカードの所有者本人に原因を確認してもらう作業が必要で、`「決済が失敗した理由は、当社では分かりかねますので、クレジットカード会社様へお問い合わせいただけますと幸いです」`的なCS対応になります。

したがって、**発生した支払いエラーの詳細に関係なく、支払いエラーをどういうフローで対処するか**が大事です。

そこで、私は支払いエラーの対応を3つに分けて考えました。

- 1. 支払いエラーの予防
- 2. 支払いエラーの検知
- 3. 支払いエラーの解消（復帰・降格）

この3つに対して、Stripe Webhook、Firebase Cloud Functions、Stripeのスマートリトライ機能を使って安全に処理しよう、というのがこの記事の趣旨です。

1. 予防 | 2. 検知 | 3. 解消（復帰） | 3. 解消（降格） | 
:-: | :-: | :-: | :-: | 
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/2b48735e-5e19-5f29-ac70-6be44a0f2eb5.jpeg width=180/> | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/ff7e3383-fc9f-824e-4ff6-725c690193cc.jpeg width=180/> | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/7c2231e0-3d57-470b-52b9-035c668f70a6.jpeg width=180/> | <img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/08961e6f-4944-7044-671a-d87d4ab5d87d.jpeg width=180/> | 


# 1. 支払いエラーの予防 

## Stripe Elementsを使う

Stripeは、[Stripe Elements](https://stripe.com/jp/payments/elements)というクレジットカード登録フォームのUIライブラリを公開しています。

これでカード情報の未入力や、存在しないカード番号のバリデーションなどは可能です。
使い方は、ドキュメントやQiitaの記事を参照してください。

- [Stripe.js & Elements を利用して決済フローを理解する](https://qiita.com/y_toku/items/7e51ef7e69d7cbbfb3ca)

## Stripe Error codesのハンドリング

仕様に依りますが、多くのサービスではクレジットカードの登録と、サブスクリプションプランの登録は同時に行われます。
そして、トライアルがないサービスの場合、即座に引き落としが発生します。

このとき、クレジットカードの残高不足や、何かしらの制限がカード会社に付与されていた場合、登録時にエラーが出ます。
エラーハンドリングが出来るようにエラーコードの一覧には目を通しておきましょう。
特に `processing_error` と `card_declined` は頻繁に発生するので、ユーザーへ分かりやすいエラーをクライアント側で表示するべきです。

- [Error codes | Stripe](https://stripe.com/docs/error-codes)

## カードの有効期限が来ることを通知する

StripeにはWebhook機能があり、支払いに関するイベントを検知してSlackに通知できます。
Webhookへ登録するエンドポイントは、Firebase Cloud Functionsの`functions.https.onRequest`で対応できます。

- [HTTP リクエスト経由で関数を呼び出す](https://firebase.google.com/docs/functions/http-events?hl=ja)

クレジットカードの支払いでまず検知したいのは、有効期限が近いカードを登録しているユーザーの情報です。
`customer.source.expiring`[^3] というイベントで月末に有効期限が切れるカードの情報を検知してSlackに通知できます。

月末で有効期限切れる通知 | 
:-: | 
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/7f2986d1-92a0-7b6f-3c34-0f698e9e920c.png width=180/> | 

これで、該当ユーザーに`「カードの有効期限が迫っているので、新しいカードが届いたらカードの更新をお願いします」`とCS対応ができます。

もちろん、Stripeに登録されているメールアドレスへ有効期限が迫っているメールを送信することもStripeの設定からできます。

# 2. 支払いエラーの検知

## 支払いエラーをSlackへ通知する

支払いエラーも`invoice.payment_failed`[^4]というイベントで検知できます。
こちらも、有効期限と同様にメールで通知することも可能です。

これで例えば、 `「ご登録いただきましたお客様のクレジットカードでの決済が失敗したと、システムより連絡がありまして、現在一時的に無料プランに変更しております。決済が失敗した理由は当社では分かりかねますので、クレジットカード会社様へお問い合わせいただけますと幸いです」`とCS対応することが可能です。

支払いエラーの通知 | 
:-: | 
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/60eab449-88a2-bdad-cc8d-b4b11d05c65d.png width=180/> | 


## エラー発生と同時にユーザーの支払い状態をエラーにする

仕様に依りますが支払いエラーが起きた場合、ユーザーのサービス利用を制限しないといけません。

Firebaseを使っている場合、Firestoreにユーザーの支払い情報をもたせるので、`invoice.payment_failed`[^4]をトリガーにしてユーザーの支払い情報をエラーにしましょう。

少し話はそれますが、このときユーザーの基本情報と、支払い情報は別コレクションにしておき、誤って関係のないデータまで更新されない工夫はしておくべきです。

下記の記事のSame IDという手法が参考になります。

- [Cloud Firestoreを実践投入するにあたって考えたこと : ■ Same ID](https://qiita.com/1amageek/items/d606dcee9fbcf21eeec6#-same-id)

# 3. 支払いエラーの解消

最後にエラーの解消です。解消は、２パターンあります。

- ① エラーから有料へ（復帰）
- ② エラーから無料へ（降格）

この2つのパターンを、Stripe Webhook、Firebase Cloud Functions、Stripeのスマートリトライ機能の3つを活用して対応します。

## Stripeのスマートリトライ機能について

スマートリトライ機能とは、支払いエラーが発生した後にStripeの機械学習アルゴリズムが「適切なタイミング」で支払いをリトライしてくれる機能です。

- https://stripe.com/docs/billing/automatic-collection#smart-retries

スマートリトライ機能で抑えておくべき機能は以下の３つです。

- 最大4回に渡ってリトライが行われる
- リトライ期間を `1週間`、`2週間`、`3週間`、`1ヶ月` から選択できる
- リトライがすべて失敗した場合に、支払いステータスを `キャンセル`、`未払いマーク`、`現状のまま` のいずれかに変更できる

このリトライスケジュールは、Stripeのダッシュボードの該当のインボイスで見えます。

次回のリトライスケジュール | 
:-: | 
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/b23cc550-dd4b-ec34-4777-2a72b345eea7.png width=960/> | 

ちなみに、スマートリトライの「適切なタイミング」が具体的にどのように判定されるかは、ドキュメントを見ても詳細にはわかりませんでした。

しかし、運用してわかったのは、カードの新たなトークンを取得して更新をすると、リトライされることが多いです。
なので、登録しているカードとは別のカードに変更するとリトライが行われたりします。


## ① エラーから有料へ（復帰）

何かしらの理由で支払いエラーになった場合、スマートリトライが適切なタイミングで支払いを再試行します。
もし、ユーザーが金額をチャージしたり、何らかの理由でカード会社の制限が解除された場合、リトライで支払いが成功するかもしれません。

このときエラーとは逆に`invoice.payment_succeeded`[^5]をトリガーにしてエラーにしたユーザーの支払い状態を元に戻します。
私の場合、支払い状態を元に戻せたことをSlackへ通知して、CS対応の成果があったことを確認しています。

リトライで支払いが成功した通知 | 
:-: | 
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/ea486ab7-f1ea-f6a0-5289-2bce30cfe03f.png width=180/> | 


## ② エラーから無料へ（降格）

今度は、残念なことにリトライを繰り返しても支払いが成功しない場合です。
ここはサービスの仕様によって、どう対処するか別れますが、私の場合はサブスクリプションをキャンセルして無料プランへ降格させています。

サブスクリプションをキャンセルすると、今度は`customer.subscription.deleted`[^6]というイベントが発生するので、これをトリガーにユーザーの支払い情報をエラーから無料プランへ変更します。

このとき、ユーザーがサービスの解約画面で自分からサブスクリプションをキャンセルした場合でも、`customer.subscription.deleted`[^6]は発生するので注意してください。

サービスの解約画面からサブスクリプションをキャンセルする場合は、支払いエラーではないユーザーに限るなど、何らかの制限を設けて `customer.subscription.deleted`[^6]のWebhookで意図しない処理が起きないように注意してください。

そして、リトライが失敗し続けてサブスクリプションをキャンセルにした場合、未回収の料金が発生します。
この料金への対応は、サービス運営の考え方次第なので、ここでは言及しません。

リトライで定額支払いがキャンセルされた通知 | 
:-: | 
<img src=https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/37385/57c99712-8d87-9565-7116-8a72c26cc950.png width=180/> | 


# 補足

## Stripe Webhookはstripe-signatureを必ず使うこと

虚偽のイベントを通知されてWebhookが悪用されないように、stripe-signatureを使って安全にWebhookを処理してください。
なお、Firebase Cloud Functionsで対応する場合、`request.rawBody`を使わないと処理できないので注意してください[^7]。

- [Checking webhook signatures](https://stripe.com/docs/webhooks/signatures)

```js:checkStripeSignature.ts
import * as functions from 'firebase-functions';
import * as Stripe from 'stripe';
const stripe = new Stripe(functions.config().stripe.token);

export = function(
  request: functions.https.Request,
  secret: string
): Stripe.events.IEvent {
  try {
    return stripe.webhooks.constructEvent(
      request.rawBody, 
      <string | string[]>request.headers['stripe-signature'],
      secret
    );
  } catch (e) {
    console.error(`🧨 checkStripeSignature: ${JSON.stringify(e)}`);
    throw e;
  }
};
```

## どの環境で発生したStripe Webhookなのか検知する

Stripeの環境は、本番とテストしかありません。
これだと、自分のサービスが本番、ステージング、開発など複数の環境をもつ場合、どの環境で起きたWebhookなのか検知できず開発時に困ります。

どう対応すれば良いのかは、下記のブログに書いたので参考にしてください。

- [FirebaseとStripe Billingを組み合わせるとき、stripe.customerのdescriptionとmetadataが便利です](https://tech.studyplus.co.jp/entry/2019/11/18/101638)

# おわりに

私は、今回のプロジェクトでStripeを使って、初めてサブスクリプションを実装しました。

Stripeのドキュメントは頻繁にアップデートされており、ドキュメントのサポートが素晴らしいです。さらに、理解できないことはカスタマーサポートに連絡すればスピーディに対応してくれました。

しかし、支払いエラーに関してはどう取り組めば安全なのか、対応フローが頭の中で整理できず、実装していたときは不安でした。

StripeのCSへ相談するにしても、サービスの仕様を考えるのはStripeではなく、こちら側です。
最初の頃は、いわゆる「何がわかってないのかわからない」状態で相談する感じになり、結構苦しみました😅

この記事で支払いエラーが発生したとき、とりあえず**「何を抑えておくことが必要なのか」**という自分なりの答えは示せました。

**一応ですが、9月からサービスを開始して現時点で本記事の支払いエラー対応で、対応出来なかったユーザーは1名だけです。**
その1名は、登録したカードの口座とチャージした口座が別だったというヒューマンエラーでした。

最後に、本記事がこれからStripeでサブスクリプションを実装する人に少しでも役立つと幸いです😊


[^1]: [FlutterとFirebaseで新しいサービスをリリースしました](https://tech.studyplus.co.jp/entry/2019/09/27/113643)
[^2]: [顧客の支払いが拒否されました。なぜ拒否されたのでしょうか](https://support.stripe.com/questions/jp-why-was-a-customers-charge-declined)
      [Stripe外からアプリの支払いを受け取った時の対処手順](https://blog.craftz.dog/stripe-payment-adjustment-df5e1c996c12)
[^3]: https://stripe.com/docs/api/events/types#event_types-customer.source.expiring
[^4]: https://stripe.com/docs/api/events/types#event_types-invoice.payment_failed
[^5]: https://stripe.com/docs/api/events/types#event_types-invoice.payment_succeeded
[^6]: https://stripe.com/docs/api/events/types#event_types-customer.subscription.deleted
[^7]: https://stackoverflow.com/a/53899407/10578728