自社アプリ ahha にサブスクリプションを組み込んだとき、いちばん最後に見つかって、いちばん最後に直したバグがこれでした。
サンドボックスのサブスクが2 日前に期限切れになっています。それなのにアプリを開くと、こう出ます。
プレミアムプラン(継続中) 次回更新日: 2026/08/02
すでに過ぎた日付を「次回更新日」として表示しています。アプリを落として立ち上げ直しても同じ。何度やっても同じです。
ところが「プラン詳細を見る」を押すと、その瞬間に画面が「無料」に変わります。
先に結論を書いておきます
有効期限切れは、ストアの通知対象ではありません。
解約した・更新された・支払いに問題が起きた。これらは全部「起きた出来事」なので、ストアがその瞬間にプッシュを飛ばします。
期限切れは出来事ではありません。誰も何もしていないのに、時間が経ったことで状態が変わっただけです。プッシュすべきイベントが存在しません。
だから通知を待っていても永久に来ません。アプリ側が自分で取りに行くしかないのですが、そこにもう一段の落とし穴がありました。
最初に疑ったこと
- ボタンのクリックハンドラに、状態を書き換えるコードが紛れている
- 画面のレンダリングタイミングの問題
- キャッシュをどこかで読み違えている
三つとも外れです。正確には 3 番は方向が合っていたのに、どのキャッシュかを間違えていました。
「プラン詳細を見る」はストアの購読管理ページへ飛ばすボタンです。その URL を取るために getManagementURL() を呼んでいて、この関数が内部で getCustomerInfo() を叩きます。そこで初めて実際の状態に到達していたわけです。
クリックが原因ではなく、クリックがきっかけで、すでに起きていたズレが表面化しただけでした。
何が起きていたのか
RevenueCat を入れるとき、私はこう理解していました。
ストアで状態が変われば通知が来て、RevenueCat が最新状態を保つ。アプリは RevenueCat に聞くだけでいい
前半は合っていて、後半が間違いでした。
[期限切れ直前]
ストア → RevenueCat : このユーザーは active、期限 8/02 10:00
RevenueCat → SDK キャッシュ : active のスナップショットを保存
↓
8/02 10:00 経過
↓
[期限切れ後]
ストア : (通知なし)
RevenueCat サーバ : 期限を計算して free と判定 ← ここは正常
SDK ローカルキャッシュ : active のスナップショットのまま ← ここが問題
アプリ : キャッシュ値をそのまま表示 →「継続中」
RevenueCat の「サーバ」は期限切れをちゃんと知っています。問題はアプリ内の SDK ローカルキャッシュが期限切れ直前のスナップショットを持っていて、起動時の照会がそれを優先して返すことでした。
自己延長まで起きていました
ここに、自分で書いた構造が問題を増幅させていました。起動時にサーバ照会の結果を受け取って、ローカルストレージにも書き戻すコードがありました。
function applyCustomerInfo(customerInfo: CustomerInfo): boolean {
const status = toPlanStatus(customerInfo);
planStatusVar(status);
writePlanStatusCache(status); // ← キャッシュに書き戻し
const active = isPremiumPlan(status);
isPremiumVar(active);
return active;
}
SDK キャッシュから来た古い active が、アプリのキャッシュに書き戻され、次の起動でそれが初期値になり、また SDK キャッシュから古い値が来て、また書き戻される。
期限切れの状態が、自分自身を延長し続けるループができていました。
三層の安全網が全部すり抜けた
ここがこの件でいちばん効いた学びです。安全網は張ってありました。三層です。それでも通り抜けました。
| 層 | 内容 | なぜ効かなかったか |
|---|---|---|
| 1 | 起動時にサーバ照会 | 動いていた。ただしキャッシュ値を受け取っていた |
| 2 | AppState が active に戻ったら再照会 | コールドスタートだけ繰り返すとこのイベントが発生しない。テストがまさにその形だった |
| 3 | キャッシュの期限時刻を検証 | 正しく動く。ただし起動初期値にしか適用されない |
3 層目はこういうコードです。
if (expiresAt !== null && expiresAt <= Date.now()) {
expiredCacheKind = parsed.kind;
return { kind: "unknown" };
}
期限を過ぎたキャッシュは信用せず unknown を返します。ちゃんと動きます。
問題は、そのあとでした。初期値を unknown にきれいにセットしたところへ、数百ミリ秒後に到着したサーバ照会の結果(= SDK キャッシュの古い active)が上書きしていったのです。
三層とも「自分の担当範囲では正しく動いて」いました。それでも全体としては間違った値が表示される。層を増やすことと、抜け道を塞ぐことは別だという話です。
直したのは一行
変更前
useEffect(() => {
configurePurchases();
void refreshPremiumStatus(); // SDK キャッシュ優先
}, []);
変更後
useEffect(() => {
configurePurchases();
void refreshPremiumStatusForced(); // キャッシュ無効化してからサーバ照会
}, []);
二つの関数の違いは invalidateCustomerInfoCache() の一行だけです。
/**
* Premium 状態の強制更新(キャッシュ無効化 + サーバ照会)。
*
* 実状態の確定が必要な経路(起動時の seed など)で使う。
* 期限切れはストアの通知対象ではないため、SDK ローカルキャッシュには
* 期限切れ直前の active スナップショットが残る。
*/
export async function refreshPremiumStatusForced(): Promise<boolean> {
try {
await Purchases.invalidateCustomerInfoCache();
const customerInfo = await Purchases.getCustomerInfo();
return applyCustomerInfo(customerInfo);
} catch (err) {
console.warn("[app] Failed to force-refresh Premium status:", err);
return isPremiumVar();
}
}
コストは見合うのか
「毎回強制照会したら API コールが増えるのでは」という話になります。増えます。アプリ起動あたり 1 回です。
そしてそのコストは、お金が絡む画面が間違った値を出すことと比べる対象ではありません。「もう解約したのに、なぜ継続中と出るのか」とユーザーが思った瞬間に信頼が崩れます。逆に「払っているのに無料と出る」なら、もっと悪い。
副次的な効果もありました。機種変更した場合や、長く開いていなかったアプリを久しぶりに開いた場合も同時に整合します。どちらも SDK キャッシュが古い状態です。
照会失敗を free と断定しない
同じファイルにこのコメントを残しました。こちらも重要です。
/** * 照会失敗時に false と断定しない。通信障害・オフライン時に、 * 課金したユーザーの権限をアプリが勝手に回収すると、 * 金銭に関わる信頼を損なう。 * 最後に確認した状態を維持し、次の照会で訂正する。 */
catch で return false と書けばコードはきれいになります。ただし地下鉄でアプリを開いた有料ユーザーが、いきなり無料に降格します。最後に確認された状態を保って、次の照会で直すほうが正しい。
リリースビルドではログで追えない
このバグを追うのに時間がかかった理由がもう一つあります。
babel-plugin-transform-remove-console を使っているので、リリースビルドでは console.log が全部消えます。実機で再現する問題をログで追おうとして、何も出ませんでした。
結局こう判定しました。
- 画面に実際に表示される文言
- RevenueCat ダッシュボードの当該ユーザーのデータ
リリースビルドで状態の問題を追うときは、ログを期待せずに画面表記とサーバのダッシュボードを突き合わせるほうが速いです。
「反映されない」の三階層
同じ症状を何度か踏んでから整理したチェック順です。
| 階層 | 確認すること |
|---|---|
| ① ストア → RevenueCat | サーバ通知が実際に繋がっているか。ここが切れていればアプリを直しても無意味 |
| ② RevenueCat → SDK キャッシュ | 今回の主題。通知が正常でもローカルキャッシュは古くなる。特に期限切れは通知自体がない |
| ③ アプリの状態モデル | isPremium: boolean だけでは「解約済みだが有効期限内」を表現できない |
症状は全部「反映されない」で同じなのに、原因の階層が違います。アプリのコードから掘る前に ① を確認するほうが時間を節約できます。私はそれを二セッション分無駄にしてから覚えました。
① については別途書きます。アプリを二回直しても直らないとき、原因はアプリの外にありました。
この記事について
自社アプリ ahha(React Native + Expo Bare Workflow · RevenueCat 月額サブスク · AdMob)の開発中に実際に起きた問題です。2026 年 8 月時点の内容で、SDK の挙動もダッシュボードの画面も変わります。
確認しきれていないことも書いておきます。SDK 内部キャッシュの TTL の正確な数値はドキュメントに明記がなく、「キャッシュ優先で返る」ところまでしか確認していません。iOS サンドボックスでのみ再現確認しており、Android はコード経路が同じなので同一と判断しましたが実測ではありません。


コメント