技術 約6分で読めます

Flutter WebView + Google Play 課金実装ガイド

WebView内のWebアプリからGoogle Play課金(消費型アイテム/ポイント購入)を実装するためのガイド。

早い話ガチャの石ってどうやって課金処理するねんって話
仕事でアプリに実装したことがないのでいったんメモで


全体構成

[WebView (HTML/JS)] ←→ [Flutter (Dart)] ←→ [Google Play Billing]
                   JavaScript Channel      in_app_purchase

                   [バックエンドサーバー]
                   レシート検証・ポイント付与

消費型アイテム(ポイント)の購入フロー

1. [ユーザー] ポイント購入ボタン押す
2. [WebView → Flutter] JavaScript Channel で購入リクエスト
3. [Flutter] Google Play 決済画面を起動
4. [Google Play] ユーザーが支払い完了
5. [Flutter] 購入成功を検知
6. [Flutter → サーバー] レシート検証リクエスト
7. [サーバー] Google API でレシート検証 → ポイント付与
8. [Flutter] completePurchase() で消費処理 ← 重要!
9. [Flutter → WebView] 購入完了を通知

事前準備

1. Google Play Console での設定

課金アイテムの登録

収益化 → アプリ内アイテム → アイテムを作成

アイテムID: point_100  / 100ポイント / ¥120
アイテムID: point_500  / 500ポイント / ¥550
アイテムID: point_1000 / 1000ポイント / ¥1000

※「管理対象のアイテム」を選択(消費型)
※ステータスを「有効」にする

ライセンステスターの登録(テスト用)

設定 → ライセンステスト → テスターのGmailアドレスを追加

2. Flutter プロジェクトの設定

pubspec.yaml

dependencies:
  webview_flutter: ^4.8.0
  in_app_purchase: ^3.2.0
  http: ^1.1.0

AndroidManifest.xml

<uses-permission android:name="com.android.vending.BILLING" />
<uses-permission android:name="android.permission.INTERNET" />

Flutter側の実装

import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:in_app_purchase/in_app_purchase.dart';
import 'package:http/http.dart' as http;

class WebViewBillingPage extends StatefulWidget {
  @override
  _WebViewBillingPageState createState() => _WebViewBillingPageState();
}

class _WebViewBillingPageState extends State<WebViewBillingPage> {
  late WebViewController _webViewController;
  final InAppPurchase _iap = InAppPurchase.instance;
  late StreamSubscription<List<PurchaseDetails>> _subscription;

  static const Set<String> _productIds = {'point_100', 'point_500', 'point_1000'};

  @override
  void initState() {
    super.initState();
    _initBilling();
    _initWebView();
  }

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }

  void _initBilling() {
    _subscription = _iap.purchaseStream.listen(
      _handlePurchaseUpdates,
      onError: (error) => _notifyWebView('purchaseError', {'error': error.toString()}),
    );
  }

  Future<void> _handlePurchaseUpdates(List<PurchaseDetails> purchases) async {
    for (var purchase in purchases) {
      switch (purchase.status) {
        case PurchaseStatus.purchased:
          await _handleSuccessfulPurchase(purchase);
          break;
        case PurchaseStatus.error:
          _notifyWebView('purchaseError', {'error': purchase.error?.message ?? 'エラー'});
          if (purchase.pendingCompletePurchase) await _iap.completePurchase(purchase);
          break;
        case PurchaseStatus.canceled:
          _notifyWebView('purchaseCanceled', {});
          break;
        case PurchaseStatus.pending:
          _notifyWebView('purchasePending', {'message': '支払いを完了してください'});
          break;
        default:
          break;
      }
    }
  }

  Future<void> _handleSuccessfulPurchase(PurchaseDetails purchase) async {
    // 1. サーバーでレシート検証
    final verified = await _verifyPurchaseOnServer(purchase);
    if (verified) {
      // 2. 消費処理(再購入可能にする)
      if (purchase.pendingCompletePurchase) await _iap.completePurchase(purchase);
      // 3. WebViewに成功通知
      _notifyWebView('purchaseSuccess', {'productId': purchase.productID});
    } else {
      _notifyWebView('purchaseError', {'error': 'レシート検証失敗'});
    }
  }

  Future<bool> _verifyPurchaseOnServer(PurchaseDetails purchase) async {
    try {
      final response = await http.post(
        Uri.parse('https://your-server.com/api/verify-purchase'),
        headers: {'Content-Type': 'application/json'},
        body: jsonEncode({
          'productId': purchase.productID,
          'purchaseToken': purchase.verificationData.serverVerificationData,
        }),
      );
      return response.statusCode == 200 && jsonDecode(response.body)['success'] == true;
    } catch (e) {
      return false;
    }
  }

  void _initWebView() {
    _webViewController = WebViewController()
      ..setJavaScriptMode(JavaScriptMode.unrestricted)
      ..addJavaScriptChannel('NativeBilling', onMessageReceived: _onJavaScriptMessage)
      ..loadRequest(Uri.parse('https://your-web-app.com'));
  }

  Future<void> _onJavaScriptMessage(JavaScriptMessage message) async {
    final data = jsonDecode(message.message);
    switch (data['action']) {
      case 'getProducts':
        await _getProducts();
        break;
      case 'purchase':
        await _purchaseProduct(data['productId']);
        break;
    }
  }

  Future<void> _getProducts() async {
    if (!await _iap.isAvailable()) {
      _notifyWebView('productsError', {'error': 'ストアが利用できません'});
      return;
    }
    final response = await _iap.queryProductDetails(_productIds);
    final products = response.productDetails.map((p) => {
      'id': p.id, 'title': p.title, 'price': p.price,
    }).toList();
    _notifyWebView('productsLoaded', {'products': products});
  }

  Future<void> _purchaseProduct(String productId) async {
    final response = await _iap.queryProductDetails({productId});
    if (response.productDetails.isEmpty) {
      _notifyWebView('purchaseError', {'error': '商品が見つかりません'});
      return;
    }
    // autoConsume: false でサーバー検証後に手動消費する(デフォルトは true で自動消費)
    await _iap.buyConsumable(
      purchaseParam: PurchaseParam(productDetails: response.productDetails.first),
      autoConsume: false,
    );
  }

  void _notifyWebView(String event, Map<String, dynamic> data) {
    _webViewController.runJavaScript("window.onNativeMessage && window.onNativeMessage('$event', ${jsonEncode(data)});");
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('ポイント購入')),
      body: WebViewWidget(controller: _webViewController),
    );
  }
}

Web側(JavaScript)の実装

// Flutterへ送信
function sendToFlutter(action, data = {}) {
  if (window.NativeBilling) {
    NativeBilling.postMessage(JSON.stringify({ action, ...data }));
  }
}

// 商品一覧取得
function loadProducts() { sendToFlutter('getProducts'); }

// 購入
function purchase(productId) { sendToFlutter('purchase', { productId }); }

// Flutterからのコールバック
window.onNativeMessage = function(event, data) {
  switch (event) {
    case 'productsLoaded':
      displayProducts(data.products);
      break;
    case 'purchaseSuccess':
      alert('購入完了!');
      refreshUserPoints();
      break;
    case 'purchaseError':
      alert('エラー: ' + data.error);
      break;
    case 'purchaseCanceled':
      console.log('キャンセル');
      break;
  }
};

サーバー側のレシート検証(PHP例)

<?php
function verifyAndroidPurchase($productId, $purchaseToken) {
    $client = new Google_Client();
    $client->setAuthConfig('service-account.json');
    $client->addScope('https://www.googleapis.com/auth/androidpublisher');

    $service = new Google_Service_AndroidPublisher($client);
    $purchase = $service->purchases_products->get('com.your.package', $productId, $purchaseToken);

    // purchaseState: 0=購入済み, consumptionState: 0=未消費
    if ($purchase->getPurchaseState() === 0 && $purchase->getConsumptionState() === 0) {
        if (!isPurchaseTokenUsed($purchaseToken)) {
            return ['valid' => true, 'orderId' => $purchase->getOrderId()];
        }
    }
    return ['valid' => false];
}

テスト方法(お金がかからない)

ライセンステスターの設定

Google Play Console → 設定 → ライセンステスト → Gmailアドレスを追加

これを設定しないと実際に課金されます!

テスト手順

  1. ライセンステスターのGmailでAndroid端末にログイン
  2. アプリをインストール
  3. 購入ボタン → 「テストカード、常に承認」を選択
  4. 購入完了(課金なし)

確認ポイント

購入画面に表示されていればOK:

「これはテスト用の注文です。課金は発生しません。」

テスト用支払い方法

方法動作
テストカード、常に承認購入成功
テストカード、常に拒否購入失敗
テストカード、遅延保留中(コンビニ決済シミュレーション)

重要な注意点

消費型アイテムのポイント

  1. 必ず completePurchase() を呼ぶ - 呼ばないと再購入できない
  2. サーバー検証してから消費 - 検証前に消費すると不正購入の温床に
  3. 購入トークンの重複チェック - 同じトークンで2回付与しないようDBで管理

autoConsume パラメータについて

buyConsumable() には autoConsume パラメータがある(デフォルト true)。

// デフォルト(自動消費)- 購入成功時に即座に消費される
await _iap.buyConsumable(purchaseParam: param);

// 手動消費 - サーバー検証後に completePurchase() で消費
await _iap.buyConsumable(purchaseParam: param, autoConsume: false);

サーバーでレシート検証してからポイント付与する場合は autoConsume: false を推奨。検証前に消費されると、検証失敗時にポイント未付与なのに再購入できない状態になる可能性がある。

よくあるエラー

エラー対処
商品が見つからないConsole で「有効」になっているか確認、反映に数時間かかる場合あり
ストアが利用できないGoogle Playサービスがインストールされているか確認
テストなのに課金されるライセンステスターに正しく登録されているか確認

参考リンク