ECサイトの購入・決済システムの設計で考えたこと
What?
SNKRDUNKではDDD/モジュラモノリスな決済システムを開発中である。この過程で決済の設計をゼロベースで考えているので、検討したこととその結果を書いていく。
今回は以下について議論する。
- payment集約をどう設計するか
- webhook受信のアーキテクチャ
- リコンサイルをどう設計するか
(1) Paymentドメインモデルの設計
Paymentドメインモデルの設計について述べる。ポイントとしては、決済処理を完遂するのに必要な情報は支払い手段(クレジットカード、アプリ決済、コンビニ決済など)によって異なる。
結論としては、「支払い手段によらない共通で必要な情報」「支払い手段に固有な情報」を分ける設計とする。

- Payment には支払い方法によらない情報を入れる。ステートソーシング。
- ConveniTransaction / CreditCardTransaction は各支払い方法固有の情報。イベントソーシング。
集約ルートとしてのpayment
PaymentとXxxTransactionは一つの集約である。Paymentが、集約ルート。ConveniTransaction / CreditCardTransactionは集約内の従属エンティティ。決済モジュールの外側(購入処理など)との通信は、常にpaymentのidでやりとりする。
- ビジネスロジックでの決済ステータス管理はpaymentで一元管理する。transactionのログはビジネスロジックでは使わず参照用途に留める。理由は下記。
- (1) 生のtransactionのログから現在のステータスを算出するのは複雑でバグの温床になる
- (2) paymentとtransactionの整合性を完全一致させるのも運用上様々な例外が入ったりするので難しい
Paymentモデルに何を持たせるか?
- id / user_id / amount / method / status / subject / object_id
- subject / object_id は決済がどの購入に紐づいているかの情報である。決済を追跡可能にし、購入をユニーク・冪等にしたり、決済のWebhookから購入情報を復元するのに利用する。
XxxTransactionテーブルとして、TXログを自社のDBに残しておくのは、運用コストを下げるため。何か問題が発生したときに決済手段ごとに異なるPSPのサイトにログインして調査するのは手間が大きい。監査・リコンサイルもしやすい。
PSPの決済IDをどこに持たせるか?
決済種別ごとのCapture, Cancelに必要なprovider_payment_idをPaymentモデルに持たせるかトランザクションモデルから取るかは諸説あり。考えうるパターンは3つ
- Payment モデルに provider_payment_id という1つのフィールドを作って持たせる
- Payment モデルには持たせず、Transactionモデルから取得(決済種別ごとに取得メソッドが必要)
- provider_payment_id という独立した値オブジェクトとして定義し、オンデマンドで transactionを格納したテーブルから取得する
Paymentはイベントソーシングすべきか?
- しない
- 決済のイベントは外部決済システムに依存する。そしてイベント履歴のみから現在の状態を算出するのは難しくてバグを生みやすい。抽象化したstatusを持ったドメインモデルと監査用のイベントログとを隔離する方が安全と考えている
(2) Webhookの受信アーキテクチャ
大抵の決済手段は「ユーザとPSPの間で決済を行い、その結果をECサイトに通知する」という仕組みを取っていて、その通知方法においては「ユーザ(クライアント)からの決済完了リクエスト」のほかに、「PSPからWebhookによる決済完了リクエスト」の2つを提供している。前者はユーザから見て同期的なアクションとなるので体験が良いが離脱・通信遮断してしまいECサイトに届かないことがあるため、後者のWebhookを受信することで、決済結果がECサイトに確実に届くようになる。
基本形

- Webhook受信用のAPIを作る
- WebhookのログはDBに残す。SSOTとしてリコンサイル・監査に利用できる
- 確実に残るように、ビジネスロジックとは別TXにする方が安全
発展形

- PSPにエラーレスポンスを返した場合はWebhookは再送してもらえることが多いはずだが仕様はPSP依存となる
- Webhook受信APIを薄くして、ワーカーでの処理にすることで、PSP側のWebhookへの依存リスクを減らすことができる
冪等性の遵守

- 同期処理とは冪等にして、どちらが先に来ても(同時に来ても)処理可能にする
- 処理ずみなら何もしない(エラーにもしない)
- レスポンスは初回・2回目以降で共通
- それが冪等の原則だが、決済の場合は必ず2回リクエストが来るので、2回目以降は処理済みのフラグやヘッダを付けて返すと、後続処理をスキップするためのハンドリングがしやすいかもしれない。
(3) リコンサイルして不整合を検知・修正する

PSP↔決済ステータス↔購入ステータスの2者間でそれぞれ不整合が起こる。
よくあるケース
- 決済したが購入ができていない
- 購入・決済ステータスが中間状態のまま
原因
- APIやDBの通信障害、DBのロック待ちタイムアウト
- ユーザの離脱
- ビジネスロジックバグ
対応: バッチを回して状態を確定させる
- 決済が完了していなかったら購入をキャンセル
- 決済が完了していたら購入完了処理をリトライ
- リトライしたが購入完了ステータスへの続行が不可能の場合は、決済・購入をキャンセル
- すでに購入がキャンセル済み(期限切れでキャンセルにした後に決済された場合など)
- 外部連携先の在庫切れ
- リトライしたが購入完了ステータスへの続行が不可能の場合は、決済・購入をキャンセル
- 決済<->購入の不整合は、大抵バグのため、アラートを上げて個別対応とする
株式会社SODAの開発組織がお届けするZenn Publicationです。 是非Entrance Bookもご覧ください! → recruit.soda-inc.jp/engineer
Discussion