💎

Flutter × iOS でアプリ内課金を実装する(in_app_purchase + StoreKit)

に公開

はじめに

こんにちは、株式会社ジャンボ でエンジニアをしている OKA です。
今年4月からFlutterでのアプリ開発を行っています。
初めてFlutterのパッケージを使用してiOSでの課金実装を行ったので実装方法と注意点などを忘れないためにも記録しておくことにしました。

なぜこの記事を書いたのか?

  • 公式パッケージ in_app_purchase & in_app_purchase_storekit の落とし穴をまとめることで、同じようにハマった方の助けになればうれしい。
  • アウトプットして自分の理解をもう一段深めたかった。

前提条件

項目 内容
パッケージ
in_app_purchase  3.2.1 – OS を跨いで使える公式課金ラッパー
in_app_purchase_storekit 0.3.21iOS 専用のバックエンドin_app_purchase が iOS で動く際に内部で呼ばれる
アーキテクチャ MVVM + Riverpod
対象 OS iOS のみ(Android は今回触れません)
課金アイテム App Store Connect上で追加済み
SANDBOXアカウント App Store Connect上で追加済み

Xcode 側の設定

  • Signing & Capabilities → +Capability → “In‑App Purchase” を追加

サンプルコード

pubspec.yaml へ依存追加

追加後、flutter pub getを実行する

dependencies:
  flutter:
    sdk: flutter
  in_app_purchase: ^3.2.1            # ← 必須
  in_app_purchase_storekit: ^0.3.21  # ← iOS 用バックエンド

購入処理のポイント解説

InAppPurchaseServiceは課金処理の中心となるサービスで、以下の責務を持ちます。

1. AppStoreから製品情報を取得

final productQueryResult = await _inAppPurchase.queryProductDetails(productIds);

指定したproductIdsを使って、AppStoreに登録済みの課金アイテム情報(ProductDetails)を取得します。
ここで取得した情報をもとに画面に商品リストを表示します。

Appleから取得できる製品情報(ProductDetails)

以下のような情報がProductDetailsとして取得できます。

フィールド名 説明
id 製品ID(App Store Connect で設定) com.example.coin100
title 表示用タイトル 100 コインパック
description 商品説明 アプリ内で使えるコイン100枚
price 通貨記号付きの整形済み価格 ¥160
rawPrice 小数値としての価格 160.0
currencyCode 通貨コード(ISO 4217形式) JPY
currencySymbol 通貨記号(ロケールに応じて取得される) ¥

2. 購入を開始する

await _inAppPurchase.buyConsumable(
  purchaseParam: PurchaseParam(productDetails: productDetails),
  autoConsume: true,
);

buyConsumable()を使って、Appleの純正購入シートを表示し、課金処理を開始します。
autoConsume: trueを付けることでiOS側での自動消費が有効になります。
autoConsume: trueを指定しないとAppleの純正購入シートが表示されませんでした(↓これ)

3. 購入ストリームの監視と処理

void _handlePurchaseStream(List<PurchaseDetails> purchaseDetailsList) async {
  for (final purchaseDetails in purchaseDetailsList) {
    // pendingCompletePurchase が true の場合は先に完了処理
    if (purchaseDetails.pendingCompletePurchase) {
      await _inAppPurchase.completePurchase(purchaseDetails);
    }

    final waitingCompleter = _pendingPurchaseCompleters[purchaseDetails.productID];
    if (waitingCompleter == null) continue;

    switch (purchaseDetails.status) {
      case PurchaseStatus.purchased:
      case PurchaseStatus.restored:
        waitingCompleter.complete(purchaseDetails);
        break;
      case PurchaseStatus.canceled:
        waitingCompleter.completeError(PurchaseCanceledException());
        break;
      case PurchaseStatus.error:
        waitingCompleter.completeError(
          Exception(purchaseDetails.error?.message ?? '購入失敗'),
        );
        break;
      default:
        break;
    }

    // pending 以外の状態になったら待機リストから除去
    if (purchaseDetails.status != PurchaseStatus.pending) {
      _pendingPurchaseCompleters.remove(purchaseDetails.productID);
    }
  }
}
  • pendingCompletePurchase == true の場合、必ず completePurchase() を呼び出します。これは Apple 側で購入処理を完了させるための必須ステップです。
  • 購入のステータスは PurchaseStatus で判定され、状態ごとに適切な処理(成功・キャンセル・エラー)を実行します。
  • 完了した処理は _pendingPurchaseCompleters から削除して管理します。

このステップがないと、「購入は成功しているのにアイテムが付与されない」「ストリームが永遠に待機してしまう」といった問題が発生します。

purchaseStreamとは?

_inAppPurchase.purchaseStream.listen(_handlePurchaseStream);

Appleの課金処理結果を非同期に受け取るためのストリームです。
ユーザーが購入を完了・キャンセル・エラーなどの操作をしたあと、
その状態が PurchaseDetails として配信され、
指定したハンドラ(ここでは _handlePurchaseStream)に通知されます。

4. 購入処理の統合(purchasePackage()

purchasePackage() は、実際に課金処理を行う際にアプリ側で呼び出す中心的な関数です。
この中で、製品取得 → 課金 → レシート送信までを1つの流れとしてまとめています。

Future<void> purchasePackage(PointPackageEntity package) async {
  // 1. 商品取得(App Store から最新の価格や情報を取得)
  await checkAvailability();
  final detailsList = await fetchProducts({package.productId});
  if (detailsList.isEmpty) throw Exception('商品が取得できませんでした');
  // 2. 購入(Appleの純正購入シートが表示される)
  final PurchaseDetails purchaseDetails = await buy(detailsList.first);
  // 3. レシート検証
  final receipt = extractReceipt(purchaseDetails);
  final transactionId = purchaseDetails.purchaseID ?? '';
  await _confirmPurchaseDataStore.confirmPurchase(
    transactionId: transactionId,
    receipt: receipt,
  );
  // 4. 完了報告(購入処理がすべて終わったことをAppleに通知)
  await finish(purchaseDetails);
}
  /// ストアに接続できるか確認(App Store使用可か)
  Future<void> checkAvailability() async {
    final ok = await _inAppPurchase.isAvailable();
    if (!ok) throw Exception('ストアに接続できませんでした');
  }

  /// レシート(Base64形式)を取り出す
  String extractReceipt(PurchaseDetails details) => details.verificationData.serverVerificationData;

  /// 完了報告
  Future<void> finish(PurchaseDetails details) => _inAppPurchase.completePurchase(details);

この関数はViewModel側から下記のように呼び出すことができます

final ok = await purchasePackage(pointPackage);
if (ok) {
    // 例)ポイントを付与する
}

🪤 ハマりポイント & 注意点

詰まったこと 解決 / 理由
実機の Apple ID でサインインしていないと製品情報が取得できない シミュレータや Apple ID 未登録端末は StoreKit サーバーへの認証が通らない
SANDBOX で製品取得が不安定 今回は課金アイテムのステータスを「提出準備完了」にしており、バイナリの申請とまとめて出す予定だったため一時的に取得できなかった可能性あり
Apple純正の購入シートが出ない buyConsumable()autoConsume: true を忘れていないか確認(iOS では true 固定が安全)
App Store Connect に “スクリーンショット” を登録していないと製品が見つからない 申請前でも ローカライズ情報 & スクショの設定 必須

🏁 さいごに

StoreKit2を使用しての実装経験があったおかげで めっっちゃ詰まる みたいなことはありませんでしたが、Flutterパッケージ特有のクセで小さな落とし穴がいくつかありました。

同じように 「Swift → Flutter で課金を実装してみたい」 方の助けになれれば幸いです!


📢 採用情報

株式会社ジャンボではエンジニアを 募集中 です!
カジュアル面談もウェルカムなのでお気軽にどうぞ 🙌

Jambo Tech Blog

Discussion