こんにちは、もんすんです。
個人開発の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で返してくれます。
何がマズかったのか:課金状態を「自分で計算」していた
移行前の私の設計はこうでした。
-
in_app_purchaseで購入が成功する - 購入情報を
SubscriptionStatusModel(isActive/subscriptionId/expiryTimestamp/autoRenewing/purchaseToken)に詰める - それを Firestoreのユーザードキュメントに保存
- アプリ起動のたびに「今が有効期限内か?」をクライアントで計算して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);
}
expiryTimestamp も autoRenewing も purchaseToken も、アプリからは消えました。
期限管理はRevenueCat(とその裏のストア)の仕事です。
getCustomerInfo() を呼べば、解約も返金も自動更新も反映済みの「今の真実」が返ってきます。
Before / After
| 観点 | 移行前(自前) | 移行後(RevenueCat) |
|---|---|---|
| 有効期限の出どころ |
DateTime.now() で端末が決め打ち 😇 |
ストア由来をRevenueCatが管理 |
| 判定ロジック | 7メソッド+画面側の期限チェック |
getCustomerInfo() を聞くだけ |
| 解約・返金の反映 | 検知できない | 自動で反映 |
| レシート検証 | 自前(実質してない) | RevenueCatが代行 |
| Notifierのコード量 | 多い | -115行(+77/-192) |
実装の差し引き115行が消えたこと以上に、「正しく書こうとすると本来もっと膨らんでいたはずのコード」を丸ごと書かずに済んだのが大きい。
レシートのサーバー検証、ストアごとの差異、猶予期間(grace period)、価格改定……このあたりを個人開発で自前実装するのは、控えめに言って地獄です。
もう一つの落とし穴:entitlementが「空」のとき
移行後にもう1回ハマりました。新規ユーザーや未購入ユーザーは、customerInfo.entitlements.all が 空のMap で返ってきます。
最初のコードは forEach で key == '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使い放題のアプリをリリースするところでした。よく知らない領域こそ、自前で抱え込まず、肩代わりしてくれる仕組みを疑いの目で探してみてください。