こちらはエムスリー Advent Calendar 2023の22日目の記事です。
こんにちは、エムスリーエンジニアリンググループ、デジスマ診療チームの山本 (id:shunyy) です。
医療機関向けSaaSであるデジスマ診療は、開発開始からちょうど3年が経ち、現在では予約・問診等、多様な機能を提供していますがリリース当初は決済機能のみを提供していました。そんなデジスマのコア機能である決済機能はStripe Connectを利用しており、今回は3年間運用した学びを共有したいと思います。
デジスマ診療のプロダクトの内容は以下のスライドを御覧ください。
- そもそもStripe Connectとは何なのか
- Stripe Connectのアカウントタイプ・支払いタイプについて
- デジスマ診療の全体構成
- 追加開発事例
- 教訓・Tips
- まとめ
- We are hiring!!
そもそもStripe Connectとは何なのか
Stripe Connect は、不特定多数の販売者と購入者を管理するプラットフォームビジネスのためのソリューションです。オンライン上の顧客管理や複雑なお金の流れの自動化を、簡単に実現することができます。
つまり、多数の店子(デジスマ診療だと各医療機関、ECサイトだと各売り主)の商品を、単一のプラットフォーム上で提供する場合に課題となる、以下のような業務をまるっとカバーしてくれるサービスです。
- 各店子の本人確認、振込先口座情報の登録
- プラットフォーム上での決済機能
- であがった売上の各店子の銀行口座への入金
- 各店子が売上等の各種統計情報を確認できるダッシュボード
また、資金の流れをざっくり図にすると以下のようなものになります。
基本的にプラットフォームとしては、このときの決済金額から各種手数料を引いたものと入金金額の差分が売上となるわけです。
Stripe Connectのアカウントタイプ・支払いタイプについて
Stripe Connectを使うに当たってまず、2つのタイプを決める必要があります。詳しい説明は公式ページが詳しいのでざっくりとした説明になりますが、
- アカウントタイプ
- 上記業務のどこまでをStripe上で行うか、プラットフォームサイト上で行うか。
- Stripeに任せる画面が多いほど実装は容易になりますが、ユーザー体験を制御することが難しくなります
- Express, Custom, Standardのどれか
- 上記業務のどこまでをStripe上で行うか、プラットフォームサイト上で行うか。
- 支払いタイプ
- 顧客の支払った金額をどのようにプラットフォーム・店子側で分配するか。
- 決済ごとにプラットフォーム手数料を徴収するか or 月ごとにまとめて, etc.
- ダイレクト支払い、デスティネーション支払い、支払いと送金別方式のどれか
- アカウントタイプによって選択できるものが違うので任意のものが選べるわけではない
- 顧客の支払った金額をどのようにプラットフォーム・店子側で分配するか。
のそれぞれを作成するプロダクトに応じて事前に決める必要があります。
デジスマ診療はアカウントタイプはExpress、支払いタイプは「支払いと送金別方式」を選択しました。これは、デジスマの手数料徴収の仕組みが、決済ごとの割合の手数料と、決済タイミングで確定しない固定の手数料の2種類があり、実現するにはこの方式しかなかったためです。が、基本的にこの連携方式はおすすめしません。シンプルな決済ごとの利用料徴収であれば、Express + 「デスティネーション支払い」を利用することをおすすめします。
デジスマ診療の全体構成
デジスマ診療での全体のシステム構成は以下のようになっています。
デジスマ診療で採用しているイベント駆動でのPush/メールの配信システム以外は一般的なStripe組み込み時の構成となっていると思います。図としては省きましたが、細かくは支払いの信頼性向上のためにSQSでの非同期支払いリトライ等も行っています。これだけシンプルに決済サービスを提供できているのは、StripeのIdempotency Keyを利用した冪等リクエストのための仕組みや、Webhookによるイベント通知と自動リトライといったStripeプラットフォーム側の強力なバックアップがあるためで、改めて非常に使いやすいプラットフォームだなと感じます。
追加開発事例
サービスが拡大するに連れていくつかの大型開発を経てきているのでそのいくつかを紹介します。
Amazon AppFlowを利用したBigQueryとのデータ連携
サービスの成長に伴い、当然Stripe上のデータと他のデータとjoinした分析を投げたいというニーズが高まります。特に会計周りでは、売上、手数料、最終的なStripe口座残高を正確に把握したいというニーズがあります。PaymentIntentに関わるようなデータ(決済額・決済手数料)はWebhook経由で取得しているものもありますが、以下のようなデータは性質上PaymentIntentのライフサイクルとは独立して徴収されるため把握が難しいです。
- Connect口座管理費用
- 月に一度まとめて請求
- Connect入金手数料
- 月に一度まとめて請求
- Radar利用料
- 一日分まとめて数日後に請求
- 3D Secure利用料
- 一日分まとめて数日後に請求
またWebhook経由でのデータ連携ではデータの完全性の保証が難しく、そういった理由でも別経路でのデータの連携の必要がありました。
Amazon AppFlow
Stripeの公式機能としてSnowflakeとRedshiftとの連携機能は提供されていますが、デジスマはDWHとしてBigQueryを利用しているため、AWSのマネージドなサービス間データフローであるAppFlowを利用しました。
S3 -> BQ間もLambdaで実装したためサーバーレスにデータフローを構築でき、低コストで安定して運用できています。AppFlowでのS3 -> BQ間連携や、BigQuery Data Transfer Service などは検討しましたが、日別テーブルやスキーマ自動検出等を利用したかったため、現状は単にLambdaでloadしています。
BigQueryにさえ入ってしまえばGoogle SpreadsheetのConnected Sheets機能を使って自動で経理に提出するデータを生成できるので、これまでは毎回経理チームにStripeのダッシュボードから支払い関連のデータをダウンロードしてもらっていましたが、手作業が不要になりました。
複数プラットフォームアカウントの併用
デジスマ診療サービスでは、提供先医療機関の多様化に伴い、複数のプラットフォームアカウントを作成する必要がありました。かなり特殊な状況ではあるので何を言っているんだという感じだと思いますが、事実そういう状況でした。
複数プラットフォームを併用するとなると以下のような問題があります
- ユーザーの登録した決済手段はプラットフォームに紐づくため、決済先の医療機関によっては、ユーザーに再度決済情報を入力して貰う必要がありそう
- Webフロントエンドやアプリのバイナリに組み込まれたPublishable keyはプラットフォームに紐づくため、Stripe.initのような初期化を医療機関ごとに複数回行う必要がありそう
- プラットフォームとしての認証や、WebhookのEndpoint追加、上記データフロー等、1から設定・構築をやり直す必要がある
3.に関しては、やるだけ、ではありますし、2.に関してもAPIから医療機関ごとに動的に公開可能キーを返す仕組みに変更することで対応できました。
しかし1.に関しては当初解決方法が思いつかず、解決できない場合、ECサイトに例えると
- 商品Aを購入しようとするときにクレカ情報を入力する
- 商品Bを購入しようとすると、再度クレカ情報を要求される
- 以下略
というかなりありえないUXとなり、サービスとして許容できないとして行き詰まっていました。図にすると以下のような感じで
言われてみればそう簡単にプラットフォーム間で決済手段が共有できても困るなという状況ではあります。
ということで、困り果ててStripeの担当の方に相談したところ、プラットフォームアカウント同士を連結することで、アカウント間で決済方法をコピーできるということを教えていただきました。
つまり上の図のように、プラットフォームアカウント同士を、互いに店子として連結することで、通常のConnectでダイレクト支払いで決済方法を共有するときに利用するような、PaymentMethodの複製を利用できるということでした。プラットフォームアカウントもConnectも同様に acct_***
のようなIDで識別することは認識していましたが、これらを並列に扱えるという発想がなかったので、すぐには理解できませんでしたが、この事実のお陰で比較的シンプルに実装できました。
最終的な処理の流れ
最終的な処理の流れとしては以下のようになりました
- サブのプラットフォームでユーザー(決済者・患者)作成済みかチェックする
- 1.がnoの場合、サブプラットフォームでユーザーを作成する
curl https://api.stripe.com/v1/customers \ -u {メインプラットフォーム用のクレデンシャル} \ -H "Stripe-Account: {サブアカウントID}" \ --data-urlencode description="Test Customer"
- 該当ユーザーに決済情報が複製済みかチェックする
- 3.がnoの場合サブプラットフォームのユーザーにPaymentMethodをコピーする
curl https://api.stripe.com/v1/payment_methods \ -u {メインプラットフォーム用のクレデンシャル} \ -d customer={サブアカウント上のユーザーID} \ -d payment_method={メインプラットフォーム上のPaymentMethod ID} \ -H "Stripe-Account: {サブアカウントID}"
- 決済するときはダイレクト支払いと同じようにStripe-Accountヘッダーを追加する
curl https://api.stripe.com/v1/payment_intents \ -u {メインプラットフォーム用のクレデンシャル} \ -d amount=1000 \ -d currency=jpy \ -d on_behalf_of={サブアカウント上の店子アカウントID} \ -d payment_method={4.で複製したPaymentMethod ID} -d customer={サブアカウント上のユーザーID} \ -d payment_method={メインプラットフォーム上のPaymentMethod ID} \ -H "Stripe-Account: {サブアカウントID}"
こうすることで、APIとしてはメインプラットフォーム上で決済を行っている感覚で、サブプラットフォーム上での決済を実現できます。
教訓・Tips
ここまではデジスマ特有の事象も多かったですが、以降は3年間の運用の中で学んだ、一般的に適用できそうな、いくつかの教訓/Tipsの共有です。
Webhookのイベント逆転問題に気をつけよう
Webhookで各種イベントが受け取れることはとても便利ではありますが、StripeのWebhookの仕組み上、予期せぬエラーでのリトライやネットワークの状況次第では、実際の事象の時系列とは逆順にイベントが届くことがあります。
デジスマでは payment_intent.created
で決済処理中という表示を出し、payment_intent.succeeded
で決済完了という表示にするという要件があります。このときイベントが逆順に届くと、ずっと決済処理中になってしまうという不具合がありました。
解決
デジスマでは単にWebhook受信後にStripe APIを介して最新の情報を問い合わせることで、情報の先祖返りを防いでいます。
一方で少し複雑にはなりますが、各種event通知にはcreatedにタイムスタンプが入るため、こちらのフィールドをDBにも保持することで状態の先祖返りを防ぐことができると思います(秒単位でしか無いですし、同じタイムスタンプで来る可能性は0ではないので、上記方法のほうが確実ではあると思います。)
{ "id": "evt_***", "object": "event", "api_version": "2020-08-27", "created": 1702818630, "data": {...} }
on_behalf_ofを指定するかの決定は慎重に
デジスマのようにExpress/支払いと送金別方式を採用している場合、決済作成時のon_behalf_ofは必須ではありません。 このオプションを使用しない場合、クレカ利用明細がプラットフォーム名で統一できるなどのメリットも有るため、あえて指定しないこともあるでしょう。(逆に使用した場合は各店子の名称がクレカ明細に記載されます)
一方でJCB決済において、on_behalf_ofの指定が必須となることがありますプラットフォームの性質によって、必要・不要は決定されるようです。
デジスマ診療は当初JCBに対応していなかったのでon_behalf_ofを指定していませんでしたが、後日対応する際にon_behalf_ofが必須であることがわかり対応が必要でした。
on_behalf_ofを設定すればいいじゃんという話に聞こえますが、クレカ明細に記載される店子の名称(漢字・ローマ字・カタカナ)を追加で医療機関から収集する必要があり、意外と大変な移行作業でした。
各種リジェクトはSlack通知等で気付けるようにようにしよう
決済に例外はつきものです。以下のようなイベントは必ず気付けるようにしましょう。
- Connectアカウントの本人確認情報の不備等に依る非承認
- 決済の不審申請
1.はサービス上で不備がある・再申請をしてくださいと表示するだけでは不十分なことが多いです。Connectの店子申請画面は非常によくできていますが、利用される方によっては何を提出すればよいのかわからないこともあります。この場合、非効率に思えますが、人力での対応が必要になります、適切にカスタマーサクセスチーム等にエスカレできる仕組みを作成するべきです。
不審請求の申請 (チャージバックとも呼ばれます) は、カード保有者がカード発行会社に対して、お客様への支払いについて疑問を呈したときに発生します。
2.はかなり頻度も低いため、後回しにされがちですが、いつかは必要になります。不審申請の場合は明確に対応する期日(通常7日程度)があるため、確実に対応できるよう通知を設定しておきましょう。
Idempotency Keyの設計に気をつけよう
Stripe APIのすべてのPOSTエンドポイントは、Idempotency-Key
ヘッダーをつけることで安全にリトライ を行うことができます。
こちらの仕組みは、システマティックな自動リトライによるリソースの重複を防ぐという利点もありますが、RDBにおけるユニークキーのような役割を果たします。
例えば、デジスマのようなサービスで1つの予約に対しては、1請求しかできないような制約をつけたい場合
curl https://api.stripe.com/v1/payment_intents \ -d amount=5000000000000000 \ -d currency=jpy \ -H "Idempotency-Key: {予約ID}" \
のようなヘッダーを付けることで、同一の予約IDでの複数リクエストは同一視され2回目以降は請求が発生しません。また、同一Idempotency-Keyでamountが違うようなリクエストはエラーが発生*1するようになります。
とはいえこれはシンプルなケースです。例えば返金機能を実装したとして
- payment_intent作成
- 返金
- 再度payment_intent作成
1.と3.で同じImpotency-Keyを指定すると、金額が違えば当然エラーになりますし、同じ場合は成功したように見えて再請求が実施されないという状況になりえます。 デジスマでは別途IdempotencyKeyを発番する仕組みを作っていますが、このように便利な半面、予期せぬエラー・不整合を招くことがあり注意が必要です。
Stripeメタデータを使おう
Stripeにはメタデータという仕組みがあり、PaymentIntentのような主要なオブジェクトにはkey/valueで任意の文字列を設定できます。
例えばPaymentIntentにアプリケーション側で利用する決済IDをメタデータとして付与することで、サポート対応時のStripeダッシュボード上での検索キーとして使うことができます。また、有事の際のデータ整合性チェックにも使えるので設定しておくと安心です。
開発/ステージング用アカウントは分けよう
Stripeにはテスト環境の仕組みがあり、1つのアカウントで本番・開発環境を実現できるようになっています。
当然といえば当然ですが、1つのアカウントしかない場合テスト環境も1つしかできないので、開発環境・ステージング環境のように複数のテスト環境を実現したい場合は、Webhookや顧客データを共有できないのでアカウントを分ける必要があります。
テスト利用でも本番申請が事前に必要な場合があり、面倒ではありますが、権限管理もし易いですし、普通に分けましょう。
まとめ
ということで、デジスマ診療におけるStripeの事例・学びをいくつか紹介しました。
いくつかのひっかかりポイントは紹介しましたが、改めて総合的に見るとStripeは非常に素晴らしい決済プラットフォームです。冪等キーやWebhookによる安全なアプリケーション側との結合。各種データ連携といった拡張できる余地を持ちながら、単体で使いやすいダッシュボード。素早く安定したサポート体制。など、SaaSを提供する一エンジニアとしても見習うべき点が非常に多いなと感じます。
引き続きStripeと共にやっていきできればいいんじゃないかなと思っております。
We are hiring!!
エムスリーでは絶賛エンジニアを募集中です! デジスマ診療以外にも様々なプロダクトがありますので、ご興味ある方は是非カジュアル面談等ご応募ください!