💰

Stripe Checkoutによる単発決済とエラーハンドリングの実装

2024/12/09に公開

https://jpstripes.connpass.com/event/332871/?fbclid=IwZXh0bgNhZW0CMTEAAR140oSpVRIg-DNqQx5iSVpWvvo9eeo4RGCUDTO9rSil_MOhtunEIHnhvCU_aem_Ga2yqxDkMor78tvXhJtaLA

こちらのイベントに登壇させていただきましたので、その発表内容を記事にまとめました。
発表資料はこちらになります。
https://www.docswell.com/s/nekoshita/KP2WW7-2024-11-12-145157#p1

どんな機能を作ったのか

僕は現在ColorSingというライブ配信アプリのサーバーサイドの開発を行っています。

https://colorsing.com/

ColorSingでStripeを使った単発決済機能を実装したので、その設計を紹介します。

ライブ配信を視聴してるユーザーは配信者への応援活動としてギフトを贈ることができます。ギフトを送るためにはアプリ内のコインが必要になります。そのコインを購入するための機能をStripeを使って実装しました。

Stripeで単発決済を実装する方法の比較検討

Stripeで単発決済を行う場合 Payments という機能を利用します。

https://docs.stripe.com/payments

Payments を利用するための実装方法が3種類用意されています。

  • Stripe Checkout
  • Payment Intents API
  • Charges API

Stripe Checkout

Stripe Checkout は決済フォームをStripeが用意してくれる実装方法です。

https://docs.stripe.com/payments/checkout#setup-future-usage

決済フォームは以下のどちらかの方法で実装します。

  • Stripeがホストしてくれている決済ページへリダイレクト
  • 自社サイトにフォームを埋め込む

Payment Intents API と Charges API

決済フォームを自社で用意する実装方法です。

  • ユーザーごとに複数のカード情報を保存しておいて支払い時に選択する
  • 決済フォームを見せずに決済する

などの用途ではこちらを利用する必要があります。
また、Payment Intents API と Charges API の比較はこちらです。

https://docs.stripe.com/payments/payment-intents/migration/charges#stripe-の支払いのための-api-について

Charges APIで使える Sources APIでしか利用できない支払い方法もあるらしいですが、Sources APIはすでに非推奨となっていたり

Payment Intents API は、すべての Stripe 製品と支払い方法の統合 API です。Charges API を廃止する予定はありませんが、新機能は Payment Intents API のみで使用できます。

と記載もあるので、基本的には Payment Intents API を利用するのがよさそうです。

Checkout Session を採用した理由

Checkout Session

Payment Intents API

  • メリット
    • 決済フォームや支払いフローを自由にカスタマイズできる
  • デメリット
    • 実装コストが高い

ColorSingでは、実装コストを抑え、なるべく早くユーザーに決済機能を届けられるメリットを重要とし、支払いフローとフォームを自由にカスタマイズができないデメリットを許容としたので、Checkoutを採用しました。

実装方針

課金まわりなので絶対に不整合を避けたいです。不整合が起きるパターンは以下のようなパターンがあります。

  • ColorSing内でコインを付与したのにStripeで決済できていない
  • Stripeで決済をしたのにColorSing内でコインを付与をしていない
  • 1回の決済に対してColorSingで決済2回分のコインを付与してしまう

ColorSingでは決済した後にStripeで決済を取り消す導線はないので、決済を取り消す場合の考慮は不要でした。
不整合を避けるためにColorSingでは以下の設計方針にしました。

  • コインを付与するのはStripeで決済が完了してから
    • ColorSing内でコインを付与したのにStripeで決済できてないというケースを避けらる
  • Stripeに対して何か操作を行う場合は、ColorSing内のDBにデータを作成してから行い、ステータスも管理する
    • ColorSingのシステムとして全く知らないデータ(決済、顧客など)がStripe上に存在しなくなる
    • 操作後に決済が完了したのか、失敗したのか、などをStripeの状態を追跡しやすくなる
    • ステータス更新とコインを付与する処理を同一トランザクション内で行うことにより、1回の決済に対してColorSingで決済2回分のコインを付与してしまうことを避けられる

また、クレジットカードによる支払いの場合、不正利用などの理由で決済が取り消される場合がありますが、このケースに対してシステム的な対応はしておらず、都度運用による対応を行なっています。

実際に行った実装

テーブル設計

ColorSingのDBにPaymentsテーブルを作成しました。

Name Type PK Description
PaymentID STRING o ColorSingが発行するUUID
Status ENUM PENDING,COMPLETED,EXPIREDのいずれか
CheckoutSessionID STRING Stripeが作成したCheckoutのID

Paymentsテーブルは以下の3つのステータスを持ちます

  • PENDING: 決済待ち
  • COMPLETED: コイン付与完了
  • EXPIRED: 有効期限切れ

ColorSingのPaymentsテーブルにあるレコードのうち、ステータスが PENDING のPaymentIDに紐づくCheckoutSessionをStripeから取得することで決済状況を照合することができます。

決済処理を行う時は、Paymentsテーブルにレコードを保存してから、StripeのCreate CheckoutSession APIをコールします。
Stripeに作成した CheckoutSesion がどうなったかをwebhookとバッチ処理で追跡し、それに合わせてPaymentsレコードのStatusを更新します。

決済処理

スクリーンショット 2024-11-06 1.26.56.png

  1. ユーザーがコインを購入するボタンをタップし、ColorSingサーバーのAPIをコールする
  2. ColorSingサーバーでPaymentIDを発行し、PENDINGステータスでPaymentsテーブルに保存する
  3. StripeのCreate CheckoutSession APIをコールし、ユーザーをCheckoutSession決済画面にリダイレクトする
  4. ユーザーがStripeの決済フォームに必要な情報を入力し決済する
  5. 決済完了するとStripeがwebhookを送ってくれる
  6. ColorSingサーバーでwebhookを受け取り、同じトランザクション内で、 コインの付与を実施し、PaymentsレコードのステータスをCOMPLETEDに更新する

決済処理のポイント①

StripeのCheckoutSessionを作成する前にColorSingでPaymentIDを発行し保存する

ColorSingのシステムとして全く知らないデータがStripe上に存在すると追跡が非常に困難になります。

もし事前にデータを作っておかなかった場合、全ての決済に対して決済処理が正常に行われたかどうかを確認するためには、Stripeから全てのCheckoutSessionを取得し、それぞれの状態を確認し、決済完了してるものに対してはコインが付与されたか、決済完了してないものに対してはコインを付与してないか、を照合しなければなりません。

決済処理のポイント②

ColorSingサーバーで発行したPaymentIDをStripeのCheckoutSessionのClientReferenceIDに保存する

ClientReferenceIDとは、Stripeのさまざまなオブジェクトに付与できる参照用のIDです。
https://docs.stripe.com/api/checkout/sessions/object#checkout_session_object-client_reference_id

ClientReferenceIDにColorSingが発行したPaymentIDを保存しておけば、PaymentIDに紐づくCheckoutSessionを特定することができます。
Stripe上にはCheckoutSessionが作成されているのに、ColorSingのDBにCheckoutSessionIDの保存に失敗してしまった場合にも、CheckoutSessionを特定できるように備えておく必要があります。

決済処理のポイント③

StripeのPaymentsテーブルでステータス管理を行う

ColorSingのPaymentsテーブルは以下の3つのステータスを持ちます

  • PENDING: 決済待ち
  • COMPLETED: コイン付与完了
  • EXPIRED: 有効期限切れ

ColorSingのPaymentsテーブルにあるレコードのうち、ステータスが PENDING のPaymentIDに紐づくCheckoutSessionをStripeから取得することで決済状況を照合することができます。

決済処理のポイント④

ColroSingのPaymentsのステータス更新はコイン付与と同一トランザクション内で行う

1回の決済でコインを付与を重複して行なっては絶対にいけません。コインを付与する処理とColorSingのPaymentsテーブルのステータス更新の処理を同一トランザクション内で行うことで重複処理を防げます。

エラーハンドリング

サービス運営者として「ユーザーが料金を支払ったのにコインが付与されない」というケースだけは絶対に避けなければいけないです。
ColorSingではユーザーが決済したのにコインを付与できなかった場合備えて、復旧用のバッチ処理を実装しました。

StripeのWebhookとは

https://docs.stripe.com/webhooks

Stripeには何かイベントがあった時にサービス側のWebhookを叩いてくれる仕組みがあります。
たとえば、ColorSingでは、決済が完了した時に発行されたWebhookイベントを受け取り、コインを付与する処理を実行しています。

スクリーンショット 2024-10-29 3.49.08.png

StripeのWebhookはエラーを返すとリトライしてくれる仕組みがあり、最大3日間リトライしてくれます。
なので、基本的にはWebhookによるリトライを期待したエラーハンドリングの処理を実装することになります。

ですがデータ不整合が許されない課金処理であることから、Webhookが失敗するケースも気になったので、検討することにしました。

Webhookの失敗に備える必要はあるか

ColorSingでは以下のような、もしものことが起きた場合にも決済処理を正常に行えるよう実装しました。

ColorSingサーバーのwebhook受け取り処理が3日間以上正常に動作しなかった場合
webhookのリトライの有効期限を過ぎてしまうので、ユーザーの決済に対してコインを付与する処理を実施できなくなってしまいます。

Webhookの作り直し(バージョン変更するなど)によりwebhookイベントが欠損した場合

Stripeでwebhook設定を行うとCheckoutSessionが作成された時、決済が行われた時、有効期限が切れた時、などにwebhookイベントを送ってくれます。Stripeのwebhookにはバージョンがあり、webhookを受け取るサーバー側のライブラリのバージョンと一致してる必要があります。また、Stripeではwebhookを複数作成することができます。

たとえば、Stripeのwebhookのバージョンを上げる時には以下の手順で行えばwebhookイベントを欠損せずにバージョンアップを行えます。

(Stripeにはv1のwebhookがすでにあり、webhook受け取りサーバーのバージョンもv1だとする)

  1. Stripeにv2のwebhookを作成する
  2. webhook受け取りサーバーのバージョンをv2にする
  3. Stripeのv1のwebhookを削除する

1の時、Stripeのv1とv2の両方のwebhookからイベントが送られてきますが、v2の方はエラーします。
2の時、Stripeのv1とv2の両方のwebhookからイベントが送られてきますが、v1の方はエラーします。
このパターンではエラーは起きますが、webhookイベントが欠損することはないですし、全てのイベントを正常に処理することができます。

もし間違えて、たとえば、webhook受け取りサーバーのバージョンを先にv2にしてしまうと、webhook受け取りサーバーのv1から発行されたイベントを正常に処理できなくなってしまうので、正常に処理できないwebhokイベントが発生してしまいます。

ColorSingでは実際にバージョンアップの手順を間違え、正常に処理できないwebhookイベントが発生してしまいました。後述するバッチ処理による復旧のおかげでコイン付与処理は正常に行うことができました。

実際にWebhookイベントのバージョンアップを行うときは、こちらのドキュメントを参考に、クエリパラメータを利用するとエラーさせることなく、バージョンアップを行うことができます。

https://docs.stripe.com/webhooks/versioning#create-a-new-disabled-webhook-endpoint

Webhookが失敗した時の復旧方法の比較検討

2パターンあります。

  1. Stripeから全てのCheckoutを取得し、コインを付与したか照合する
  2. ColorSingのDBで全てのCheckoutSessionのステータス管理を行い、未完了ものだけStripeのChekcoutSessionと照合する

1は処理コストが大き過ぎるのと、永遠に増え続けるので2を選択しました。
ColorSingではCheckoutSessionを作成する前にPaymentIDを発行し、Paymentsテーブルにステータスと一緒に保存しているので2を実現することができます。
2で行うことにより復旧処理が低コストになること以外にも様々なメリットがあります。

  • 照合処理がシンプルになる
  • 外部サービスは何が起きるかわからないので内部に信頼できるデータを保持できる
  • StripeのCheckoutはStripeのダッシュボードでみれないので状況の把握をしやすい
  • 問題が発生した時に調査しやすい
  • Stripe以外の外部サービスに対しても同様の設計を行える

Webhookが失敗した時のためのバッチ処理

実際に実装した復旧用のバッチ処理は以下の処理を行なっています。

  1. PaymentsテーブルからPENDINGステータスのレコードを取得する
  2. Get CheckoutSession APIでStripeからCheckoutSessionを取得
  3. 状態に合わせてハンドリング
    • 決済未完了 -> 何もしない
    • 決済完了 -> コインを付与し、COMPLETEDステータスに更新する
    • 有効期限切れ -> EXPIREDステータスに更新する

1年運用した振り返り

懸念していた問題は起きたか?

最も懸念していたのは「ユーザーが決済したのにコインが付与されない」という不整合です。Stripeの運用を始めてから10万件以上の決済が行われましたが、webhookとバッチ処理による復旧のおかげでデータ不整合は0件でした。

Webhookが失敗した時のためのバッチ処理は役に立ったのか?

役に立ちました。
Webhookサーバー自体はほぼエラーが発生することもなく、エラーが発生してもリトライによって決済処理は正常に完了しています。
ColorSingで役に立ったのは、Stripeの仕様変更により、ライブラリのバージョンを急いで更新しないといけなくなった時でした。

本来は以下の流れで更新すべきです。
(Stripeにはv1のwebhookがすでにあり、webhook受け取りサーバーのバージョンもv1だとする)

  1. Stripeにv2のwebhookを作成する
  2. webhook受け取りサーバーのバージョンをv2にする
  3. Stripeのv1のwebhookを削除する

しかし、ColorSingではこの手順を間違え、2を先に行なってしまいました。
その結果、webhookのイベントを受け取ってはいるがコイン付与処理を実施できない、という状況になってしまいました。いくらv1のwebhookがリトライしてくれても、受け取り側のサーバーはv2のため、いつまで経っても正常に処理することができません。
バッチ処理によってコイン付与処理を行えるように設計していたおかげで、webhookイベントを処理できなくても、バッチ処理によってコイン付与を正常に実施することができました。

Stripeの挙動を正確に把握できていたら未然に防げたのですが、外部サービスの挙動を正確に把握するのは難しいですし、ライブラリの更新も急遽必要になったしまったため、事前の調査と考慮が漏れてしまいました。

実装時に困ったこと

どのWebhookイベントを受け取ればいいのかわからない

Stripeのwebhookは大変便利な機能なのですが、イベントの種類がたくさんあるのでどのイベントを受け取ればいいのか選別が非常に大変です。Checkoutを利用した決済を行なっている場合でも、CheckoutSessionの作成と同時に裏側でさまざまなオブジェクトがStripe上に作成され、それもwebhookイベントとして受け取ることができます。

仕様書をよみつつ、実際に動作確認して、操作ごとにどんなイベントが送られてるのか、イベントごとにオブジェクトのどのデータが更新されているのか、などを確認する作業はかなり大変でした。

結果的にColorSingでは以下のイベントを受け取っています。

  • 決済まわりの処理を行うために受け取っているイベント
    • checkout.session.completed
    • checkout.session.expired
    • checkout.session.async_payment_succeeded
    • checkout.session.async_payment_failed
  • 不正利用された時にアラートを飛ばすために受け取っているイベント
    • charge.dispute.created
    • radar.early_fraud_warning.updated

CheckoutSessionをClientReferenceIDで検索できない

StripeのCheckoutSessionのClientReferenceIDに、ColorSingが発行したPaymentIDを保存しています。バッチ処理では、StripeのPaymentsテーブルからステータスが PENDING のものを抽出し、PaymentIDに紐づくCheckoutSessionをStripeから取得しています。

StripeからCheckoutSessionを取得する時に、事前にCheckoutSessionIDがわかっていれば、特定のCheckoutSessionを取得することができます。

https://docs.stripe.com/api/checkout/sessions/retrieve

このようにClientReferenceIDをもとに特定のCheckoutSessionを取得したかったのですが、現在のAPIではそれができないようです。なので、ColorSingでは List all Checkout Sessions API を使い、顧客ごとに全てのCheckoutSessionを取得し、ClientReferenceIDと一致するのも探しています。

https://docs.stripe.com/api/checkout/sessions/list

(補足1)
実際には、ColorSingのDB内にStripeのCheckoutSessionIDも保存しているので、ほとんどのケースで特定のCheckoutSessionだけを取得しています。ClientReferenceIDによるCheckoutSessionを取得する方法は、ColorSingでPaymentIDを発行した後にCheckoutSessionIDを保存し損ねたというレアケースのための対応として行なっています。

(補足2)
Payment Intent においては、 search というAPIが存在しており、metadataが一致するPayemnt Intentを検索することが可能です。しかし、Checkout Sessionには search APIは存在しません。
https://docs.stripe.com/api/payment_intents/search

コンビニ決済の有効期限が切れたかどうかを知るためにPaymentIntentの状態を確認する必要がある

Checkoutによるコンビニ決済において、有効期限切れなのかを判定するのにCheckoutSessionオブジェクトだけでなく、PaymentIntentオブジェクトの状態を参照する必要がありました。
(コンビニ決済による支払いが完了したかどうかを知るだけならCheckoutSessionだけでOKです)

Checkoutでコンビニ決済を選択すると、CheckoutSessionの statuscompleted になります。

https://docs.stripe.com/api/checkout/sessions/object#checkout_session_object-status

スクリーンショット 2024-10-23 3.23.20.png

コンビニ決済が完了すると、CheckoutSessionの payment_statuspaid になります。

https://docs.stripe.com/api/checkout/sessions/object#checkout_session_object-payment_status

スクリーンショット 2024-10-23 3.45.18.png

ColorSingでは、決済が成功する、もしくは、有効期限が切れるまで、バッチ処理によってデータを照合し続けるため、コンビニ決済の有効期限が切れたかどうかを知る必要がありました。

しかし、CheckoutSessionオブジェクトからはコンビニ決済の有効期限が切れているかどうかを知ることができず、PaymentIntentオブジェクトを別途取得し、その状態を確認しなければなりませんでした。

PaymentIntentオブジェクトは、StripeのCheckoutをPaymentsモードで利用した時にStripeの裏側で作成される支払いに関する詳細情報を保持したオブジェクトです。

https://docs.stripe.com/api/payment_intents

CheckoutSessionオブジェクトはPaymentIntentオブジェクトを保持しておらず、PaymentIntentIDしか保持していません。

https://docs.stripe.com/api/checkout/sessions/object#checkout_session_object-payment_intent

なのでコンビニ支払いの状態を確認する時は

  1. WebhookでCheckoutSessionオブジェクトを受け取る
  2. Get PaymentIntent API をたたき、PaymentIntentオブジェクトを取得する
  3. CheckoutSessionとPaymentIntentオブジェクトの状態から、コンビニ決済の状態を判断する

という処理を行なっています。

コンビニ決済の有効期限切れかどうかを知る目的以外ではPaymentIntentのことを一切考慮せずに実装できたのですが、ここだけPaymentIntentのことを考慮する必要が出てしまいました。

以下を満たしている場合、コンビニ決済が有効期限切れと判断しています。

  • PaymentIntentのstatusが requires_payment_method

https://docs.stripe.com/api/payment_intents/object#payment_intent_object-status

https://docs.stripe.com/api/payment_intents/object#payment_intent_object-last_payment_error-code

最後に

ここまで読んでいただきありがとうございました!
もし、何か間違っていたり、他に良い実装がある、など気づいたことがあればコメントいただけますとめちゃくちゃ嬉しいです🙏

また、この記事に出てきたColorSingでは現在エンジニア採用やっているので、興味ある方は採用ページを見てください!
https://corp.colorsing.com/recruit

Discussion