Friday, July 25, 2025

Flutter WebViewでPG決済連携を完全ガイド(SDKがない場合)

モバイルアプリに決済機能を追加する際、多くの開発者はPG(ペイメントゲートウェイ)が提供するネイティブSDKを活用します。しかし、プロジェクトの要件や特定のPGの方針により、Flutter専用のSDKが提供されていないケースも少なくありません。この記事では、Flutter SDKがない状況でWebView(ウェブビュー)を利用し、国内PGの決済連携を成功させた経験と、その過程で直面した技術的な課題の解決方法を詳しく共有します。

1. SDKなしのPG連携:WebViewベースのアーキテクチャ設計

Flutter SDKがないということは、PGが提供する決済プロセスをネイティブコードで直接制御できないことを意味します。その代替案は、PGが提供する「Web決済画面」をアプリ内で表示することであり、そのための最も確実な技術がWebViewです。

安定した決済処理のため、私たちは以下のようにデータフローとアーキテクチャを設計しました。

  1. [Flutterアプリ] 決済リクエスト: ユーザーがアプリで「決済する」ボタンをタップすると、アプリは商品情報(名前、価格)と注文者情報をバックエンドサーバーに送信します。
  2. [バックエンドサーバー] PGへの決済準備リクエスト: サーバーはアプリから受け取った情報を基に、一意の注文番号(orderId)を生成し、この情報を含めてPGの決済準備APIを呼び出します。
  3. [PGサーバー] 決済ページURLの応答: PGサーバーはリクエストを検証した後、その決済のための一意なWeb決済ページURLを生成し、バックエンドサーバーに返します。
  4. [バックエンドサーバー] アプリへのURL伝達: バックエンドサーバーはPGから受け取った決済ページURLをFlutterアプリに返します。
  5. [Flutterアプリ] WebViewで決済ページをロード: アプリはサーバーから受け取ったURLをWebViewにロードしてユーザーに表示します。ここからユーザーは、PGが提供するWebページ内でカード情報の入力や認証など、すべての決済手続きを進めます。
  6. [PGサーバー → バックエンドサーバー] 決済結果の通知(Webhook): ユーザーが決済を完了すると(成功・失敗・キャンセル問わず)、PGサーバーは事前に設定されたバックエンドサーバーの特定URL(コールバック/Webhook URL)に決済結果を非同期で通知します。このサーバー間通信(Server-to-Server)が、最も信頼できる唯一の決済結果となります。
  7. [PG Web → Flutterアプリ] 決済完了後のリダイレクト: Web決済画面での全プロセスが終了すると、PGはWebViewを私たちが指定した「結果ページ」のURL(例:https://my-service.com/payment/result?status=success)にリダイレクトさせます。アプリはこの特定のURLへの遷移を検知してWebViewを閉じ、ユーザーに適切な結果画面を表示します。

このアーキテクチャにおいて、サーバーはPGとの安全な通信、決済データの改ざん検証、最終状態の管理を担当し、アプリはユーザーインターフェースの提供とWebViewを介したPG決済画面の中継役を担います。一見シンプルに見えますが、本当の問題は国内の決済環境の特殊性にありました。

2. 最大の難関:WebViewと外部決済アプリ(App-to-App)連携

日本のPGのWeb決済画面は、単にカード情報を入力して終わるわけではありません。セキュリティと利便性向上のため、様々な外部アプリを呼び出す「アプリ間連携(App-to-App)」方式が必須となっています。

  • カード会社のアプリカード: 三井住友カードのVpassアプリ、楽天カードアプリなど、各カード会社のアプリを直接呼び出して認証・決済を行います。
  • かんたん決済アプリ: PayPay、LINE Pay、楽天ペイなどのアプリを呼び出します。
  • 本人認証アプリ: 3Dセキュアのための各カード会社の認証アプリなどを呼び出します。

これらの外部アプリは、一般的なhttp://https://のリンクではなく、カスタムURLスキーム(Custom URL Scheme)Androidのインテント(Intent)という特別な形式のアドレスを介して呼び出されます。例えば、以下のようなものです。

  • ispmobile://:ISP/PayBocアプリを呼び出すスキーム
  • kftc-bankpay://:銀行口座振替関連のアプリを呼び出すスキーム
  • intent://...#Intent;scheme=kb-acp;...;end:KB Payアプリを呼び出すAndroidインテントのアドレス

問題は、Flutterの公式WebViewプラグインであるwebview_flutterが、デフォルトではこれらの非標準URL(non-HTTP)を処理できない点です。WebViewはこれを不正なアドレスと認識し、「ページが見つかりません」というエラーを表示するか、何も反応しません。この問題を解決することが、このプロジェクトの成否を分ける最大のハードルでした。

3. 解決戦略:`navigationDelegate`でURLをインターセプトする

この問題解決の鍵は、webview_flutterが提供するnavigationDelegateにあります。navigationDelegateは、WebView内で発生するすべてのページ遷移(URL読み込み)リクエストを横取り(インターセプト)し、開発者が意図したカスタムロジックを実行できる強力な機能です。

私たちの戦略は明確でした。

  1. navigationDelegateを設定し、すべてのURL読み込みリクエストを監視します。
  2. リクエストされたURLが一般的なhttp/httpsではない非標準スキームの場合、WebViewのデフォルト動作(NavigationDecision.navigate)を停止します(NavigationDecision.prevent)。
  3. インターセプトしたURLを分析し、Android用のintentか、iOS用のカスタムスキームかを判断します。
  4. プラットフォームに適したネイティブコードやヘルパーパッケージ(url_launcher)を呼び出し、外部アプリを直接実行します。

まず、Flutter側のWebViewウィジェットの基本的な骨格コードです。


import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'dart:io' show Platform;

class PaymentWebViewScreen extends StatefulWidget {
  final String paymentUrl;
  const PaymentWebViewScreen({Key? key, required this.paymentUrl}) : super(key: key);

  @override
  State<PaymentWebViewScreen> createState() => _PaymentWebViewScreenState();
}

class _PaymentWebViewScreenState extends State<PaymentWebViewScreen> {
  late final WebViewController _controller;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('お支払い')),
      body: WebView(
        initialUrl: widget.paymentUrl,
        javascriptMode: JavascriptMode.unrestricted,
        onWebViewCreated: (WebViewController webViewController) {
          _controller = webViewController;
        },
        navigationDelegate: (NavigationRequest request) {
          // 1. 最終的な決済完了/キャンセル/失敗URLを検知
          if (request.url.startsWith('https://my-service.com/payment/result')) {
            // TODO: 決済結果を処理し、現在のWebView画面を閉じる
            Navigator.of(context).pop('決済処理が試行されました');
            return NavigationDecision.prevent; // WebViewがこのURLに遷移するのを防ぐ
          }

          // 2. 外部アプリ呼び出しURL(非http)の処理
          if (!request.url.startsWith('http://') && !request.url.startsWith('https://')) {
            if (Platform.isAndroid) {
              _handleAndroidIntent(request.url);
            } else if (Platform.isIOS) {
              _handleIosUrl(request.url);
            }
            return NavigationDecision.prevent; // WebViewのデフォルト動作を止めることが重要
          }

          // 3. その他のすべてのhttp/https URLは正常にロードを許可
          return NavigationDecision.navigate;
        },
      ),
    );
  }

  // Androidインテントを処理するロジック(後述)
  void _handleAndroidIntent(String url) {
    // ...
  }

  // iOSカスタムスキームを処理するロジック(後述)
  void _handleIosUrl(String url) {
    // ...
  }
}

3.1. Android:`intent://`との戦いとMethodChannelの活用

Androidのintent://スキームは最も厄介な相手です。このURLには、実行するアプリのパッケージ名や、アプリがインストールされていない場合に遷移する代替URL(主にGoogle Playストアのリンク)など、複雑な情報が含まれています。これをDartコードだけで解析して実行するのはほぼ不可能であり、ネイティブのAndroidコードの助けが絶対に必要です。そのために、FlutterのMethodChannel(メソッドチャンネル)を使用します。

Flutter (Dart) 側のコード

まず、url_launcherパッケージを追加します。intent://を直接処理はできませんが、market://のような単純なスキームや代替URLを開く際に役立ちます。


flutter pub add url_launcher

次に、_handleAndroidIntent関数を具体化します。intent://で始まるURLはネイティブ側に渡し、それ以外のスキームはurl_launcherで実行を試みます。


import 'package:flutter/services.dart';
import 'package:url_launcher/url_launcher.dart';

// ... _PaymentWebViewScreenState クラス内部 ...

// Androidネイティブコードと通信するためのチャンネルを定義
static const MethodChannel _channel = MethodChannel('com.mycompany.myapp/payment');

Future<void> _launchAndroidIntent(String url) async {
  if (url.startsWith('intent://')) {
    try {
      // ネイティブコードにintent URLを渡し、実行をリクエスト
      await _channel.invokeMethod('launchIntent', {'url': url});
    } on PlatformException catch (e) {
      // ネイティブ呼び出しの失敗時(例:処理できないインテント)
      debugPrint("インテントの起動に失敗しました: '${e.message}'.");
    }
  } else {
    // intent以外のスキーム(例:market://, ispmobile://など)
    // url_launcherで起動を試みる
    _launchUrl(url);
  }
}

// url_launcherを使用する共通関数
Future<void> _launchUrl(String url) async {
  final Uri uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    await launchUrl(uri, mode: LaunchMode.externalApplication);
  } else {
    // URLを起動できない場合(アプリが未インストールなど)
    debugPrint('$url を起動できませんでした');
  }
}

// navigationDelegateから呼び出す最終的な関数
void _handleAndroidIntent(String url) {
  _launchAndroidIntent(url);
}

ネイティブAndroid (Kotlin) 側のコード

android/app/src/main/kotlin/.../MainActivity.ktファイルに、MethodChannelの呼び出しを受け取り、intentを処理するコードを記述します。


package com.mycompany.myapp

import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import android.content.Intent
import android.net.Uri
import java.net.URISyntaxException

class MainActivity: FlutterActivity() {
    private val CHANNEL = "com.mycompany.myapp/payment"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)
        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler {
            call, result ->
            if (call.method == "launchIntent") {
                val url = call.argument<String>("url")
                if (url != null) {
                    launchIntent(url)
                    result.success(null) // 処理成功をFlutterに通知
                } else {
                    result.error("INVALID_URL", "URL is null.", null)
                }
            } else {
                result.notImplemented()
            }
        }
    }

    private fun launchIntent(url: String) {
        val intent: Intent?
        try {
            // 1. インテントURLをAndroidのIntentオブジェクトにパース
            intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME)
            
            // 2. このインテントを処理できるアプリが存在するか確認
            val packageManager = context.packageManager
            val existPackage = intent.`package`?.let {
                packageManager.getLaunchIntentForPackage(it)
            }
            
            if (existPackage != null) {
                // 3. アプリがインストールされていれば起動
                startActivity(intent)
            } else {
                // 4. アプリがなければフォールバックURL(主にマーケットURL)に遷移
                val fallbackUrl = intent.getStringExtra("browser_fallback_url")
                if (fallbackUrl != null) {
                    val marketIntent = Intent(Intent.ACTION_VIEW, Uri.parse(fallbackUrl))
                    startActivity(marketIntent)
                }
            }
        } catch (e: URISyntaxException) {
            // 不正な形式のURIの処理
            e.printStackTrace()
        } catch (e: Exception) {
            // その他の例外処理
            e.printStackTrace()
        }
    }
}

このネイティブコードは、FlutterからlaunchIntentメソッドが呼ばれると、渡されたintent://文字列をAndroidのIntentオブジェクトにパースします。そして、対象のアプリがインストールされているか確認し、あれば起動、なければbrowser_fallback_urlで指定されたPlayストアのURLに遷移させます。これにより、Androidでのアプリ間連携の問題が解決します。

3.2. iOS:カスタムURLスキームと`Info.plist`の重要性

iOSはAndroidより状況が少しシンプルです。intent://のような複雑な構造の代わりに、ispmobile://paypay://のような単純なカスタムスキームを主に使用するため、url_launcherパッケージだけでほとんどのケースに対応できます。

しかし、絶対に先行して行うべき非常に重要な作業があります。iOS 9以降、プライバシーポリシーが強化され、アプリが呼び出そうとする他のアプリのURLスキームをInfo.plistファイルに事前に登録(ホワイトリスト化)する必要があります。このリストにないスキームは、canLaunchUrlが常にfalseを返し、アプリを呼び出すことができません。

`ios/Runner/Info.plist`の設定

ios/Runner/Info.plistファイルを開き、LSApplicationQueriesSchemesキーと共に、連携に必要なすべてのスキームを配列に追加する必要があります。このリストは、利用するPGの開発者向けガイドを必ず参照し、漏れなく追加してください。


LSApplicationQueriesSchemes

    paypay
    linepay
    rakutenpay
    ispmobile
    kftc-bankpay
    vpass
    

Flutter (Dart) 側のコード

次に、_handleIosUrl関数をurl_launcherを使って実装します。MethodChannelは不要で、Dartコードだけで十分です。


// ... _PaymentWebViewScreenState クラス内部 ...

void _handleIosUrl(String url) {
  _launchUrl(url); // 上で作成した共通の_launchUrl関数を再利用
}

// _launchUrl関数は既に上で定義済み
// iOSの場合、Info.plistにスキームが登録されていればcanLaunchUrlがtrueを返す
Future<void> _launchUrl(String url) async {
  final Uri uri = Uri.parse(url);
  if (await canLaunchUrl(uri)) {
    // externalApplicationモードで実行すると、Safariを経由せずに直接アプリが開く
    await launchUrl(uri, mode: LaunchMode.externalApplication);
  } else {
    // アプリがインストールされていない場合
    // PGのガイドに従ってApp Storeのリンクに飛ばすか、
    // ユーザーにアプリのインストールが必要だと通知します。
    // 例:if (url.startsWith('paypay')) { launchUrl(Uri.parse('AppStore_PayPay_リンク')); }
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('アプリのインストールが必要です'),
        content: const Text('決済を進めるには、対応アプリのインストールが必要です。App Storeからインストールしてください。'),
        actions: [
          TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('確認')),
        ],
      ),
    );
  }
}

これで、iOSでも外部アプリを正常に呼び出せるようになりました。Info.plistの設定が最も重要な部分であることを絶対に忘れないでください。

4. 決済完了後のアプリ復帰と最も重要な「サーバーサイド検証」

外部アプリで決済を終えて自分のアプリのWebViewに戻ってくると、PGは約束された結果ページ(例:https://my-service.com/payment/result?status=success&orderId=...)にリダイレクトします。私たちはnavigationDelegateでこのURLを検知し、決済プロセスを完了させる必要があります。


// ... navigationDelegate 内部 ...
if (request.url.startsWith('https://my-service.com/payment/result')) {
  // URLからクエリパラメータをパースして結果を確認
  final Uri uri = Uri.parse(request.url);
  final String status = uri.queryParameters['status'] ?? 'fail';
  final String orderId = uri.queryParameters['orderId'] ?? '';

  // 🚨 セキュリティ警告:ここで即座に成功/失敗を断定してはいけません!
  // この情報はクライアント(アプリ)側で簡単に改ざん可能です。
  // 必ず自社サーバーに最終的な決済ステータスを再確認する必要があります。
  
  // サーバーに最終検証をリクエストするAPI呼び出し(例)
  // Future<void> verifyPayment() async {
  //    try {
  //       bool isSuccess = await MyApiService.verifyPayment(orderId);
  //       if (isSuccess) {
  //         // 最終的な成功処理を行い、成功ページへ遷移
  //       } else {
  //         // 最終的な失敗処理を行い、失敗ページへ遷移
  //       }
  //    } catch (e) {
  //       // 通信エラーの処理
  //    }
  // }
  
  // 検証結果に応じて画面遷移
  Navigator.of(context).pop(status); // 結果と共にWebViewを閉じる
  return NavigationDecision.prevent; // WebViewがこのページをロードしないようにする
}

最も重要な点は、リダイレクトされたURLのパラメータ(status=success)だけを信じて決済を最終的に成功として処理しては絶対にいけないということです。これは、攻撃者がURLを偽装して決済せずに有料コンテンツを利用できてしまう、深刻なセキュリティ脆弱性です。アプリは自社サーバーに「この注文番号(orderId)の決済は本当に成功したか確認してほしい」という最終検証リクエストを送る必要があります。サーバーは、アーキテクチャのステップ6でPGから受け取ったWebhookの情報をデータベースに保存しておき、この情報を基に真偽を判定してアプリに応答します。このサーバーサイドでのクロス検証を経て、初めて安全な決済システムが完成します。

5. 結論:要点と教訓

Flutter SDKなしでPG決済を連携させるのは、決して簡単な道のりではありませんでした。特にAndroidのintentとiOSのInfo.plistの設定は、何度も試行錯誤を繰り返す厄介な部分でした。しかし、webview_flutternavigationDelegateとプラットフォームごとのネイティブ連携(MethodChannel)を適切に活用することで、すべての問題を解決することができました。

今回の経験から得られた重要な教訓は以下の通りです。

  • アーキテクチャ設計が半分を占める: 決済リクエストからWebViewのロード、結果の受信、最終検証までの全体フローを明確に定義することが重要です。サーバーとクライアントの役割を明確に分離してください。
  • URLのインターセプトが鍵: webview_flutternavigationDelegateは、WebView決済連携の核心です。すべてのURL読み込みを制御することで、外部アプリの呼び出しと結果処理を実装できます。
  • プラットフォームの特性を尊重する: Flutterは優れたクロスプラットフォームフレームワークですが、外部アプリ連携のような機能は、各プラットフォーム固有の方式(Android Intent, iOS Custom Scheme)を理解し、それに従う必要があります。
  • セキュリティを最優先に: クライアント(アプリ)から受け取った決済成功情報は決して信用しないでください。常にサーバー側で、PGから送信されたWebhook情報を介して最終的なクロス検証(サーバーサイド検証)を実行する必要があります。

このガイドが、Flutterで決済機能の実装を目指す他の開発者の皆様の助けとなることを願っています。


0 개의 댓글:

Post a Comment