複数の店舗が商品を並べるショッピングモールのようなWebサービスを作成したい。個々の店舗が商品を販売し、購入者の支払いをそれぞれの店舗の売り上げとして扱いながら、モールを運営するWebサービス自身も手数料を受け取りたい。
このような構成では、Stripe Connectが選択肢になります。販売者をStripeの接続アカウントとして管理し、チャージ方式とApplication Feeを適切に設計すれば、販売者への売り上げの帰属とプラットフォームの手数料受領を両立できます。
先日ローンチした当方のサービス「SpringStart」では、個人開発者がプロダクトやコンテンツをダウンロード販売したり、クラウドファンディングで開発支援を募ったりする際にこの機能を活用しました。
https://springstart.net/
この記事ではSpringStartを例に、Stripe Connectの導入時に必要な設定と、Direct chargesを決済業務へ組み込むときの設計をまとめます。
先に結論
今回の設計では、次の方針を採用しました。
- Stripe Connectの接続アカウントは商品ではなく会員に1件だけ紐づける
- Stripe ExpressとDirect chargesを使う
- Checkout成功URLでは購入完了にせず、署名検証済みWebhookで確定する
- Stripeイベント、注文、購入権、通知を一つの状態遷移として扱う
- SpringStartの接続アカウント設定と、プロダクトごとの販売管理権限を分離する
- 開発支援と有料販売は同じ接続アカウントを使い、手数料の算式は共通化しつつ、内訳と業務ルールを分離する
Stripe Connectとは
Stripe Connectは、複数の販売者が参加するWebサービスに決済機能を組み込むための仕組みです。Webサービスを運営するプラットフォームが、販売者ごとのStripeアカウントを接続アカウントとして管理し、販売者に代わってオンボーディングや決済連携の導線を提供します。
この記事で使う用語は次の通りです。
| 用語 | 役割 |
|---|---|
| Platform | ショッピングモールを運営するWebサービス |
| Connected Account | 接続アカウント。商品を販売し、売上を受け取る販売者のStripeアカウント |
| Application Fee | Platformが決済ごとに受け取るサービス手数料 |
| Direct charges | Connected Account上に決済を作成する方式 |
Direct chargesでは、決済そのものを販売者の接続アカウント上に作成し、その決済に対するApplication FeeをPlatformへ移します。
この仕組みを利用すると、Platformは決済処理や本人確認の仕組みを自前で実装せず、Stripe Connectの接続アカウントを使って共通の決済基盤を提供できます。
1会員1接続アカウントにする
最初に、接続アカウントを何に紐づけるかを決めます。
商品ごとに接続アカウントを作る設計も考えられます。しかし、同じ開発者が複数の商品を販売したり、開発支援と有料販売の両方を使ったりする場合、商品単位の接続アカウントは管理対象を増やします。
SpringStartでは、接続アカウントを次のように会員単位で管理しました。
プロダクト管理画面や販売商品画面は、Stripeの接続設定を直接操作しません。画面には接続状態の概要と共通設定画面への導線だけを表示し、接続、本人確認の再開、Express Dashboardへの移動は会員共通の「決済・受取設定」で行います。
この分離により、次の2つの権限を混ぜずに済みます。
| 権限 | 対象 |
|---|---|
| プロダクト管理権限 | 特定プロダクトの販売情報や公開状態を編集します |
| 受取人本人の決済設定権限 | 自分のStripe接続アカウントとDashboardを操作します |
例えば、共同所有者がプロダクトを編集できても、そのプロダクトの受取人である別会員のStripe設定を操作できてはいけません。接続アカウントの取得は、画面から渡された会員IDやプロダクトIDではなく、認証済みセッションの会員IDで絞り込みます。
Stripe側の設定とアプリ側の責務を分ける
接続アカウントの導入は、Stripe Dashboardの設定だけでは完了しません。アプリ側で、誰にどの状態でAccount Linkを発行するかを決める必要があります。
SpringStartの導線は次のようにしています。
- 会員が「決済・受取設定」を開く
- メール確認済みの現在会員であることをサーバー側で確認する
- 会員にまだ接続アカウントがなければ、会員単位の一意制約と冪等キーを使って作成する
- Stripe ExpressのAccount Linkをサーバー側で発行する
- 会員をStripe-hosted onboardingへ移動する
- 戻り先では、保存済みの状態だけを信用せず、接続状態と必要なcapabilityを再確認する
Account Linkは一度だけ使える短期URLです。そのため、URLをDBやログへ保存せず、認証済み画面から直接利用します。モバイルでは、Stripeの画面をアプリ内WebViewへ埋め込まず、システムブラウザーへ移動してApp Linkで戻す設計にしています。
また、クライアントから任意のreturn_urlやrefresh_urlを受け取らないことも重要です。戻り先はサーバーで許可した同一オリジンのパスに限定します。
Stripe側では、少なくとも次のような情報を確認します。
- 接続アカウントの種類とオンボーディング方式
- 支払いを受けるためのcapability
- 本人確認と入金先登録の状態
- Stripeから通知される要対応項目
- Webhookの署名検証に使うSecret
Webサービス運営側では、Stripeから返る本人確認書類や銀行口座情報を複製せず、画面には要対応項目の概要だけを表示します。Stripe上の状態とSpringStart上の公開条件は同じではないため、readyになっただけで販売公開を許可せず、販売者情報、返金方針、販売ファイルの安全確認なども別に検証します。
Direct chargesを選んだ理由
Connectには複数のチャージ方式があります。今回の有料販売では、接続アカウント上にCheckout SessionとPaymentIntentを作るDirect chargesを採用しました。
資金の流れは概念的には次のようになります。
Direct chargesでは、ChargeやPaymentIntentなどの決済オブジェクトは接続アカウント側に存在します。プラットフォーム側のAPIだけを見ていると、決済情報が存在しないように見えるため、Stripe APIを呼び出すときには、注文に保存した接続アカウントIDをスコープとして使います。
Stripe-Account: acct_xxxxxxxxxxxxx
このIDは公開画面やURLへ出さず、Webサービスの業務レコードからサーバー側で解決します。
Direct chargesを選んだ理由は、販売者の取引、入金、返金、紛争を販売者のStripeアカウントへ帰属させるモデルが、初期のソフトウェア販売の責任分界に合っていたためです。逆に、プラットフォームが販売者として決済を受け、あとから売上を分配する必要があるサービスでは、別のチャージ方式と法務・会計設計が必要になります。
手数料の算式は共通化し、内訳と業務ルールは分ける
SpringStartでは、開発支援と有料販売の両方で同じ接続アカウントを参照します。手数料の総額計算は共通していますが、機能ごとに内訳の保存方法と決済後の業務ルールを分けています。
現行設定では、どちらも対象金額に設定された手数料率を適用し、丸め規則に従って手数料総額を計算します。開発支援では支援金額、有料販売では税込み販売価格が対象です。どちらも、この手数料総額をStripeのapplication_fee_amountに指定します。
| 項目 | 開発支援 | 有料販売 |
|---|---|---|
| 対象金額 | 支援金額 | 税込み販売価格 |
| 手数料率 | 設定された手数料率 | 設定された手数料率 |
| 手数料総額 | 対象金額に手数料率を適用して計算 | 対象金額に手数料率を適用して計算 |
application_fee_amount |
手数料総額 | 手数料総額 |
| 内訳管理 | サービス利用料の総額と率 | 手数料本体と手数料消費税も保存 |
| 対応通貨 | 日本円・米ドル | 日本円 |
| 決済後の業務 | 支援履歴とキャンペーン集計 | 注文、購入権、販売ファイル、返金・紛争 |
開発支援では、税込みの支援金額からSpringStartのサービス利用料とStripe手数料を差し引く設計です。手数料率を含む計算結果は、支援決済の作成時点で保存します。
支援金額
- SpringStartサービス利用料(設定された手数料率に基づく税込みの金額)
- Stripe決済手数料(決済結果から取得)
= 開発者の受取額
有料販売でも、税込み販売価格に対するSpringStart使用料総額をapplication_fee_amountへ指定し、その内訳として手数料本体と手数料に含まれる消費税を管理します。Stripe手数料も決済結果に基づく実費として扱います。
有料販売では、同じ手数料総額を手数料本体と手数料に含まれる消費税へ分解して保存します。開発支援では、サービス利用料の総額と率を中心に管理します。これは算式が違うからではなく、販売と開発支援で必要な会計上の内訳と業務データが異なるためです。
注文や支援決済には、計算結果だけでなく、作成時点の手数料率、通貨、手数料額を保存します。有料販売の注文では、これに加えて適用税率も保存します。将来ルールを変更しても、過去の取引を現在の設定で再計算しないためです。
Checkout成功URLを支払い証明にしない
決済後にCheckoutの成功URLへ戻ってきたら、その場で購入権を発行したくなります。しかし、ブラウザーが成功URLへ戻ったことは、サーバーが決済確定を確認したこととは別です。
SpringStartでは、成功URLで次のような「確認中」表示だけを行い、購入権を発行しません。
購入権の発行は、署名を検証したStripe Webhookで行います。これにより、利用者が成功URLを直接開いた場合や、通信切断後に再読み込みした場合でも、決済確定処理をブラウザーの挙動から切り離せます。
Webhookを冪等な状態機械として扱う
Webhookは一度だけ、正しい順番で届くとは限りません。実装では、イベントの重複、遅延、逆順、Stripe APIの一時的な取得失敗を前提にします。
処理の入口は次のように固定します。
- 生のリクエストボディを取得する
- Stripe署名を検証する
-
stripe_event_idを一意キーとしてイベントを保存する - 接続アカウント由来のイベントならAccount IDを照合する
- 注文または支援決済の現在状態とイベント内容を確認する
- 業務データと集計をトランザクションで更新する
- 必要な購入権、支援履歴、通知イベントを同じトランザクションで保存する
- 処理済み時刻を保存する
有料販売で決済成功を確定するときは、次のデータを組み合わせて判定します。
- Checkout Sessionの現在状態
payment_status- PaymentIntentの決済状態
- Webhookイベントの接続アカウントID
- 注文に保存したStripe Account ID
- 注文の現在状態
例えば、checkout.session.expiredが遅れて届いたとしても、すでに支払い済みの注文をcancelledへ戻してはいけません。イベントの作成時刻だけを比較するのではなく、Stripeオブジェクトの現在状態も取得して、古いイベントで新しい状態を上書きしないようにします。
Stripe手数料は決済確定と同時に取得できないことがあります。その場合、手数料を推測値で埋めず、NULLのまま保存してバックグラウンド再照合で取得します。金額が不確かなまま売上を確定しないことが重要です。
二重Checkoutと在庫の占有を防ぐ
ソフトウェア販売では、決済の二重処理だけでなく、シリアル番号在庫の二重予約も問題になります。
Checkout開始時には、販売商品行をロックし、同じ会員と販売商品に有効なprocessing注文がないか確認します。注文IDからStripeの冪等キーを生成し、注文とCheckout Sessionの関係を再利用できるようにします。
通信タイムアウトが起きたときに、すぐ注文を失敗扱いにしないようにします。Stripe側では作成済みで、アプリだけが応答を受け取れていない可能性があるためです。確定エラーと不明な結果を分け、不明な場合は同じ冪等キーで再取得します。
返金と紛争は注文の外側にある
返金を注文のステータスだけで表現すると、外部操作や部分返金に弱くなります。注文の現在状態と、返金・紛争の履歴を分けて持つ設計にします。
有料販売の全額返金では、次の状態を連動させます。
ただし、返金APIのレスポンスだけで完了扱いにはしません。Stripe上の返金成功Webhookを受信してから、注文と購入権を確定的に変更します。タイムアウトで返金結果が不明な場合は、同じStripe冪等キーで照合または再試行します。
チャージバックはさらに別の状態遷移を持ちます。紛争開始で購入権を一時停止し、販売者勝訴なら復旧し、敗訴なら失効させます。StripeのDispute状態、金額、手数料、資金移動は、注文テーブルへ上書きするのではなく専用の履歴として保存します。
注意点
Stripe Connectを導入してCheckout画面を表示するだけでは、決済基盤としては不十分です。どのアカウントが販売者なのか、決済・返金・チャージバックの責任を誰が負うのか、売上と手数料をどこへ帰属させるのかを、先に決める必要があります。
本当に難しいのは、次の境界を一貫させることです。
- どのアカウントが販売者なのか
- 売上とプラットフォーム手数料をどこへ帰属させるのか
- 決済完了を何を根拠に確定するのか
- 返金やチャージバックが起きたとき、注文と利用権をどう戻すのか
- 開発支援と有料販売で異なる手数料ルールをどう保つのか
ここでいう「販売者に売上を帰属させる」は、Webサービス側が販売者の代わりに売上を預かるという意味ではありません。SpringStartの有料販売仕様では、接続アカウント側をMerchant of Recordとして扱います。サービス側はプラットフォームとして、接続アカウント上の決済とApplication Feeを管理します。
また、Stripe Connectのチャージ方式によって、決済オブジェクトが存在するアカウント、返金やチャージバックの負担先、購入者へ表示する販売者情報が変わります。Direct chargesを選ぶ場合は、接続アカウントIDを注文とWebhookの両方で照合し、接続アカウント側のStripeオブジェクトを取得する設計が必要です。
そのため、実装前に次の点を決めておきます。
- 販売者とMerchant of Recordを誰にするか
- どのチャージ方式で資金を移動するか
- Application Feeの計算方法と丸め規則
- 返金、紛争、負残高に対する責任分界
- Stripe側の本人確認、capability、利用可否の確認方法
- 商品ページ、購入確認、注文詳細に表示する販売者情報
セキュリティ境界を先に決める
決済機能では、「便利そうだから返してしまう」情報を最初に制限します。
- Stripe Secret KeyとWebhook Secretはクライアントへ渡さない
- Account LinkとLogin LinkはDB、アクセスログ、監査ログへ保存しない
- Stripeの本人確認情報や銀行口座情報をSpringStartへ複製しない
- クライアント入力の価格、販売者、購入権状態、ファイル状態を信用しない
- Stripe Account IDはクライアント入力ではなく業務レコードから解決する
- 返金額はクライアントから受け取らず、注文に保存した決済総額をサーバー側で取得する
特に、認証後のアプリ内戻り先を示すreturnToを許可済みパスとして検証すること、Stripeから返る要対応項目を画面へ丸ごと出さないことは、実装初期に決めておくと後からの修正が少なくなります。なお、returnToは認証後のアプリ内戻り先であり、Stripe Account Linkのreturn_urlやrefresh_urlとは別の値です。
実装ではなく仕様を先にテストへ落とす
この設計で重要なのは、正常系の決済が通ることだけではありません。静的テストとE2Eテストでは、例えば次の観点を確認します。
- 1会員に複数の接続アカウントを作成できないこと
- 他会員の接続アカウントや販売者情報を操作できないこと
- メール未確認の会員へAccount Linkを発行できないこと
- 同じWebhookを複数回受信しても注文、購入権、集計、通知が重複しないこと
- 接続アカウントIDが注文とイベントで一致しないWebhookを処理しないこと
- Checkout成功URLだけでは購入権が発行されないこと
- Stripe手数料が取得できなくても決済処理全体が停止しないこと
- 全額返金成功後に注文と購入権が連動して失効すること
- Stripe Express Dashboardから行われた返金も同期できること
- Stripeの一時URL、秘密情報、本人確認情報がログへ出力されないこと
決済機能のテストは、StripeのAPIレスポンスを固定して終わりにせず、自分たちの業務状態がどの順番で変わるかを確認するテストとして設計します。
この設計から得たこと
1. 接続アカウントは決済画面の設定ではなく業務主体
誰が販売者で、誰が受取人で、誰が返金や紛争に対応するのかを決めないままAPIを呼ぶと、後から注文、法務表示、権限がつながりません。
2. Webhookはイベントログではなく業務状態を進める入力
重複、遅延、逆順、欠落を前提に、冪等キー、状態遷移、定期照合を組み合わせる必要があります。
3. 共通化するのは接続基盤と手数料総額の計算で、業務ルールは分ける
開発支援と有料販売は同じ会員・接続アカウントを参照できます。手数料総額の計算も共通化できます。しかし、手数料の内訳、返金、購入権、法務文書、通知のルールはそれぞれの機能に閉じ込めた方が安全です。
4. 「決済が成功した」後に作るデータを決めておくべき
注文、購入権、販売完了通知、売上集計をどのトランザクションで作るかを先に決めると、画面の成功表示に業務処理を引きずられにくくなります。
まとめ
Stripe Connectの実装で難しいのは、Checkoutを表示することではないです。Direct chargesを採用するなら、接続アカウント側に存在する決済情報を正しく追跡し、Webhookを冪等に処理し、返金や紛争まで含めて自分の業務状態へ落とし込む必要があります。
SpringStartでは、会員単位の共通接続アカウントを軸にしながら、開発支援とソフトウェア販売の業務ルールは分離しました。この構造は、マーケットプレイス、SaaS、クリエイター向け販売基盤など、複数の提供者がそれぞれの取引を持つサービスでも応用できます。
一方で、Merchant of Record、税務、返金責任、Stripeの利用可否はサービスの事業形態と地域によって変わります。コードだけで解決できる問題ではないため、決済を本番で有効化する前に、Stripeへビジネスモデルを確認し、法務・税務の確認を別途行う必要があります。
参考資料
Stripe公式ドキュメント
- Understand how charges work in a Connect integration
- Create direct charges
- Onboard your connected account
- Idempotent requests
SpringStart