Firestoreのis_premium: trueなのに、なぜプレミアム機能が動かない? — RevenueCatとの不一致という個人開発の落とし穴

こんにちは、SINQWELLです。

先日、月謝袋アプリの v1.13.0 に「月謝入力画面から未納一覧にアクセスできる」新機能を実装している時、思わぬ罠にハマりました。

Firestoreを見ると自分のユーザーは is_premium: true になっているのに、アプリ内では「通常ユーザー」と判定されて、プレミアム機能のダイアログが出てしまう。

「あれ、なぜ?」

犯人は、アプリ内にプレミアム判定のロジックが2種類あって、それらが必ずしも一致しないという設計上の落とし穴でした。

同じような構成でアプリを作っている個人開発者の方に、共有しておく価値がありそうなので、記事にしてみます。

目次

発生した問題

月謝袋アプリのカレンダー画面(既存機能)では、以下のような2段階チェックをしていました:

  1. まず Firestore の is_premium フラグをチェック
  2. Firestore で false なら、RevenueCat の状態をチェック

これで、両方のケースに対応できます。

でも、私が新しく実装した「未納一覧」ボタンでは、うっかり RevenueCatだけをチェックしてしまいました:

final isPremium = await PurchaseService().checkPremiumStatus();

一見、正しそうに見えます。でも、これだと is_premium: true に手動設定した自分のアカウントが、「通常ユーザー」と判定されてしまうんです。

発見のきっかけ

シミュレータでテスト中、私は自分のアカウント(Firestoreで手動 is_premium: true)でログインしていました。

未納一覧ボタンを押す → プレミアムプランのご案内が出る。

「え、私はプレミアムユーザーなのに?」

一度ログアウトして、別のアカウントでログインし直しても同じ。ログを見てみると、こんなメッセージが:

✅ RevenueCatログイン: [UID]
ℹ️ 通常ユーザ

RevenueCat側では「通常ユーザー」と判定されている。でもFirestoreを直接見ると is_premium: true。

Firestore と RevenueCat の状態が、必ずしも一致しないということに、この時初めて気づきました。

前提:プレミアム機能のよくある構成

月謝袋アプリでは、プレミアム機能を以下のように管理しています:

  • RevenueCat:実際の課金・購読状態を管理する外部サービス
  • Firestore の is_premium フラグ:アプリ側で独自に持つプレミアム状態

この2つの構成、実は個人開発でプレミアム機能を実装する際にはとてもよくあるパターンです。

なぜ2つ持つのか

「RevenueCatだけで管理すれば十分では?」と最初は思うかもしれません。私もそうでした。

でも、実際に運用してみると、以下のようなケースでFirestore側のフラグが必要になります:

  • 手動で有効化したいユーザーがいる時
  • テスト用の自分自身のアカウント
  • キャンペーンやプロモーションでの無料付与
  • 開発者用アカウント
  • 旧バージョンからの移行ユーザー
  • プレミアム機能をリリースする前から使っていたユーザーへの配慮
  • RevenueCat 側で管理していない特殊ケース
  • 教育機関への一括提供
  • 招待コード的な仕組み

こういった「実際の課金は伴わないけれど、プレミアム機能を使わせたい」ケースを、RevenueCat の外側で管理するための仕組みが、Firestore の is_premium フラグです。

解決策:2段階チェックのヘルパー関数

対応方法は、既存のカレンダー画面と同じ設計を採用することでした:

Future<bool> _checkPremiumStatus() async {
  bool isPremium = false;

  // ① Firestore の is_premium フラグをチェック
  try {
    final userDoc = await FirebaseFirestore.instance
        .collection('user')
        .doc(currentUserUid)
        .get();
    isPremium = userDoc.data()?['is_premium'] as bool? ?? false;
  } catch (e) {
    print('Firestoreプレミアム確認エラー: $e');
  }

  // ② Firestore で false なら RevenueCat をチェック
  if (!isPremium) {
    try {
      isPremium = await PurchaseService().checkPremiumStatus();
    } catch (e) {
      print('RevenueCatプレミアム確認エラー: $e');
    }
  }

  return isPremium;
}

これで、どちらのケースでも正しくプレミアム加入者として認識されるようになりました。

教訓

今回の経験から、いくつか学びがありました。

教訓①:プレミアム判定のロジックはヘルパー関数に集約する

同じ判定を複数の場所に書くと、今回のような不整合が起きます。「プレミアムかどうか」を返すヘルパー関数を最初から作っておいて、全画面でそれを使うのが理想です。

教訓②:RevenueCat と Firestore は必ずしも一致しない

これは頭では分かっていても、実装の時にはつい忘れがち。「なぜプレミアム機能が動かない?」となった時、真っ先に疑うべきポイントです。

教訓③:自分の開発アカウントで手動フラグを立てていることを忘れない

私は Firestore で自分自身を is_premium: true に手動設定していました。これは開発者としてはよくある設定です。でも、この設定が、実装のバグを覆い隠してしまうリスクがあります。

「自分では動いているように見えるけど、実際のユーザーには動かない」という状況は避けたいので、時々は普通のユーザーアカウントでテストすることも大切です。

まとめ

Firestore と RevenueCat の不一致は、個人開発でプレミアム機能を実装している人なら、いずれ遭遇する可能性が高い落とし穴です。

もし新しくプレミアム機能を追加する時は、判定ロジックを1つのヘルパー関数に集約して、その中で Firestore優先 → RevenueCatフォールバック の順で確認する設計にしておくと安心です。

私自身、同じアプリの中で判定ロジックが2つ存在していたことに、この機会に初めて気づきました。1つはカレンダー画面用、もう1つは新しい未納一覧用。将来的にはこれも共通化して、プロダクト全体で1つのヘルパー関数で判定するようにリファクタリングしていきたいと思っています。

小さな不整合が思わぬバグにつながる。個人開発の醍醐味であり、難しさでもありますね。


関連リンク

目次