金融システム開発規約
状態遷移設計
決済システムにおける状態遷移図・状態遷移表の活用とステータス管理
状態遷移設計
決済システムでは、取引が複数の状態を段階的に遷移します。「今どの状態にあるか」「次にどの状態に遷移できるか」を厳密に管理しないと、二重決済・返金漏れ・不整合といった重大な問題が発生します。
状態遷移図と状態遷移表は、この設計を可視化・検証するための必須ツールです。
1. なぜ状態遷移設計が重要か
決済で起きがちな問題
| 問題 | 原因 |
|---|---|
| 二重決済 | 「承認済み」から「売上確定」への遷移を制御できていない |
| 返金後に再度売上確定 | 「返金済み」から「売上確定」への不正な遷移を許可 |
| 取消の取消 | 「取消済み」から再度「承認済み」に戻してしまう |
| 処理中のまま放置 | タイムアウト後のステータスが未定義 |
原則: 許可されていない状態遷移は、コード上で明示的に拒否する。
2. 状態遷移図(State Transition Diagram)
状態遷移図は、状態(ノード)と遷移(矢印)で全体の流れを視覚化します。
カード決済の状態遷移図
銀行振込の状態遷移図
状態遷移図を描く際のポイント
- 初期状態と終了状態を明示する
- 異常系の遷移(エラー、タイムアウト、期限切れ)を必ず含める
- **すべての状態からの「行き先」**を定義する(行き先のない状態は設計漏れ)
- ステークホルダー(業務担当者、QA)と一緒にレビューする
3. 状態遷移表(State Transition Table)
状態遷移図を表形式で網羅的に整理したものが状態遷移表です。すべての「状態 × イベント」の組み合わせを列挙し、漏れなくテストケースを洗い出すために使います。
カード決済の状態遷移表
| 現在の状態\イベント | オーソリ承認 | オーソリ拒否 | 売上確定 | 取消 | 返金要求 | 期限切れ | エラー |
|---|---|---|---|---|---|---|---|
| PENDING | → AUTHORIZED | → DECLINED | N/A | N/A | N/A | N/A | → ERROR |
| AUTHORIZED | N/A | N/A | → CAPTURED | → VOIDED | N/A | → AUTH_EXPIRED | N/A |
| CAPTURED | N/A | N/A | N/A | N/A | → REFUND_PENDING | N/A | N/A |
| REFUND_PENDING | N/A | N/A | N/A | N/A | N/A | N/A | → ERROR |
| REFUNDED | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
| DECLINED | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
| VOIDED | N/A | N/A | N/A | N/A | N/A | N/A | N/A |
| ERROR | N/A | N/A | N/A | N/A | N/A | N/A | → PENDING(リトライ) |
→ STATE: 遷移する(許可)N/A: 遷移しない(明示的に拒否すべき)
状態遷移表の読み方と活用
- N/A のセル = 「そのイベントが来たら拒否する」ことを意味する。テストケースとして「DECLINED 状態で売上確定リクエストが来たらエラーを返す」を書く
- 空欄がないことを確認する。空欄は設計漏れ
- テストケースの網羅性 = 状態遷移表の全セルをテストすれば、状態に関する全パターンをカバーできる
4. 実装パターン
パターン1: 許可リストによる遷移制御
// 許可された遷移を定義
const ALLOWED_TRANSITIONS: Record<PaymentStatus, PaymentStatus[]> = {
PENDING: ['AUTHORIZED', 'DECLINED', 'ERROR'],
AUTHORIZED: ['CAPTURED', 'VOIDED', 'AUTH_EXPIRED'],
CAPTURED: ['REFUND_PENDING', 'CHARGEBACK'],
REFUND_PENDING: ['REFUNDED', 'PARTIAL_REFUNDED', 'ERROR'],
PARTIAL_REFUNDED: ['REFUND_PENDING', 'REFUNDED'],
REFUNDED: [], // 終了状態
DECLINED: [], // 終了状態
VOIDED: [], // 終了状態
AUTH_EXPIRED: [], // 終了状態
ERROR: ['PENDING'], // リトライのみ
CHARGEBACK: ['CHARGEBACK_REVERSED'],
CHARGEBACK_REVERSED: [],
};
function transition(current: PaymentStatus, next: PaymentStatus): void {
const allowed = ALLOWED_TRANSITIONS[current];
if (!allowed.includes(next)) {
throw new InvalidStateTransitionError(
`Cannot transition from ${current} to ${next}`
);
}
}パターン2: State Machine ライブラリの活用
import { createMachine } from 'xstate';
const paymentMachine = createMachine({
id: 'payment',
initial: 'pending',
states: {
pending: {
on: {
AUTHORIZE_SUCCESS: 'authorized',
AUTHORIZE_DECLINE: 'declined',
SYSTEM_ERROR: 'error',
},
},
authorized: {
on: {
CAPTURE: 'captured',
VOID: 'voided',
EXPIRE: 'auth_expired',
},
},
captured: {
on: {
REFUND_REQUEST: 'refund_pending',
CHARGEBACK: 'chargeback',
},
},
refund_pending: {
on: {
REFUND_COMPLETE: 'refunded',
PARTIAL_REFUND: 'partial_refunded',
},
},
partial_refunded: {
on: {
REFUND_REQUEST: 'refund_pending',
REFUND_COMPLETE: 'refunded',
},
},
declined: { type: 'final' },
voided: { type: 'final' },
auth_expired: { type: 'final' },
refunded: { type: 'final' },
error: {
on: { RETRY: 'pending' },
},
chargeback: {
on: { REVERSE: 'chargeback_reversed' },
type: 'final',
},
chargeback_reversed: { type: 'final' },
},
});ライブラリの利点: 状態遷移の可視化ツール、テスト生成、デッドロック検出など、手動実装では得られないメリットがあります。
パターン3: DBでの状態管理
-- 状態遷移を履歴テーブルで管理
CREATE TABLE payment_status_history (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
payment_id VARCHAR(36) NOT NULL,
previous_status VARCHAR(30),
new_status VARCHAR(30) NOT NULL,
event VARCHAR(50) NOT NULL,
actor VARCHAR(100) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
metadata JSON,
INDEX idx_payment_id (payment_id),
INDEX idx_created_at (created_at)
);
-- 現在のステータスは最新のレコードから取得
-- もしくは、payment テーブルに current_status を持ち、
-- 更新時に楽観ロック(version カラム)で並行更新を防ぐ
UPDATE payments
SET status = 'CAPTURED',
version = version + 1,
updated_at = NOW()
WHERE id = ?
AND status = 'AUTHORIZED' -- 期待する現在のステータス
AND version = ?; -- 楽観ロック
-- affected rows = 0 の場合、競合が発生している5. 状態遷移のテスト戦略
状態遷移表からテストケースを導出
状態遷移表の全セルをテストケースにします。
describe('Payment State Transitions', () => {
// 許可された遷移: 正常系
it.each([
['PENDING', 'AUTHORIZE_SUCCESS', 'AUTHORIZED'],
['PENDING', 'AUTHORIZE_DECLINE', 'DECLINED'],
['AUTHORIZED', 'CAPTURE', 'CAPTURED'],
['AUTHORIZED', 'VOID', 'VOIDED'],
['CAPTURED', 'REFUND_REQUEST', 'REFUND_PENDING'],
])('should transition from %s to %s on %s event',
(from, event, to) => {
const payment = createPayment({ status: from });
payment.handleEvent(event);
expect(payment.status).toBe(to);
}
);
// 許可されていない遷移: 異常系(N/Aのセル)
it.each([
['DECLINED', 'CAPTURE'],
['REFUNDED', 'CAPTURE'],
['VOIDED', 'REFUND_REQUEST'],
['CAPTURED', 'AUTHORIZE_SUCCESS'],
['PENDING', 'REFUND_REQUEST'],
])('should reject transition from %s on %s event',
(from, event) => {
const payment = createPayment({ status: from });
expect(() => payment.handleEvent(event))
.toThrow(InvalidStateTransitionError);
}
);
});テスト観点のまとめ
| 観点 | 内容 |
|---|---|
| 正常遷移 | 許可されたすべての遷移パスが正しく動作するか |
| 不正遷移の拒否 | N/A のセルでイベントが来た場合にエラーになるか |
| 並行制御 | 同時に2つのイベントが来た場合に整合性が保たれるか |
| タイムアウト遷移 | 一定時間経過後の自動遷移(期限切れ等)が正しく動作するか |
| 終了状態の不変性 | 終了状態からは何も遷移しないか |
6. チェックリスト
- すべてのステータスを洗い出し、状態遷移図を描いたか
- 状態遷移表で全「状態 × イベント」の組み合わせを定義したか
- 許可されていない遷移を明示的に拒否するコードがあるか
- 状態遷移の履歴を記録しているか(誰が・いつ・何から何に変えたか)
- 並行アクセス時の競合を防ぐ仕組み(楽観ロック等)があるか
- 終了状態からの遷移が存在しないことを確認したか
- 状態遷移表の全セルに対応するテストケースがあるか
- タイムアウトや期限切れによる自動遷移が定義されているか