課金機能のバグで、いちばん怖いのは「エラーが出るバグ」ではありません。

エラーが出ないバグです。

Kirokuというアプリで、クレジットパックを購入しても残高が増えないという症状が出ました。購入シートは正常に完了する。Google Playの決済も通る。アプリにエラー表示も出ない。ログにも何も出ない。残高だけが0のままでした。

原因にたどり着くまでに時間がかかり、その間11日間、課金が壊れたまま本番で動いていました。

症状

  • クレジットパックを購入 → Google Playの購入シートが正常に完了
  • アプリにエラー表示は出ない
  • Firestoreの credits フィールドは 0 のまま
  • Cloud Functions のエラーログにも何も出ていない

「エラーが出ていないなら関数は正常に動いているはずだ」と思いました。この思い込みが遠回りの原因でした。

切り分けの手順

推測をやめて、事実だけを1つずつ確認しました。

1. シークレットの存在を確認する

購入の検証にはRevenueCatのSecret Keyが必要です。まずそれがSecret Managerに登録されているかを見ました。

firebase functions:secrets:describe REVENUECAT_SECRET_KEY \
  --project ai-diary-shipaton-744bb

返ってきたのは 404 Not Found存在していませんでした。

2. 関数のデプロイ履歴を見る

次に、その関数がいつデプロイされたかを確認しました。

firebase functions:log --only addPurchasedCredits

CreateFunction イベントが 2026年8月4日の1件だけ。それ以降、UpdateFunction イベントが1件もありません。つまり8月4日以降、一度も再デプロイされていない。

3. 稼働中のリビジョンの中身を見る

そのレスポンスに含まれる serviceConfig を見ると、secretEnvironmentVariables が存在しませんでした。 現在動いている関数には、シークレットがバインドされていない。

4. 仕様と突き合わせる

ここで決定的なことに気づきました。

Firebase Functions v2 は、defineSecret で参照しているシークレットがSecret Managerに存在しないと、デプロイそのものが失敗します。

つまり、購入検証コードを含むいまのローカルコードは、一度もデプロイに成功していない

何が起きていたのか

整理するとこうです。

  1. 8月4日、購入検証なしの古い実装がデプロイされた
  2. その後、購入検証ありの新しい実装をローカルで書いた
  3. その実装は REVENUECAT_SECRET_KEY を参照している
  4. しかしSecret Managerに鍵が存在しないので、デプロイが毎回失敗していた
  5. 本番では、ずっと古い実装が動き続けていた

そして症状がここで繋がります。クライアントは新しい実装に合わせて {productId: 'credits_10'} という形でリクエストを送ります。しかし本番で動いている古い実装は、別のリクエスト形状(おそらく加算量を直接受け取る旧仕様)を期待しています。

受け取ったデータが期待と違うので、何も加算せずに正常終了する。 エラーにもならない。だから、ログに何も出なかったのです。

いちばん怖かったのは、残高が増えないことではなかった

原因がわかった時、もっと重い問題に気づきました。

11日間本番で動いていた「古い実装」には、購入検証が入っていません。 クライアントから「クレジットをください」と言われたら、購入したかどうかを確認せずに付与する実装です。しかもアプリは匿名認証を許可しています。

理論上、誰でも無制限にクレジットを取得できる状態が11日間続いていたということになります。

悪用されていたかを調べた

これは推測で済ませられないので、実データを見ました。一時的な診断エンドポイントを作り(確認後すぐに削除しました)、Firestoreの users コレクションを全件チェックしました。

結果は異常なしでした。判定の根拠はこうです。

  • credits が0でないユーザーは1人だけ
  • そのユーザーの内訳は processed_transactions サブコレクションに4件、すべて credits_10
  • タイムスタンプは修正版デプロイ(12:19 JST)のの 12:34〜15:05。自分が行った動作確認の購入と完全に一致

決め手は processed_transactions の存在でした。このサブコレクションは検証つきの新実装にしか存在しない概念です。古い脆弱な実装にはこの仕組み自体がありません。

もし脆弱期間に悪用があれば、「credits は持っているのに processed_transactions が空」というユーザーが残るはずです。該当者はゼロでした。

つまり、脆弱な実装は一度も呼び出しに成功していなかった。クライアント側のリクエスト形状が既に新仕様に変わっていたため、結果的に悪用も成立しなかったという、運に助けられた形でした。

解消

RevenueCatのダッシュボードでSecret API Keyを発行し、登録してデプロイしました。

firebase functions:secrets:set REVENUECAT_SECRET_KEY --project <PROJECT_ID>
firebase deploy --only functions:addPurchasedCredits --project <PROJECT_ID>

ちなみに、このSecret Keyはそれまで一度も発行されていませんでした。 RevenueCatのダッシュボードには公開キー(appl_/goog_で始まる)と秘密キー(sk_で始まる)があり、クライアントSDKで使うのは前者です。サーバー側の購入検証で必要なのは後者で、これは自分で発行しないと存在しません。そこを見落としていました。

デプロイ後、実機で3回購入して、実機のログ・RevenueCatのダッシュボード・Firestoreの残高の3箇所すべてで増加を確認しました。目視だけで合格にしないことは、以前の別のバグで学んだ教訓です。

ついでに見つかった2つのバグ

原因を追う過程で、無関係な既存バグも2つ出てきました。どちらも実害があるものでした。

1. キャンセルが「購入失敗」と表示されていた

// これは一度も捕捉できていなかった
} on PurchasesErrorCode catch (e) {

purchases_flutter SDKは PurchasesErrorCode ではなく PlatformException を投げます。 型が一致しないので、この catch は常に素通りして再スローされていました。ユーザーが購入をキャンセルしただけでも「購入に失敗しました」と表示されていたことになります。

2. 購入した瞬間に、他の手段で得たクレジットが消えていた

サーバーの返り値 credits はFirestore上の絶対値です。しかしクライアント側の残高の真実は端末内の別管理でした。旧実装はこう書いていました。

_creditService.setCredits(サーバーが返した絶対値);  // 上書き

購入が成功した瞬間に、初回無料分・連続記録ボーナス・月次プレミアム付与分が、まとめて消えます。 サーバー側に「今回加算した差分」を返す項目を足して、クライアントは加算する形に直しました。

教訓

「エラーが出ていない」は「正常に動いている」ではありません。 何も起きていないだけかもしれない。

そして今回いちばん効いたのは、推測をやめて事実を1つずつ確認したことでした。「デプロイされているはず」「鍵は設定されているはず」を全部いったん捨てて、describelog で実際の状態を見た瞬間に原因が出ました。

課金まわりを実装したら、成功パスを実機で通して、3箇所で突き合わせる。 これを必ずやることにしました。


このアプリの開発は、AIエージェントに役割を割り振った体制で行っています。その体制で何が効いたかはこちらの記事に書きました。運用にかかっている実際のクラウド費用はこちらで全部公開しています。