2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

こんにちは、もんすんです。

個人開発のFlutterアプリに月額サブスク(Proプラン)を実装したときの話です。

最初、私は素直に in_app_purchase パッケージを使い、「このユーザーがProかどうか」をFirestoreに自前で保存して判定しようとしていました。
有効期限、自動更新フラグ、購入トークン……ぜんぶ自分で持って、自分で「期限切れてないか?」を毎回チェックする設計です。

結論から言うと、これは個人開発で手を出すと火傷🔥します。最終的にRevenueCatへ移行したのですが、そのコミット1発で判定まわりのコードが +77行 / -192行、差し引き115行も消えました。動くアプリにはなったものの、消えたコードたちは「本来書く必要のなかった負債」でした。

辛い……というほどではないですが、同じ轍を踏む第二・第三の私が生まれないように、ここに供養しておきます。
課金実装をこれから書く個人開発者の参考になれば幸いです。


環境

項目 バージョン
Flutter 3.x
状態管理 flutter_riverpod ^2.4
課金(移行前) in_app_purchase ^3.2 + Cloud Firestore
課金(移行後) purchases_flutter(RevenueCat) ^8.8
バックエンド Firebase(Auth / Firestore / Crashlytics)

RevenueCatは「アプリ内課金のレシート検証・サブスク状態管理を肩代わりしてくれる」SaaSです。iOS/Androidのストアと連携し、「このユーザーは今この権利(entitlement)を持っているか?」を1つのAPIで返してくれます。


何がマズかったのか:課金状態を「自分で計算」していた

移行前の私の設計はこうでした。

  1. in_app_purchase で購入が成功する
  2. 購入情報を SubscriptionStatusModelisActive / subscriptionId / expiryTimestamp / autoRenewing / purchaseToken)に詰める
  3. それを Firestoreのユーザードキュメントに保存
  4. アプリ起動のたびに「今が有効期限内か?」をクライアントで計算してProバッジを出す

問題は、購入成功時のこのコードです。

// 購入成功時の処理(移行前)
void _handleSubscriptionPurchased(String productId, String purchaseToken) async {
  final userInfoNotifier = ref.read(userInfoProvider.notifier);

  // Google/Appleサーバーを通じて取得するはずの有効期限
  // (ここではテスト用に1ヶ月後に設定)   ← 😇😇😇😇😇😇😇😇😇😇😇
  final now = DateTime.now();
  final expiryDate = DateTime(now.year, now.month + 1, now.day);
  final expiryTimestamp = expiryDate.millisecondsSinceEpoch;

  await userInfoNotifier.activateSubscription(
    subscriptionId: productId,
    expiryTimestamp: expiryTimestamp, // ← 端末時計を信じた「自称・有効期限」
    purchaseToken: purchaseToken,
    autoRenewing: true,
  );
}

お察しの通りでございます。

有効期限を、ストアのサーバーではなく DateTime.now() を起点にクライアントサイドで決め打ちしていました。
now.month + 1、つまり「今買ったから来月までね」と端末の時計が勝手に宣言しているだけ。これでは、

  • 端末の時計を1ヶ月戻せば永久にPro
  • 実際の課金がキャンセル・返金されても、Firestoreの expiryTimestamp は知らんぷり
  • ストアの自動更新が走っても、その事実をアプリは検知できない

つまり「課金の真実」はストア側にあるのに、私はその写し(しかも捏造に近い写し)をFirestoreに持って、それを正だと信じていたわけです。

自前管理が呼び込んだ「判定ロジックの肥大化」

そして自前で持つと、当然その状態を自分で面倒みる羽目になります。UserInfoStateNotifier には、こんなメソッドが生えていきました。

  • activateSubscription() — 購入時に有効化
  • updateSubscriptionStatus() — 状態更新
  • cancelSubscription() — キャンセル
  • deactivateExpiredSubscription() — 期限切れを無効化
  • hasActiveSubscription() — 有効判定
  • getDaysUntilSubscriptionExpires() — 残日数計算
  • getSubscriptionExpiryDate() — 期限の整形

さらに画面側(cards_page.dart)では、起動時のポストフレームコールバックで毎回こんなチェックをしていました。

void _checkSubscriptionStatus() {
  final userInfo = ref.read(userInfoProvider);
  if (userInfo.isPro) {
    if (!userInfo.subscription.isSubscriptionActive()) {
      // 期限切れなら無効化してスナックバー
      userInfoNotifier.deactivateExpiredSubscription();
      ScaffoldMessenger.of(context).showSnackBar(/* 期限切れました */);
    } else {
      // 「あと3日で切れます」みたいな通知も自前で…😇😇😇😇😇😇😇😇😇😇😇
      final daysUntilExpiry = userInfo.subscription.daysUntilExpiry();
      if (daysUntilExpiry <= 3 && !userInfo.subscription.willAutoRenew()) {
        // プラットフォーム分岐してストアの解約画面URLを叩く…
      }
    }
  }
}

「残り日数の通知」も「自動更新されるか」も、本来ストアが知っている情報を、自前のフラグから再計算していました。便利機能のつもりが、全部が捏造された有効期限の上に建つ砂上の楼閣だったわけです。


RevenueCatに寄せたら、判定が「聞くだけ」になった

移行後の発想はシンプルです。「課金の真実はストアにある。だからアプリは計算せず、RevenueCatに聞くだけ」

まず初期化(main.dart)。

Future<void> initPlatformState() async {
  await Purchases.setLogLevel(LogLevel.debug);
  final configuration = PurchasesConfiguration('(RevenueCatの公開APIキー)');
  await Purchases.configure(configuration);
}
await initPlatformState();

判定はこれだけ。pro という entitlement が今アクティブかをRevenueCatに問い合わせ、その結果でフラグを上書きするだけです。

Future<void> checkSubscriptionStatus(BuildContext context, WidgetRef ref) async {
  final userInfoNotifier = ref.read(userInfoProvider.notifier);
  try {
    final customerInfo = await Purchases.getCustomerInfo();
    customerInfo.entitlements.all.forEach((key, value) {
      if (key == 'pro') {
        // ストアが返す「今アクティブか」を、そのまま正とする
        userInfoNotifier.changeSubscriptionStatus(value.isActive);
      }
    });
  } on PlatformException catch (e) {
    // 取得失敗はCrashlyticsへ
    FirebaseCrashlytics.instance.recordError(e, StackTrace.current);
  }
}

そして UserInfoStateNotifier 側は、あの7メソッドがたった1つになりました。

// 「有効化/期限計算/キャンセル/残日数…」が全部消えて、これだけ👍👍👍👍👍👍👍
Future<void> changeSubscriptionStatus(bool isActive) async {
  if (state.isGuest()) return;
  state = state.copyWith(proFlg: isActive);
  await userFireStore.changeUserData(state);
}

expiryTimestampautoRenewingpurchaseToken も、アプリからは消えました。
期限管理はRevenueCat(とその裏のストア)の仕事です。
getCustomerInfo() を呼べば、解約も返金も自動更新も反映済みの「今の真実」が返ってきます。

Before / After

観点 移行前(自前) 移行後(RevenueCat)
有効期限の出どころ DateTime.now() で端末が決め打ち 😇 ストア由来をRevenueCatが管理
判定ロジック 7メソッド+画面側の期限チェック getCustomerInfo() を聞くだけ
解約・返金の反映 検知できない 自動で反映
レシート検証 自前(実質してない) RevenueCatが代行
Notifierのコード量 多い -115行(+77/-192)

実装の差し引き115行が消えたこと以上に、「正しく書こうとすると本来もっと膨らんでいたはずのコード」を丸ごと書かずに済んだのが大きい。
レシートのサーバー検証、ストアごとの差異、猶予期間(grace period)、価格改定……このあたりを個人開発で自前実装するのは、控えめに言って地獄です。


もう一つの落とし穴:entitlementが「空」のとき

移行後にもう1回ハマりました。新規ユーザーや未購入ユーザーは、customerInfo.entitlements.all空のMap で返ってきます。

最初のコードは forEachkey == 'pro' を探す作りだったので、そもそもentitlementが空だとループが回らず、Proフラグが更新されない
前の状態が残るケースがありました。
リリース後に「課金レシートなしの場合、Freeプランへ」というコミットで明示的に潰しています。

final entitlements = customerInfo.entitlements.all;
if (entitlements.isEmpty) {
  // 権利が何も無い=Free。ここを書き忘れると状態が宙ぶらりんになる
  userInfoNotifier.changeSubscriptionStatus(false);
  return;
}

「Proになる条件」だけでなく「Freeに戻る条件」も明示的に書く
当たり前のようでいて、状態を上書きで管理していると抜けがちなポイントです。


教訓まとめ

個人開発でアプリ内課金を実装する人へ、私からの供養込みの教訓です。

  • 課金状態の「真実」をクライアントやFirestoreに持たない。それはストアにある。アプリは計算せず「今アクティブか」を聞くだけにする。
  • 有効期限を DateTime.now() で決め打ちしない。端末の時計は信用できないし、解約・返金・自動更新を一切追えない。
  • レシート検証・期限管理は専門サービス(RevenueCat等)に寄せる。自前実装は、正しくやろうとするほど青天井に膨らむ。私の場合、寄せただけで判定ロジックが115行消えた。
  • 「権利を得る条件」と同じ熱量で「権利を失う条件」も書く。entitlementが空=Freeへ、を忘れない。

「動いているから、ヨシ!」で出していたら、端末の時計を戻すだけでPro使い放題のアプリをリリースするところでした。よく知らない領域こそ、自前で抱え込まず、肩代わりしてくれる仕組みを疑いの目で探してみてください。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?