PR
個人開発 Google PlayReact NativeRevenueCatサブスクリプション個人開発

アプリを二度直しても直らないなら、原因はアプリの外にある

サーバーラックに挿さったネットワークケーブル 個人開発

サブスクリプションを載せた ahha の開発中に、こういう症状に出会いました。

Google Play で購読を解約した。ところがアプリは相変わらず「プレミアム」と表示しています。

  • アプリ内の「状態を更新」ボタンを押しても変わらない
  • アプリを完全終了して再起動しても変わらない
  • 一日置いても変わらない
スポンサーリンク

二度の空振り

私はこれをアプリのバグだと考え、二度にわたって直しました。

一度目 — Entitlement ID の確認

RevenueCat の Entitlement 識別子が、ダッシュボードの設定とコードで一致しているかを確認しました。一致していました。症状そのまま。

二度目 — SDK キャッシュの回避

キャッシュが古いのかと思い invalidateCustomerInfoCache() を入れて強制照会に変えました。三層の安全網(起動時照会・アプリ復帰時の再照会・キャッシュ期限の検証)まで敷きました。症状はそのままでした。

ここで気づくべきでした。キャッシュを無効化してサーバーに直接聞いているのに答えが変わらないなら、サーバーが持っている値そのものが間違っているということです。

原因 — 通知経路が最初から繋がっていなかった

RevenueCat ダッシュボードの Play Store アプリ設定画面を開くと、こんなエラーが出ていました。

Google Cloud Pub/Sub API must first be enabled

その下の「Google developer notifications」欄は Topic ID が空で、「Connect to Google」ボタンは押せない状態でした。

[私が思っていた構造]
Google Play ──通知──> RevenueCat ──照会──> アプリ

[実際の構造]
Google Play    ✕      RevenueCat ──照会──> アプリ
            (経路なし)

Google が解約の事実を RevenueCat に伝える通路が、最初から存在していませんでした。

アプリがどれだけキャッシュを無効化して強制照会をしても、問い合わせ先である RevenueCat のサーバー自体が古い状態を持っています。アプリのコードでは絶対に解決できない問題でした。

Google 側の設定 — 罠がひとつある

1. GCP プロジェクトで Cloud Pub/Sub API を有効化

Google Play Console と紐づいている GCP プロジェクトで Pub/Sub API を有効にします。ここが無効だと以降の手順が全部塞がれます。

2. RevenueCat のサービスアカウントに権限を付与

RevenueCat が発行したサービスアカウントにPub/Sub 管理者のロールを与えます。

ここで罠にはまりました。ロール一覧を検索すると、こういうものが並びます。

Pub/Sub 管理者
Pub/Sub Lite 管理者     ← これを選んでしまった
Pub/Sub 編集者
Pub/Sub 購読者

Pub/Sub Lite はまったく別のサービスです。名前が似ているので選んでしまい、権限を与えたのに接続できずしばらく迷いました。

3. RevenueCat で Topic を接続

RevenueCat ダッシュボード → 該当アプリ → Google developer notifications で Topic を選び、「Connect to Google」を押します。

4. 実際に受信しているかを確認する

ここが肝心です。接続ボタンを押したら終わりではありません。「Last received」に実際の受信時刻が入るかを見ます。

入らなければどこかで切れています。テスト購読をひとつ作って状態を変えてみると、通知が来るかどうかが分かります。

5. Play Console にトピック名を登録

Google Play Console → 収益化の設定 で Pub/Sub トピック名を入力して保存します。

スポンサーリンク

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

モバイルアプリ開発の知識地図:一冊でiOS/Android/Flutterの開発技術が学べる本

楽天で見る

スポンサーリンク

モバイルアプリ開発の知識地図 / 國師誠也

モバイルアプリ開発の知識地図 / 國師誠也

Yahoo!ショッピングで見る

Apple 側は簡単だった

RevenueCat ダッシュボードの App Store アプリ設定に「Apply in App Store Connect」ボタンがあります。これを押すと RevenueCat が App Store Connect に Production・Sandbox 両方の通知 URL を自動登録します。

登録後に状態表示が configured correctly になるかを確認すれば完了です。

エラーバナーはすぐ消える

デバッグ中に踏んだ小さな罠をひとつ。

RevenueCat ダッシュボードのエラーバナーは、画面に入った直後だけ表示されて消えることがあります。他を確認してから戻ると、もうありません。

エラーバナーを見たらその場でスクリーンショットを撮っておくのが安全です。私はこれを逃して「確かに赤いものがあったはずだ」と画面を何度も再読み込みしました。

アプリ側にも問題があった — boolean では表せない

通知経路を直すと、二つ目の問題が見えてきました。

解約は即終了ではありません。Apple も Google も、解約しても支払い済みの期間中は購読が有効です。entitlements.active にそのまま残ります。

ところが私のアプリの状態モデルはこうでした。

const isPremiumVar = makeVar<boolean>(false);

boolean ひとつでは「解約したがまだ利用できる」を表す方法がありません。そのため解約直後にアプリを見ても「プレミアム」としか出ず、ユーザーは「解約できていない」と誤解します。

状態をこう分けました。

export type PlanStatus =
  | { kind: "unknown" }                                    // サーバー確認前
  | { kind: "free" }                                       // 無料
  | { kind: "active"; expiresAt: number | null }           // 有料・更新予定
  | { kind: "cancelled"; expiresAt: number | null }        // 解約済み・期間残あり
  | { kind: "billingIssue"; expiresAt: number | null };    // 支払い問題が発生

RevenueCat の CustomerInfo から、こう判定します。

/**
 * CustomerInfo から PlanStatus を導出する。
 *
 * billingIssue を先に判定するのは、支払い問題の復旧導線が最優先だからである。
 * 解約判定に unsubscribeDetectedAt を先に置く理由は本文を参照。
 */
function toPlanStatus(customerInfo: CustomerInfo): PlanStatus {
  const entitlement = customerInfo.entitlements.active[PREMIUM_ENTITLEMENT_ID];
  if (!entitlement) return { kind: "free" };

  const parsed = entitlement.expirationDate
    ? Date.parse(entitlement.expirationDate)
    : null;
  const expiresAt = parsed !== null && Number.isFinite(parsed) ? parsed : null;

  if (entitlement.billingIssueDetectedAt) {
    return { kind: "billingIssue", expiresAt };
  }
  if (entitlement.unsubscribeDetectedAt || !entitlement.willRenew) {
    return { kind: "cancelled", expiresAt };
  }
  return { kind: "active", expiresAt };
}

機能へのアクセス権は cancelledbillingIssue でも有効です。表示文言だけを分岐させます。

/**
 * プラン状態が「Premium 系」かどうかを判定する。
 *
 * cancelled・billingIssue も entitlement は有効なので機能アクセスは許可する。
 * 表示文言だけを状態ごとに分岐させる。
 */
export function isPremiumPlan(status: PlanStatus): boolean {
  return (
    status.kind === "active" ||
    status.kind === "cancelled" ||
    status.kind === "billingIssue"
  );
}

unsubscribeDetectedAt を先に見る理由

解約判定に willRenew だけを使わなかったのには理由があります。

willRenewサーバー通知を受けて更新される値です。通知が遅れると、解約したのに true のまま残ります。unsubscribeDetectedAt は「解約されたがまだ有効な購読」を検出するために RevenueCat が推奨しているフィールドです。

両方を見るのが安全です。

まとめ

決済・購読の不具合は、アプリのコードを漁る前に通知経路を確認します。RTDN(Google)・ASSN(Apple)が実際に接続され、実際に受信しているかどうか。これを先に見ていれば、二つのセッションを無駄にせずに済みました。

「アプリを直したのに直らない」が二度続いたら、原因がアプリの外にある可能性を先に検討します。同じ階層で三つ目の処方を試す前に、一階層外を疑うほうが速い。

解約は即終了ではありません。状態モデルを boolean にすると、この区間を表現できず、ユーザーは解約が失敗したと誤解します。

この記事について

自社アプリ ahha(React Native + Expo Bare Workflow・RevenueCat サブスク・AdMob)の開発中に踏んだ内容です。画面の経路は 2026 年 8 月時点のもので、RevenueCat のダッシュボード UI は変更が頻繁です。

Pub/Sub 管理者ロールが最小権限かどうかは検証していません。より狭いロールでも動く可能性があります。また App Store 側(ASSN)は RevenueCat の自動登録ボタンで処理したため、手動設定の場合に必要な手順は確認できていません。

スポンサーリンク

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

Coding Assistant対応 iPhoneアプリ開発集中講座 Xcode 26/iOS 26

楽天で見る

スポンサーリンク

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

iPhoneアプリ開発集中講座 iOS 26 Xcode 26 AIアシスタント(Coding Assistant)も解説

Yahoo!ショッピングで見る

コメント

タイトルとURLをコピーしました