ドキュメント

Part 3. 運用・品質保証編

モバイルアプリのアプリ内課金 実装・運用設計ドキュメント の「Part 3. 運用・品質保証編」をまとめたページです。

Part 3. 運用・品質保証編

24. 通知と状態遷移の詳細マッピング

24-1. Apple の代表的な対応関係

入力代表的な解釈Subscription.statusEntitlement.isActive
SUBSCRIBED (subtype: INITIAL_BUY)初回購入ACTIVE または TRIALINGtrue
DID_RENEW更新成功ACTIVEtrue
DID_RENEW (subtype: BILLING_RECOVERY)billing retry / grace period からの回復ACTIVEtrue
DID_RECOVER支払い問題からの回復ACTIVEtrue
DID_FAIL_TO_RENEW (subtype: GRACE_PERIOD)猶予期間入りIN_GRACE_PERIODtrue
GRACE_PERIOD_EXPIRED猶予期間終了。以後は billing retry に入り、利用停止相当として扱うON_HOLD 相当(Apple 公式状態ではなく内部正規化)false
EXPIRED期限切れEXPIREDfalse
REFUND / REVOKE返金・取消REVOKEDfalse

Apple には ON_HOLD という公式の状態名はありません。ここでは、GRACE_PERIOD_EXPIRED 後に billing retry へ入った結果としての「支払い問題による利用停止相当」を、Apple 公式状態ではない内部正規化状態として ON_HOLD 相当に寄せて扱っています(6-4 節参照)。
また、回復把握は DID_RECOVERDID_RENEW(subtype: BILLING_RECOVERY)の両方を処理対象とし、最終判断は常に最新状態再照会で行います。通知名を 1 つに決め打ちせず、どちらを受けても同じ投影ロジックへ流す方針です。

24-2. Google の代表的な対応関係

入力代表的な解釈Subscription.statusEntitlement.isActive
SUBSCRIPTION_PURCHASED新規購入ACTIVE または TRIALINGtrue
SUBSCRIPTION_RENEWED更新成功ACTIVEtrue
SUBSCRIPTION_RECOVEREDaccount hold / pause からの回復ACTIVEtrue
SUBSCRIPTION_IN_GRACE_PERIOD猶予期間入りIN_GRACE_PERIODtrue
SUBSCRIPTION_ON_HOLD保留ON_HOLDfalse
SUBSCRIPTION_CANCELED自動更新オフCANCELED または継続利用中期限までは true
SUBSCRIPTION_EXPIRED期限切れEXPIREDfalse
SUBSCRIPTION_REVOKED取消・返金REVOKEDfalse

Google の返金・取消・チャージバックは、通常の subscription notification だけでなく VoidedPurchaseNotificationpurchases.voidedpurchases.list でも追跡できます。REVOKED の確定を RTDN 1 本に寄せすぎず、監査やバックフィルでは voided purchase 系も参照できるようにしておくと安全です。 また、Google では 猶予期間終了 専用の永続状態を別に持つというより、silent grace period 後の結果として SUBSCRIPTION_ON_HOLD / SUBSCRIPTION_CANCELED / SUBSCRIPTION_EXPIRED / SUBSCRIPTION_RENEWED / SUBSCRIPTION_RECOVERED のいずれかを観測して扱います。

24-3. 通知をそのまま信用しない理由

1-1 節・1-3 節で述べたとおり、通知には順序逆転や取りこぼしがあります。
したがって、通知は**「再照会のきっかけ」**と考え、最終状態はストア再照会結果から決めるのが安全です。

25. 定期ジョブと補正処理

25-1. この章の目的

この章でいう「バッチ」は、毎晩全件を総なめする重い一括処理を指しません。
ここで必要なのは、通知取りこぼし・一時障害・ack 漏れ・状態ずれを定期的に補正する処理 です。

Apple 通知や Google RTDN を主経路にしても、次のような取りこぼしは現実に起こり得ます。

  • Webhook / RTDN の一時受信失敗
  • 受信後のストア再照会失敗
  • 一時的な DB 障害
  • Google acknowledgement 漏れ
  • 自動更新・猶予終了の追従遅れ

そのため、イベント駆動を主経路としつつ、定期ジョブで整合性を戻す 前提で設計します。

25-2. バッチは必要か

結論として、重い夜間バッチは不要でも、定期ジョブは必要 です。

MUST

  • 失敗イベント再処理
  • 通知欠落補正
  • Google acknowledgement 救済

SHOULD

  • 期限近辺の再照合
  • 長期間更新のない active 契約の点検
  • voided purchase / refund 系の補助照合
  • 監査差分レポート

25-3. MUST の定期ジョブ

ジョブ目的主な入力元頻度の目安
失敗イベント再処理ジョブprocessingError が残った通知や再照会失敗を再投入するInboundWebhookEvent数分ごと
通知欠落補正ジョブ通知取りこぼしや順序逆転でずれた状態を戻すSubscription, BillingTransaction15〜60 分ごと
Google acknowledgement 救済ジョブnew purchase token を伴う未 ack purchase を救済するSubscription, BillingTransaction, GooglePurchaseToken5〜15 分ごと

これらは、今回の最低要件である 決済失敗検知・回復追従・猶予期間終了追従 を安定して満たすため、初回リリースでも外しにくい処理です。

25-4. SHOULD の定期ジョブ

ジョブ目的入れると良い理由
期限近辺の再照合ジョブ更新直前 / 失効直後の状態ずれを早めに戻す通知欠落時の検知を速めやすい
長期間未更新 active 契約の点検ジョブ長く変化のない契約をサンプリング再照合する静かな欠落を見つけやすい
voided purchase / refund 系の補助照合返金・取消・チャージバックの監査を強める会計・CS 調査がしやすい
監査差分レポート期待状態と実状態の差分を日次で確認する運用品質を上げやすい

25-5. ジョブの入力元

定期ジョブの入力元は、基本的に次の 5 つです。

  1. InboundWebhookEvent
  2. Subscription
  3. BillingTransaction
  4. GooglePurchaseToken
  5. 管理画面や運用者からの手動再照合要求

ジョブは ストア API を直接全件走査する のではなく、まず自前 DB から「怪しいもの」「期限が近いもの」「失敗が残っているもの」を絞ってから再照会します。

25-6. 再照合対象の選び方

優先順位の目安は次のとおりです。

  1. acknowledgementStatus = PENDING が残っている
  2. processingError が残っている
  3. 期限切れ / 更新時刻が近い
  4. 直近に返金・取消・価格変更があった
  5. 長期間更新のない active 購読

この順に見ると、要件を壊しやすいもの から先に救済できます。

25-7. 実行方式

NestJS で実装する場合、推奨は次の形です。

  • 同じモノレポ内に api app と batch app を持つ
  • 課金ドメインの use case / repository / provider client は library に寄せる
  • api app は HTTP endpoint を公開する
  • batch app は cron / command / queue worker を実行する

つまり、同じコードベースに統合してよいが、同じプロセスで動かす必要はない という整理です。

25-8. API app と batch app の責務分離

app主な責務
api購入反映 API、通知受信 API、利用可否取得 API、管理 API
batch失敗イベント再処理、通知欠落補正、ack 救済、期限近辺再照合、バックフィル
libsprojection、restore、reconcile、provider client、repository、lock utility

重要なのは、batch から controller を呼ばない ことです。
API と batch は同じ use case / service を共有し、入口だけを分けます。

25-9. 多重実行対策

バッチは、API よりも 二重実行 に弱いです。
そのため、少なくとも次のどれかを入れます。

  • Subscription 単位の排他
  • DB ロック / FOR UPDATE SKIP LOCKED
  • Redis ロック
  • queue の single consumer 制御
  • idempotency key

また、ジョブの成功 / 失敗だけでなく、最終的に Subscription / Entitlement が正しい状態へ収束したか を監視します。

25-10. 実行頻度の目安

ジョブMUST/SHOULD頻度の目安
失敗イベント再処理MUST数分ごと
通知欠落補正MUST15〜60 分ごと
Google acknowledgement 救済MUST5〜15 分ごと
期限近辺の再照合SHOULD数時間ごと
長期間未更新 active 契約の点検SHOULD日次
監査差分レポートSHOULD日次

頻度は固定値ではなく、購入件数・通知量・ストア API のレート制限 を見ながら調整します。

25-11. 監視とアラート

定期ジョブ専用に見ておきたいものは次のとおりです。

  • ジョブごとの最終成功時刻
  • ジョブ失敗回数
  • 再処理待ち件数
  • 未 ack 件数
  • processingError 滞留件数
  • 再照合対象抽出件数の急増
  • ジョブ実行時間の急増
  • DLQ 相当の退避件数

ジョブは「動いたか」だけでは不十分で、救済対象をきちんと減らせているか を見ます。

25-12. 実装時の注意点

  • Webhook の同期処理で重い再照会をしすぎない
  • API app に cron を分散配置しない
  • batch から controller を呼ばず、use case / service を呼ぶ
  • ストア API のレート制限を前提に backoff を入れる
  • ジョブはすべて冪等前提で設計する
  • 「ジョブ成功」で安心せず、投影結果を監視する
  • バックフィルや手動再照合は通常ジョブと実行経路を分ける

26. 障害復旧とバックフィル

26-1. 復旧方針

障害時は、「通知を再生する」より「現在のストア状態へ再収束させる」ことを優先します。

26-2. Apple 側

  • Notification History を使って一定期間の通知を回収する
  • 必要なら transaction / status を再照会する
  • production first で呼び、必要に応じて sandbox へフォールバックする

26-3. Google 側

  • RTDN の未処理イベントを再投入する
  • purchaseToken ごとに purchases.subscriptionsv2.get を再実行する
  • linkedPurchaseToken の連鎖がある場合は最新 token を中心に整合性を確認する

26-4. ランブックに残すべき項目

  • インシデントの起点
  • 影響範囲の抽出方法
  • 再照合対象の抽出 SQL
  • 再投影ジョブの実行手順
  • ユーザー通知要否の判断基準

27. 詳細テスト戦略

27-1. テストレイヤー

  • 単体テスト: projection 関数を pure function として検証する
  • integration test: Prisma を含む DB 更新を確認する
  • 結合テスト: Apple Sandbox / Google license tester で E2E を確認する
  • 本番リプレイテスト: 匿名化した本番 payload をフィクスチャとして再生する

27-2. 最優先で自動化したいケース

  • 同一通知の再送
  • 通知順序逆転
  • PENDING -> ACTIVE
  • ACTIVE -> IN_GRACE_PERIOD -> ON_HOLD -> EXPIRED
  • ACTIVE -> REVOKED
  • Google plan change と re-signup
  • restore の誤紐付け防止

27-3. フィクスチャ戦略

  • Apple 通知 payload
  • Google RTDN payload
  • Apple transaction / status のレスポンス
  • Google purchases.subscriptionsv2.get のレスポンス

生に近い JSONで保存しておくと、仕様変更やリグレッションに強くなります。

27-4. テストコード例

describe("projectSubscriptionState", () => {
  it("returns grace period as active entitlement", () => {
    expect(
      projectSubscriptionState({
        provider: "GOOGLE",
        currentStatus: "ACTIVE",
        storeState: "ACTIVE",
        inTrial: false,
        inGracePeriod: true,
        onHold: false,
      }),
    ).toEqual({
      nextStatus: "IN_GRACE_PERIOD",
      entitlementActive: true,
    });
  });

  it("returns canceled with active entitlement (auto-renew off but within paid period)", () => {
    expect(
      projectSubscriptionState({
        provider: "GOOGLE",
        currentStatus: "ACTIVE",
        storeState: "CANCELED",
        inTrial: false,
        inGracePeriod: false,
        onHold: false,
      }),
    ).toEqual({
      nextStatus: "CANCELED",
      entitlementActive: true,
    });
  });

  it("returns revoked with inactive entitlement", () => {
    expect(
      projectSubscriptionState({
        provider: "APPLE",
        currentStatus: "ACTIVE",
        storeState: "REVOKED",
        inTrial: false,
        inGracePeriod: false,
        onHold: false,
      }),
    ).toEqual({
      nextStatus: "REVOKED",
      entitlementActive: false,
    });
  });
});

28. 管理画面・CS・運用権限

28-1. 管理画面で見えるとよい情報

  • ユーザー ID
  • BillingAccount.accountToken
  • Apple originalTransactionId
  • Google purchaseToken
  • 現在の Subscription.status
  • 現在の Entitlement.isActive
  • acknowledgement 状態
  • 直近の BillingTransaction
  • 直近の InboundWebhookEvent
  • 最終再照合時刻

28-2. CS に許可する操作

  • 再照合の実行
  • 通知再投影の実行
  • 移管申請の起票
  • 内部メモの記録

28-3. CS に許可しない操作

  • Subscription.status の直接上書き
  • Entitlement.isActive の直接上書き
  • ストア状態と整合しない手動復旧

29. ログ・監査・保持ポリシー

29-1. 何を残すか

  • Webhook / RTDN 生 payload
  • 署名検証結果
  • 再照会レスポンスの要約
  • 状態遷移前後
  • 人手による操作履歴

29-2. マスキング方針

purchaseTokentransactionIdorderIdappAccountToken は、
平文のままアプリケーションログへ大量出力しないほうが安全です。
検索性が必要なら、ハッシュや部分マスキングを併用します。

29-3. 監査ログの対象

  • 再照合
  • 再投影
  • 移管申請
  • 管理画面からの限定アクション
  • 価格変更設定反映
  • feature flag の変更

29-4. Google の返金・取消監査で追加するとよいもの

Google では、通常の RTDN とは別に VoidedPurchaseNotificationpurchases.voidedpurchases.list を監査ソースとして持つと、返金・取消・チャージバックの追跡を強化できます。

  • VoidedPurchaseNotification はイベント駆動で早く気付くために有用
  • purchases.voidedpurchases.list は日次照合やバックフィルで有用
  • purchaseToken だけでなく orderIdvoidedReasonvoidedSource も保存すると監査しやすい