FinTech
レジリエントな決済システムの設計
決済システムの本質は、リクエストがタイムアウトし、資金が動いたかどうか誰にも分からないときの振る舞いに表れます。本ガイドでは、冪等性、元帳、ステートマシン、結果不明の扱い、Webhook、照合(リコンサイル)、カードデータを扱う範囲の縮小を取り上げます。
· 5分で読めます · Starone エンジニアリング
決済の正常系は短いものです。検証し、プロバイダーを呼び出し、結果を記録します。エンジニアリングのほぼすべてはそれ以外の場所にあります。ネットワーク呼び出しがタイムアウトする、コールバックが二重に届く、あるいは順序が入れ替わって届く、2つの書き込みの間でプロセスが停止する、プロバイダーの記録が自社の記録と食い違う、といったケースです。
私たちの立場は、決済システムは、あらゆる外部呼び出しが成功、失敗、不明の3つの状態のいずれかで終わりうるという前提で設計すべきだというものです。最初の2つしかモデル化していないシステムは、いずれ誰かに二重請求するか、資金の行方を見失います。以下のプラクティスは、3つ目の状態を安全に扱うためのものです。
あらゆる境界に冪等性キーを
リトライは避けられないため、資金を動かすすべての操作は繰り返しても安全でなければなりません。クライアントは論理的な操作ごとに冪等性キーを生成し、試行のたびにそれを送信します。サーバーはそのキーを、リクエストのフィンガープリント、そして完了後はレスポンスとともに、一意制約のもとで保存します。同じキーによる再送には、再実行せずに保存済みのレスポンスを返します。
うまく機能するかどうかは細部で決まります。キーは副作用より前に、決済レコードと同じトランザクションで挿入しなければなりません。そうすれば、同時に行われた2つの試行が両方とも先へ進むことはありません。同じキーを異なるペイロードで再利用するリクエストは拒否すべきで、黙って元のリクエストとして扱ってはなりません。最初の試行がまだ処理中のうちに届いた再送は、コンフリクトを受け取るか待機すべきで、2つ目の実行を開始してはなりません。そのうえで、同じキーまたはそこから導出したキーを下流のプロバイダーへ渡します。自社のAPIで止まる冪等性では、アクワイアラー側での二重請求を防げないからです。
複式簿記の元帳を信頼できる唯一の情報源に
更新可能な残高カラムは、現在の残高という1つの問いには答えますが、それ以外のあらゆる問いに答えるために必要な情報を破壊します。私たちは資金の動きを追記専用の複式簿記の元帳に記録します。各トランザクションは借方と貸方の合計がゼロになるエントリで構成され、エントリは更新も削除もされず、誤りは履歴をそのまま残す逆仕訳のトランザクションで訂正します。
合計ゼロという不変条件は安価に検証でき、幅広い種類の欠陥を捕捉します。貸借が一致しないトランザクションなしに、資金が現れたり消えたりすることはありえないからです。金額は通貨の最小単位の整数とし、通貨を併せて保存します。浮動小数点は決して使いません。残高はエントリから導出し、キャッシュしても構いませんが、キャッシュした数字は常に再現可能です。トレードオフは、ストレージの増加と読み取りクエリの複雑化です。自らを説明できる記録の対価としては妥当だと私たちは考えています。
明示的なステートマシン
決済は、作成済み、オーソリ済み、キャプチャ済み、精算済み、失敗、返金済みといった状態を経ていきます。これらを、許可された遷移の固定テーブルを持つ明示的なステートマシンとして表現すると、多くの種類のバグが拒否される操作に変わります。オーソリされていない決済へのキャプチャや、キャプチャ済み金額を超える2回目の返金は、遷移チェックの時点で失敗します。
- 現在の状態を条件とする条件付き更新によってデータベースで遷移を強制し、同時に発生した2つのイベントが両方とも成功しないようにします。
- 確定した回答を待っている操作のために、保留または不明の状態を設けます。これは通常の状態であり、エラーではありません。
- 各遷移を、その原因、実行者、タイムスタンプとともに不変のイベントとして記録します。
- 終端状態は終端のままにします。精算後の変更は編集ではなく、返金やチャージバックといった新しい操作です。
結果が不明となるタイムアウト
プロバイダーへの呼び出しがタイムアウトしたとき、リクエストは届いていなかったかもしれませんし、拒否されたかもしれませんし、成功したもののレスポンスが失われたのかもしれません。タイムアウトを失敗として扱い、顧客にもう一度試させることが二重請求の原因になります。成功として扱えば、支払いのないまま商品が引き渡されます。
正しい扱いは、決済を不明の状態に移し、それを解決することです。システムは自社の参照番号を使ってプロバイダーのステータスエンドポイントに問い合わせるか、同じ冪等性キーで元のリクエストをリトライし、確定した回答が得られるまでバックオフを伴って続けます。定められた時間内に回答が得られなければ、担当者が解決できるだけのコンテキストを添えて、オペレーション用のキューにエスカレーションします。
Webhookは事実ではなくヒント
プロバイダーは非同期の結果をWebhookで通知します。Webhookは少なくとも1回配信され、順序は保証されず、ときにはまったく届きません。ハンドラーはこの3つすべてを前提としなければなりません。
- 生のリクエストボディに対して署名を検証し、古いタイムスタンプを拒否して、偽造とリプレイを防ぎます。
- 変更を適用する前に、プロバイダーのイベント識別子で重複を排除します。
- イベントを永続化して素早く確認応答を返し、その後で非同期に処理します。これにより、遅いハンドラーが大量の再配信を招くことを防ぎます。
- イベントはステートマシンを通して適用し、遅れて届いたオーソリ済みイベントがキャプチャ済みの決済を上書きできないようにします。
- 保留のままの決済に対してポーリングジョブを実行します。Webhookの取りこぼしによって決済が未解決のまま残ってはならないからです。
照合(リコンサイル)を第一級のプロセスとして扱う
オンラインの経路をどれほど注意深く作っても、内部の元帳とプロバイダーの記録はときに食い違います。照合では、両者を体系的に比較します。内部の各トランザクションをプロバイダーの精算レポートと突き合わせ、精算済みの合計を銀行の取引明細と突き合わせます。どちらの側であれ一致しなかった項目はすべて、担当者と経過日数を伴う追跡対象の不一致として管理します。
そのためには、プロバイダーと共有する安定した参照番号、締め時刻をまたぐタイミングの差異に対する許容、手数料と通貨換算の明示的な扱い、そして不一致が想定水準を超えたときのアラートが必要です。自動で照合できない設計は未完成です。照合は、他のすべての仕組みが見逃した障害を検知する統制だからです。
PCIスコープの縮小と、この助言の限界
カードデータを保護するもっとも安全な方法は、それを扱わないことです。ホスト型の決済フィールドやプロバイダーへのリダイレクトを使えば、カード番号は自社のブラウザーコード、サーバー、ログ、バックアップに入り込まず、プロバイダーはカードの代わりとなるトークンを返します。これにより、PCI DSSの要件が適用される範囲は大幅に小さくなります。カードデータを直接扱わなければならない場合は、その機能を、これらの要件への対応を支援するよう設計した、小規模で個別に管理されるサービスに隔離し、他のいかなるものにもデータを見せません。
これらのプラクティスにはコストが伴い、規模に見合っていることが重要です。単一プロバイダーのホスト型チェックアウトで決済を受け付ける事業であれば、独自の元帳を構築せず、そのプロバイダーの記録と日次の照合に頼るのが合理的です。本格的な複式簿記の元帳、ステートマシン、照合エンジンが必要になるのは、システムが残高を保持する場合、資金を複数の当事者に分配する場合、複数のプロバイダーを利用する場合です。規模を小さくしても省けない原則が冪等性です。決済を開始するコードには、最初のリリースからそれが必要です。
