プレイブック

チェックアウト ステータス マシンの設計: チェックアウトと支払いが同じものではないのはなぜですか? (Payment State Machine Design)

支払いが成功しても、注文が完了したことを意味するものではありません。チェックアウトと支払いのライフサイクルを分離しないと、運用環境で 2 つの現実が重複してしまいます。

分散型決済エンジン

一部 2 の 22

取得と完了の間のギャップを埋める一連の分散型支払いアーキテクチャ。

Distributed payment engine architecture diagram

2 つのカレンダー、1 つの画面

顧客には画面上に 1 つの「注文ステータス」が表示されます。ただし、チェックアウト ライフサイクルと支払いライフサイクルという少なくとも 2 つの独立したステート マシンがバックグラウンドで実行されます。単一のフィールド (status) を通じてこれら 2 つのマシンを管理しようとすることは、最初のセクションで説明した「単一の真実」幻想のステート マシン バージョンです。```text Checkout: Init → Processing → FinalizePending → Completed ↘ Failed / Expired

Payment: Init → Processing → Captured → Completed ↘ Failed / Expired


## 最初に説明した概念```text
📦 Checkout Lifecycle
Siparişin müşteri gözünden geçtiği aşamalar: başlatıldı, işleniyor, tamamlanma bekliyor, tamamlandı.

📦 Payment Lifecycle
Para hareketinin PSP gözünden geçtiği aşamalar: başlatıldı, işleniyor, çekildi (captured), tamamlandı.

📦 Terminal Durum
Geriye dönüşü olmayan, makinenin o dal için sonlandığı durum (Completed, Failed, Expired).

📦 Transition Guard
Bir durumdan diğerine geçişe izin vermeden önce kontrol edilen ön koşul.

📦 State Drift
İki ilişkili durum makinesinin, senkronize olması gereken noktada birbirinden kopması.
````Captured` は PSP が「お金を手に入れました」と言っています。 `Completed` は、「注文を完了しました」という言葉です。これら 2 つは同じイベントではありません。これらの間には `FinalizePending` と呼ばれる待機状態があり、この状態は数秒ではなく、場合によっては数分間続くことがあります。

## 単一の `status` フィールドでは不十分な理由

多くのシステムでは、注文テーブルに `status` 列が 1 つあり、「支払いステータス」と「注文ステータス」の両方がこの列に詰め込まれています。これは、2 つの異なる責任を 1 つのフィールドに詰め込む典型的な症状です。支払い Webhook が到着したときのステータス `Completed` の行には、実際には在庫が予約または請求さ​​れていないにもかかわらず、注文が「完了」として表示されます。```text
Orders
id | status
1  | Completed   ← webhook geldi, ama finalization saga'sı henüz çalışmadı
```## 2 つのマシンを分離する

正しいモデルは、チェックアウトと支払いを別個のステート マシンとして定義し、それらの間に一方向のトリガー関係のみを確立します。支払いの状態 `Captured` への遷移は、チェックアウトの状態 `FinalizePending` への遷移をトリガーします。ただし、チェックアウトが `Completed` であるかどうかは、それ自体の物語の終わりに依存します。```text
Payment.Captured  --(tetikler)-->  Checkout.FinalizePending
                                          |
                          stok, finans, bildirim, sepet temizliği tamamlanınca
                                          ↓
                                  Checkout.Completed
```この区別のおかげで、「支払いは成功しましたが、注文はまだ処理中です」はもはやエラーではなく、予期された実証可能な中間状態です。顧客に「お支払いを受領しました。ご注文の準備を進めています」と伝えることは、システムの実際のステータスを正確に反映します。

## 失敗と期限切れをトリガーする人

どちらのマシンにも独自の `Failed` および `Expired` ブランチがあり、それぞれ独立してトリガーできます。支払い側では、`Expired` は、PSP が一定期間内に応答しないことを意味します (例: 3D セキュアの確認が完了していない)。チェックアウト側では、`Expired` は、支払いが `Captured` であっても、ファイナライズ処理が指定時間内に終了しないことを意味します。```text
Payment.Captured  +  Checkout finalization 30 dakika içinde bitmedi
        ↓
Checkout.Expired (ama Payment.Captured hâlâ geçerli — para geri iade edilmeli mi, saga retry mi edilmeli, karar operasyonel bir konudur)
```このシナリオは、2 つのマシンを分離することの本当の利点を示しています。`Payment.Captured` 状態をそのままにして、チェックアウト側で個別の回復プロセスを開始できます。単一の `status` フィールドがある場合、これら 2 つのファクトを同時に表すことはできません。

## ガード: パスのガード

すべてのパスにはガードが必要です。たとえば、`Checkout.Processing → Checkout.FinalizePending` への移行は、関連する支払いレコードが `Captured` 状態であることを確認した後にのみ発生する必要があります。 Webhook が順序どおりに到着しない場合、Guard のないステート マシンは無効な状態に陥る可能性があります (これについては、このシリーズの第 6 回で詳しく説明します)。```text
Guard: Checkout.FinalizePending'e geçiş
  → İlişkili Payment kaydı var mı?
  → Payment.Status == Captured mı?
  → Payment.Amount, Checkout snapshot'ıyla eşleşiyor mu?
  Hepsi doğruysa geçiş serbest; değilse geçiş reddedilir ve olay bir “beklemede” kuyruğuna düşer.

このエピソードで最も混乱を招く対戦```text

❌ Payment.Succeeded = Checkout.Completed ✓ Payment.Succeeded, Checkout.FinalizePending'i tetikler; Completed ayrı bir karardır

❌ Tek bir status alanı hem ödeme hem sipariş durumunu taşıyabilir ✓ İki bağımsız yaşam döngüsü, iki bağımsız alan (veya tablo) gerektirir

❌ Failed durumu her zaman “para geri gitti” anlamına gelir ✓ Checkout.Failed, Payment.Captured'ı geçersiz kılmaz; ayrı bir telafi süreci gerekir

❌ Guard'sız bir geçiş, sadece “fazladan kontrol”dür ✓ Guard, sırasız veya tekrarlı olaylara karşı tek savunma hattıdır


## ステート マシンのチェックリストを作成します

1. 支払いステータスと注文ステータスは注文テーブルの同じ列に保持されていますか?別。
2. `Captured` と `Completed` の間の遷移をトリガーするコードによって制御されるガードはどれですか?
3. `Captured` の支払い後 30 分以内にチェックアウトが終了しなかった場合はどうなりますか?警報はありますか?
4. 誰がどのようなイベントで `Failed` および `Expired` 状態に入ったかを明確にリストできますか?
5. 同じ支払いに対して 2 つの Webhook が順番どおりに到着しない場合、ガードはこれを検出しますか、それとも無効な遷移を許可しますか?

これら 5 つの質問に自信を持って答えられない場合は、2 つのステート マシンを 1 つに結合している可能性があります。

## このセクションで覚えておくべきこと

1. チェックアウトと支払いは、関連する 2 つの独立したステート マシンです。単一の `status` フィールドに圧縮することはできません。
2. Payment.Succeeded は Checkout.Completed を保証するものではありません。それらの間には、測定可能かつ実証可能な `FinalizePending` の範囲があります。
3. 各パスはガードに接続する必要があります。ガードのないパスは、順序が乱れたイベントや繰り返しのイベントで無効な状況を引き起こします。
4. `Failed` および `Expired` は 2 台のマシンで独立してトリガーできます。一方が他方を自動的にオーバーライドすることはありません。

> 支払い状況と注文状況を同じ欄に書いた日から、2 つの異なる真実が 1 つの嘘に変わってしまいます。

FAQ

よくある質問

チェックアウトのライフサイクルとは何ですか?

顧客の目の前で注文が通過する段階は、開始済み、処理中、完了待ち、完了です。

支払いライフサイクルとは何ですか?

PSP の観点から見た資金移動の段階 (開始、処理、取得、完了)。

「支払い.成功 = チェックアウト.完了」は正しいですか?

Payment.Succeeded は Checkout.FinalizePending をトリガーします。完了は別途決定

このセクションでは何を修正しますか?

このセクションでは、これら 2 つのマシンを別々に設計し、それらの間の遅延をバグではなく設計上の決定として考慮する必要がある理由を説明します。チェックアウトと支払いは 2 つの関連はありますが、独立したステート マシンです。単一の `status` フィールドに圧縮することはできません。顧客には画面上に 1 つの「注文ステータス」が表示されます。ただし、チェックアウト ライフサイクルと支払いライフサイクルという少なくとも 2 つの独立したステート マシンがバックグラウンドで実行されます。単一のフィールド (`status`) を通じてこれら 2 つのマシンを管理しようとすることは、最初のセクションで説明した「単一の真実」幻想のステート マシン バージョンです。

学んだエンジニアリング原則

  • チェックアウトと支払いのライフサイクルは関連していますが、独立しています。同じ領域内で表現することはできません。
  • Payment.Succeeded は結果ではなくトリガーです。 Checkout.Completed は、それ自体の物語の完了に依存します。
  • ガードなしでパスすると、順序どおりでないイベントや繰り返しのイベントに遭遇したときに、ドアが開いたまま無効な状態になります。

続きを読む

続きを読む

シリーズの次のシリーズ

シリーズの次のシリーズ

同じシリーズ

Paylaş