ドキュメント
Part 3. 運用・品質保証編
モバイルアプリのアプリ内課金 実装・運用設計ドキュメント の「Part 3. 運用・品質保証編」をまとめたページです。
Part 3. 運用・品質保証編
24. 通知と状態遷移の詳細マッピング
24-1. Apple の代表的な対応関係
| 入力 | 代表的な解釈 | Subscription.status | Entitlement.isActive |
|---|---|---|---|
SUBSCRIBED (subtype: INITIAL_BUY) | 初回購入 | ACTIVE または TRIALING | true |
DID_RENEW | 更新成功 | ACTIVE | true |
DID_RENEW (subtype: BILLING_RECOVERY) | billing retry / grace period からの回復 | ACTIVE | true |
DID_RECOVER | 支払い問題からの回復 | ACTIVE | true |
DID_FAIL_TO_RENEW (subtype: GRACE_PERIOD) | 猶予期間入り | IN_GRACE_PERIOD | true |
GRACE_PERIOD_EXPIRED | 猶予期間終了。以後は billing retry に入り、利用停止相当として扱う | ON_HOLD 相当(Apple 公式状態ではなく内部正規化) | false |
EXPIRED | 期限切れ | EXPIRED | false |
REFUND / REVOKE 系 | 返金・取消 | REVOKED | false |
Apple には
ON_HOLDという公式の状態名はありません。ここでは、GRACE_PERIOD_EXPIRED後に billing retry へ入った結果としての「支払い問題による利用停止相当」を、Apple 公式状態ではない内部正規化状態としてON_HOLD相当に寄せて扱っています(6-4 節参照)。
また、回復把握はDID_RECOVERとDID_RENEW(subtype:BILLING_RECOVERY)の両方を処理対象とし、最終判断は常に最新状態再照会で行います。通知名を 1 つに決め打ちせず、どちらを受けても同じ投影ロジックへ流す方針です。
24-2. Google の代表的な対応関係
| 入力 | 代表的な解釈 | Subscription.status | Entitlement.isActive |
|---|---|---|---|
SUBSCRIPTION_PURCHASED | 新規購入 | ACTIVE または TRIALING | true |
SUBSCRIPTION_RENEWED | 更新成功 | ACTIVE | true |
SUBSCRIPTION_RECOVERED | account hold / pause からの回復 | ACTIVE | true |
SUBSCRIPTION_IN_GRACE_PERIOD | 猶予期間入り | IN_GRACE_PERIOD | true |
SUBSCRIPTION_ON_HOLD | 保留 | ON_HOLD | false |
SUBSCRIPTION_CANCELED | 自動更新オフ | CANCELED または継続利用中 | 期限までは true |
SUBSCRIPTION_EXPIRED | 期限切れ | EXPIRED | false |
SUBSCRIPTION_REVOKED | 取消・返金 | REVOKED | false |
Google の返金・取消・チャージバックは、通常の subscription notification だけでなく
VoidedPurchaseNotificationやpurchases.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, BillingTransaction | 15〜60 分ごと |
| Google acknowledgement 救済ジョブ | new purchase token を伴う未 ack purchase を救済する | Subscription, BillingTransaction, GooglePurchaseToken | 5〜15 分ごと |
これらは、今回の最低要件である 決済失敗検知・回復追従・猶予期間終了追従 を安定して満たすため、初回リリースでも外しにくい処理です。
25-4. SHOULD の定期ジョブ
| ジョブ | 目的 | 入れると良い理由 |
|---|---|---|
| 期限近辺の再照合ジョブ | 更新直前 / 失効直後の状態ずれを早めに戻す | 通知欠落時の検知を速めやすい |
| 長期間未更新 active 契約の点検ジョブ | 長く変化のない契約をサンプリング再照合する | 静かな欠落を見つけやすい |
| voided purchase / refund 系の補助照合 | 返金・取消・チャージバックの監査を強める | 会計・CS 調査がしやすい |
| 監査差分レポート | 期待状態と実状態の差分を日次で確認する | 運用品質を上げやすい |
25-5. ジョブの入力元
定期ジョブの入力元は、基本的に次の 5 つです。
InboundWebhookEventSubscriptionBillingTransactionGooglePurchaseToken- 管理画面や運用者からの手動再照合要求
ジョブは ストア API を直接全件走査する のではなく、まず自前 DB から「怪しいもの」「期限が近いもの」「失敗が残っているもの」を絞ってから再照会します。
25-6. 再照合対象の選び方
優先順位の目安は次のとおりです。
acknowledgementStatus = PENDINGが残っているprocessingErrorが残っている- 期限切れ / 更新時刻が近い
- 直近に返金・取消・価格変更があった
- 長期間更新のない active 購読
この順に見ると、要件を壊しやすいもの から先に救済できます。
25-7. 実行方式
NestJS で実装する場合、推奨は次の形です。
- 同じモノレポ内に
apiapp とbatchapp を持つ - 課金ドメインの use case / repository / provider client は library に寄せる
apiapp は HTTP endpoint を公開するbatchapp は cron / command / queue worker を実行する
つまり、同じコードベースに統合してよいが、同じプロセスで動かす必要はない という整理です。
25-8. API app と batch app の責務分離
| app | 主な責務 |
|---|---|
api | 購入反映 API、通知受信 API、利用可否取得 API、管理 API |
batch | 失敗イベント再処理、通知欠落補正、ack 救済、期限近辺再照合、バックフィル |
libs | projection、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 | 数分ごと |
| 通知欠落補正 | MUST | 15〜60 分ごと |
| Google acknowledgement 救済 | MUST | 5〜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 -> ACTIVEACTIVE -> IN_GRACE_PERIOD -> ON_HOLD -> EXPIREDACTIVE -> 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. マスキング方針
purchaseToken、transactionId、orderId、appAccountToken は、
平文のままアプリケーションログへ大量出力しないほうが安全です。
検索性が必要なら、ハッシュや部分マスキングを併用します。
29-3. 監査ログの対象
- 再照合
- 再投影
- 移管申請
- 管理画面からの限定アクション
- 価格変更設定反映
- feature flag の変更
29-4. Google の返金・取消監査で追加するとよいもの
Google では、通常の RTDN とは別に VoidedPurchaseNotification と purchases.voidedpurchases.list を監査ソースとして持つと、返金・取消・チャージバックの追跡を強化できます。
VoidedPurchaseNotificationはイベント駆動で早く気付くために有用purchases.voidedpurchases.listは日次照合やバックフィルで有用purchaseTokenだけでなくorderId、voidedReason、voidedSourceも保存すると監査しやすい