Flutter決済・送金アプリにおける二重トランザクション防止:冪等性キー設計とネットワーク断絶時の状態復元

P2P送金、加盟店決済、外貨両替機能を統合したモバイルウォレットを開発する際、PayMoney – Mobile App のようなフィンテック向けモバイルアプリケーション基盤は、決済UIフロー、QRコード送金、認証画面の設計工数を劇的に圧縮する。

【アーキテクチャの結論】
モバイル決済(Flutter / Dart)において二重引き落としや送金重複をゼロにする設計の要諦は、「① 送金ボタン押下時にクライアント側で一意なUUIDv4『冪等性キー(Idempotency-Key)』を生成してローカル暗号化ストレージに即時永続化し、② HTTPヘッダーに同キーを乗せて送信し、③ タイムアウト発生時は安易な再送を行わず、同一キーでトランザクション状態の照会(GETポーリング)へ切り替えること」 にある。ネットワーク切断とサーバー処理成功が同時に起こる「半二重障害」を前提としたステートマシンこそが、フィンテックアプリの絶対防壁となる。


1. 脆弱なアンチパターン vs 本番級の堅牢な実装

決済画面において最も致命的なバグは、ユーザーの連打や不安定な電波環境下での「二重送金」である。

脆弱なアンチパターン(Naive Implementation)

初心者が実装しがちなコードでは、単純なボタンの非活性化と http.post の呼び出しのみに依存している。

codeDart

// 危険な実装:ボタン連打やタイムアウト時の再試行で二重送金が発生する
ElevatedButton(
  onPressed: _isProcessing ? null : () async {
    setState(() => _isProcessing = true);
    try {
      // ネットワーク瞬断でレスポンスが失われた場合、再試行すると2重引き落としになる
      final response = await http.post(
        Uri.parse('https://api.wallet.local/v1/transfers'),
        body: {'amount': '500', 'recipient_id': 'user_99'},
      );
      showSuccessDialog();
    } finally {
      setState(() => _isProcessing = false);
    }
  },
  child: const Text('送金を実行'),
);

このコードの致命的欠陥は、リクエストがサーバーに到達して残高が引き落とされた直後、レスポンス返却前に電波が切れた場合にある。ユーザーにはエラーが表示されるため、再試行ボタンを押すことで2回目の送金(計1,000ドル)が実行されてしまう。

本番級の防御的リファクタリング(Hardened Implementation)

堅牢な決済パイプラインでは、クライアント主導の冪等性トークン管理と、リトライ時の安全な状態復元を担保する。

codeDart

import 'dart:async';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';

enum TransferStatus { idle, pending, completed, ambiguousTimeout }

class RobustTransferService {
  final http.Client client;
  final FlutterSecureStorage secureStorage;

  RobustTransferService({required this.client, required this.secureStorage});

  /// 冪等性を担保した安全な送金実行メソッド
  Future<TransferResult> executeTransfer({
    required String recipientId,
    required double amount,
    required String idempotencyKey, // UUIDv4
  }) async {
    final uri = Uri.parse('https://api.wallet.local/v1/transfers');

    // 1. 送信前に未確定トランザクションとして安全にローカル永続化
    await secureStorage.write(key: 'pending_tx_$idempotencyKey', value: 'submitted');

    try {
      final response = await client.post(
        uri,
        headers: {
          'Content-Type': 'application/json',
          'Idempotency-Key': idempotencyKey, // サーバー側に重複実行を禁じる一意のキー
        },
        body: '{"recipient_id": "$recipientId", "amount": $amount}',
      ).timeout(const Duration(seconds: 15));

      if (response.statusCode == 200 || response.statusCode == 201) {
        await secureStorage.delete(key: 'pending_tx_$idempotencyKey');
        return TransferResult.success(response.body);
      } else if (response.statusCode == 409) {
        // 同一キーによる処理中・または処理済みの競合を検知
        return TransferResult.alreadyProcessed(response.body);
      } else {
        return TransferResult.failed('Server error: ${response.statusCode}');
      }
    } on TimeoutException {
      // 2. タイムアウト時は再送せず「状態未確定」としてフラグを維持
      return TransferResult.ambiguous(
        '通信がタイムアウトしました。残高更新を確認しています...',
        idempotencyKey: idempotencyKey,
      );
    } catch (e) {
      return TransferResult.failed('通信エラーが発生しました: $e');
    }
  }
}

class TransferResult {
  final TransferStatus status;
  final String message;
  final String? idempotencyKey;

  TransferResult._(this.status, this.message, {this.idempotencyKey});

  factory TransferResult.success(String msg) => TransferResult._(TransferStatus.completed, msg);
  factory TransferResult.alreadyProcessed(String msg) => TransferResult._(TransferStatus.completed, msg);
  factory TransferResult.ambiguous(String msg, {required String idempotencyKey}) =>
      TransferResult._(TransferStatus.ambiguousTimeout, msg, idempotencyKey: idempotencyKey);
  factory TransferResult.failed(String msg) => TransferResult._(TransferStatus.idle, msg);
}

2. 致命的なエッジケースと障害復旧シナリオ

決済トランザクションにおいては、以下の2つの極限状態に対するフォールバックが必須となる。

エッジケース A:半二重パケット損失(Half-Duplex Drop)

  • 現象:クライアントの POST 送信はサーバーに到達し、送金DBトランザクションがコミットされたが、キャリア基地局の切り替えによりサーバーからの 200 OK パケットのみが消失する。

  • 復旧ロジック:UI層は決して「失敗」と断定してはならない。上記実装のように ambiguousTimeout 状態へ遷移させ、バックグラウンドで GET /v1/transfers/status?idempotency_key=... を問い合わせる。すでにコミットされていれば成功画面へルーティングし、未達であれば安全にトランザクションを再試行する。

エッジケース B:送金処理中のOSによるプロセス強制終了(Process Kill)

  • 現象:送金APIを叩いてレスポンスを待っている最中に、ユーザーが別アプリに切り替え、メモリ逼迫によりOSがFlutterアプリを破棄(Activity Destroyed)する。

  • 復旧ロジック:次回アプリアクティビティ起動時の初期化シーケンスで、secureStorage.readAll() を走査。キープレフィックス pending_tx_* が残存している場合、前回の送金成否が未確認であるため、メインダッシュボード表示前に自動照会リクエストを実行してローカル残高を最新化する。


3. エラーログのシグネチャと診断トレーシング

冪等性制御が正常に稼働しているかを確認する際、開発環境およびログ監視基盤(Datadog/Sentry)で検出されるべき正常な競合ログの典型例は以下の通りである。

codeText

# 典型的な二重リクエスト検知ログ(サーバー側)
[WARN] 2026-10-12 14:22:01 [HttpKernel] Idempotency conflict detected.
Key: d4f1a238-9b8e-4a6c-b3e1-229048a1c890
Action: POST /api/v1/transfers
Status: IN_PROGRESS. Subsequent request with identical payload rejected with HTTP 409 Conflict.

# クライアント側ネットワークタイムアウトのシグネチャ
flutter: [TransferEngine] SocketException: Connection timed out after 0:00:15.000000
flutter: [TransferEngine] Shifting to AMBIGUOUS reconciliation state. Retaining idempotency lock.

自走テストを行う際は、Charles ProxyやProxymanを用い、/v1/transfers のレスポンスのみを故意に Drop Connection させるブレークポイントを設定する。この条件下でクライアント側が残高を勝手に巻き戻さず、状態照会フェーズへ遷移するかを自己診断できる。


4. アーキテクチャのトレードオフ評価

モバイル決済のトランザクション制御において、どの手法を採用すべきかはシステムの複雑度と要件によってトレードオフが存在する。

アプローチ 実装複雑度 オフライン耐性 バックエンド依存度 推奨ユースケース
単純リトライ(再送のみ) 低 脆弱(二重決済リスク極大) なし 決済用途では採用厳禁
クライアント主導の冪等性キー方式(本稿) 中 高(半二重障害を完全吸収) 中(Redis等によるキーキャッシュ必須) 一般的なウォレット・P2P送金アプリ
二段階コミット(2PC / Sagaオーケストレーション) 極めて高 極めて高(分散DB整合性を完全保証) 高(マイクロサービス全体に伝播) 銀行間コアバンキング・国際電信送金

5. クライアント実装の検証とアーキテクチャ適合性

決済トランザクションの耐障害性を評価するにあたり、PayMoney のクライアント実装 をローカル開発環境に取り込み、人工的なパケット損失環境下での送金ステートマシンをトレースした。

Flutterの画面コンポーネントとバックエンド通信部が適度に分離されているため、通信インターセプター層へ上記のUUIDv4冪等性ヘッダー注入ロジックを無理なく組み込めることを確認した。不安定なテザリング回線を想定した検証においても、未確定ステートのローカル保管と残高照会フローが論理的に完結し、二重送信を防ぐセーフティネットが正常に機能する。


6. 決済・送金アプリ公開前の必須アーキテクチャ監査リスト

モバイルフィンテックアプリをストアへ公開する前に、以下の5項目を設計監査基準として確認することを推奨する:

UUIDv4冪等性キーの一意性保証:送金リクエストごとに新規生成され、画面再描画やウィジェット再構築によってキーが誤って再利用されていないか?

半二重障害(タイムアウト)時の非破壊ハンドリング:通信タイムアウト発生時、即座に「送金失敗」と断定して再試行を促さず、状態確認フローへ分岐しているか?

セキュアストレージによる未完了状態の永続化:SharedPreferencesのような平文保存ではなく、KeyStore / Keychainを用いた暗号化ストレージで保留中トランザクションを管理しているか?

HTTP 409 Conflictレスポンスの適切なハンドリング:サーバーから冪等性競合(409)が返却された際、クラッシュせず既存トランザクションの進捗表示へ合流できるか?

生体認証(Biometrics)と決済トークンの署名連動:LocalAuthentication成功時に取得した一時トークンが、送金リクエストのペイロードに正しくバインドされ、リプレイ攻撃を防げているか?