生成AI研修
金融システム開発規約

状態遷移設計

決済システムにおける状態遷移図・状態遷移表の活用とステータス管理

状態遷移設計

決済システムでは、取引が複数の状態を段階的に遷移します。「今どの状態にあるか」「次にどの状態に遷移できるか」を厳密に管理しないと、二重決済・返金漏れ・不整合といった重大な問題が発生します。

状態遷移図状態遷移表は、この設計を可視化・検証するための必須ツールです。


1. なぜ状態遷移設計が重要か

決済で起きがちな問題

問題原因
二重決済「承認済み」から「売上確定」への遷移を制御できていない
返金後に再度売上確定「返金済み」から「売上確定」への不正な遷移を許可
取消の取消「取消済み」から再度「承認済み」に戻してしまう
処理中のまま放置タイムアウト後のステータスが未定義

原則: 許可されていない状態遷移は、コード上で明示的に拒否する。


2. 状態遷移図(State Transition Diagram)

状態遷移図は、状態(ノード)と遷移(矢印)で全体の流れを視覚化します。

カード決済の状態遷移図

銀行振込の状態遷移図

状態遷移図を描く際のポイント

  • 初期状態と終了状態を明示する
  • 異常系の遷移(エラー、タイムアウト、期限切れ)を必ず含める
  • **すべての状態からの「行き先」**を定義する(行き先のない状態は設計漏れ)
  • ステークホルダー(業務担当者、QA)と一緒にレビューする

3. 状態遷移表(State Transition Table)

状態遷移図を表形式で網羅的に整理したものが状態遷移表です。すべての「状態 × イベント」の組み合わせを列挙し、漏れなくテストケースを洗い出すために使います。

カード決済の状態遷移表

現在の状態\イベントオーソリ承認オーソリ拒否売上確定取消返金要求期限切れエラー
PENDING→ AUTHORIZED→ DECLINEDN/AN/AN/AN/A→ ERROR
AUTHORIZEDN/AN/A→ CAPTURED→ VOIDEDN/A→ AUTH_EXPIREDN/A
CAPTUREDN/AN/AN/AN/A→ REFUND_PENDINGN/AN/A
REFUND_PENDINGN/AN/AN/AN/AN/AN/A→ ERROR
REFUNDEDN/AN/AN/AN/AN/AN/AN/A
DECLINEDN/AN/AN/AN/AN/AN/AN/A
VOIDEDN/AN/AN/AN/AN/AN/AN/A
ERRORN/AN/AN/AN/AN/AN/A→ PENDING(リトライ)
  • → STATE : 遷移する(許可)
  • N/A : 遷移しない(明示的に拒否すべき)

状態遷移表の読み方と活用

  1. N/A のセル = 「そのイベントが来たら拒否する」ことを意味する。テストケースとして「DECLINED 状態で売上確定リクエストが来たらエラーを返す」を書く
  2. 空欄がないことを確認する。空欄は設計漏れ
  3. テストケースの網羅性 = 状態遷移表の全セルをテストすれば、状態に関する全パターンをカバーできる

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. チェックリスト

  • すべてのステータスを洗い出し、状態遷移図を描いたか
  • 状態遷移表で全「状態 × イベント」の組み合わせを定義したか
  • 許可されていない遷移を明示的に拒否するコードがあるか
  • 状態遷移の履歴を記録しているか(誰が・いつ・何から何に変えたか)
  • 並行アクセス時の競合を防ぐ仕組み(楽観ロック等)があるか
  • 終了状態からの遷移が存在しないことを確認したか
  • 状態遷移表の全セルに対応するテストケースがあるか
  • タイムアウトや期限切れによる自動遷移が定義されているか

On this page