Documentation
Open menu

Theme

On this page

Documentation

Mobile Iap Backend Design Expanded Primary Sources Fixed V5

Project documentation.

モバイルアプリのアプリ内課金 実装・運用設計ドキュメント

対象技術スタック: NestJS / TypeScript / Prisma / PostgreSQL
対象課金: Apple App Store / Google Play のモバイルアプリ内課金
想定要件: 月額サブスク、購入復元、自動更新、解約、返金、決済失敗時の猶予期間・保留、トライアル、価格変更、将来的な Web 決済追加


エグゼクティブサマリ

本ドキュメントは、NestJS / TypeScript / Prisma を前提に、月額サブスク、購入復元、自動更新管理、解約・返金反映、決済失敗時の猶予期間や保留状態、トライアル管理、価格変更対応、商品カタログ運用、アカウント例外処理、バッチ / ジョブ設計、障害復旧、管理画面 / CS 運用、将来の Web 決済共存までを含めて、アプリ内課金機能を安全に実装・運用するための設計を整理したものです。

基本方針は、ストアを真実の源泉、自社 DB を投影先として扱うことです。Google はバックエンドで購入状態を管理することを強く推奨しており、RTDN 受信後に Subscription lifecycleReal-time developer notifications reference guide に沿って Developer API で最新状態を取得する構成が基本です。Apple 側も Validating receipts with the App Store で、verifyReceipt ではなく App Store Server API や署名済みトランザクション情報を主軸にする方向を明示しています。

結論として、現在の実務上の推奨は次のとおりです。

  • Apple
  • Google
    • RTDN は状態変化の通知であり、完全な状態そのものではない。
    • 受信後に必ず purchases.subscriptionsv2.get を呼び、subscriptionStateacknowledgementStatelineItemslinkedPurchaseToken などを確認してから DB を更新する。
  • ユーザー紐付け
    • Apple は appAccountToken、Google は setObfuscatedAccountId / setObfuscatedProfileId を購入時に埋め込む設計を第一選択にする。
    • Apple は WWDC の Implement proactive in-app purchase restore でも、UUID ベースの識別子運用を案内している。
  • 状態管理
    • サブスク状態は ACTIVE だけでなく、PENDINGIN_GRACE_PERIODON_HOLDPAUSEDEXPIREDREVOKED を明示的に扱う必要がある。
    • TRIALING を使う場合は、Apple / Google の生の状態 enum そのものではなく、offer や取引情報から導出した内部正規化状態として定義する。
    • 特に Google は About subscriptionsIntegrate the Google Play Billing Library into your appPENDING 中の扱いや acknowledgement の要件を説明している。
  • DB 設計
    • SubscriptionEntitlement は分離し、さらに BillingTransaction / SubscriptionEvent 相当の履歴テーブルを持たなければならない(MUST)。
    • PostgreSQL では Unique Indexes の仕様上、複合 unique 制約上の NULL は既定で等価扱いされないため、nullable 列を含む unique 設計には注意が必要。
  • 価格変更
    • 価格変更は「予定された価格条件の変化」と「実際に新価格で更新された事実」を分けて扱う。通知受信やコンソール設定だけで Entitlement を変更しない。
    • Apple は価格維持、しきい値超過時の同意、PRICE_INCREASE 通知、priceIncreaseStatus を考慮する。Google は legacy cohort の移行、opt-in / opt-out、priceChangeDetails を考慮する。

本ドキュメントの表記規約

本ドキュメントでは、RFC 2119 に準拠した以下の表現で要求レベルを示します。

  • MUST(しなければならない): 必須要件。省略すると課金事故、データ不整合、ストアポリシー違反など重大な問題を引き起こす。
  • SHOULD(すべきである): 強い推奨。特段の理由がない限り従うべきだが、正当な根拠があれば逸脱を許容する。
  • MAY(してもよい): 任意。プロダクト方針や運用規模に応じて判断する。

設計原則

1. ストアを真実の源泉とし、自社 DB は投影とみなす

Apple も Google も、バックエンドがストア状態を検証し、それを自社 DB へ反映する設計に寄せています。Google は Google Play’s billing systemSubscription lifecycle で、バックエンドによる購入状態管理を前提とした説明をしています。Apple は Validating receipts with the App Store で、署名済みトランザクション情報や App Store Server API を利用することを案内しています。

したがって、DB に保存する Subscription は「独立した真実」ではなく、最新のストア状態を反映した投影として扱わなければなりません(MUST)。通知欠落、一時障害、再送、順序逆転に備え、通知駆動 + 定期リコンシリエーションを前提とすべきです(SHOULD)。

2. 決済状態と利用可否は分離する

Google は Integrate the Google Play Billing Library into your appAbout subscriptions で、grace period、account hold、paused など、決済状態と利用可否が一致しない局面を持っています。したがって、決済契約を表す Subscription と、機能利用可否を表す Entitlement は別モデルに分離しなければなりません(MUST)。

3. 履歴を持つ

返金、取消、チャージバック、プラン変更、価格変更、保留、猶予、再購読、監査対応を考えると、最新スナップショットだけでは不足します。Apple は App Store Server API を通じて署名済み transaction 情報を扱い、Google は RTDN と Developer API を組み合わせるため、正規化された取引履歴 / イベント履歴を持たなければなりません(MUST)。

4. 通知駆動を主処理、バッチ / ジョブを補正系として設計する

通常の状態反映は、Apple のサーバ通知と Google の RTDN を起点に行わなければなりません(MUST)。一方で、通知欠落、順序逆転、一時障害、acknowledgement 漏れ、運用時の設定ミスは避けられません。したがって、通常経路は通知駆動、バッチ / ジョブは補正・監査・復旧のための安全装置として設計します。

5. 商品、アカウント、決済経路の境界を明示する

実運用では、単なる購入検証よりも、どの商品を売っているか、誰の購入か、複数の課金経路がどう共存するかで事故が起きやすくなります。Plan、ストア商品、BillingAccountSubscriptionEntitlement の境界を曖昧にせず、商品カタログ運用、アカウント例外、将来の Web 決済追加まで見据えたモデルにしておくべきです(SHOULD)。


課金ドメインの最小概念セット

Plan

自社の販売プランです。例として PRO_MONTHLY のような内部コードを持ちます。Apple の productId、Google の productId + basePlanId + offerId へのマッピングは別テーブルで管理します。Google の定期購入は About subscriptions のとおり base planoffer を持つため、単なる productId だけでは足りません。

Subscription

ストア上の契約状態です。最低でも以下を表現できる必要があります。

  • PENDING
  • ACTIVE
  • TRIALING
  • IN_GRACE_PERIOD
  • ON_HOLD
  • PAUSED
  • CANCELED
  • EXPIRED
  • REVOKED

Google は Subscription lifecycleAbout subscriptions で、これらに相当する状態遷移を示しています。

なお、TRIALING は Google の subscriptionState や Apple の通知タイプにそのまま存在する標準状態名ではありません。導入する場合は、intro offer / free trial / transaction 情報から導出した内部状態として扱うのが適切です。

Entitlement

現在ユーザーが利用できる機能の可否です。SubscriptionIN_GRACE_PERIOD のときに Entitlement を継続するか、即時縮退するかはプロダクト方針次第です。

BillingTransaction

課金イベントの正規化履歴です。Apple では signed transaction、Google では purchase token と lineItems.expiryTime などを基に一意キーを作り、最新状態とは別に履歴を保持します。

InboundWebhookEvent

ストアから受け取った通知の生イベントです。Apple の notificationUUID、Google の messageId に加え、Google では purchaseToken + notificationType + eventTimeMillis のような業務キーも補助的に保持すると堅牢です。RTDN の形式は Real-time developer notifications reference guide を参照してください。


推奨アーキテクチャ

Apple

Apple は Validating receipts with the App StoreverifyReceipt で、verifyReceipt が deprecated であることを明記しています。現在は、App Store Server API署名済み Transaction / App TransactionApp Store Server Notifications V2 を使う構成に寄せるべきです。

Apple 側は次の構成を推奨します。

  1. App Store Server Notifications V2 を受信する
  2. signedPayload を検証してデコードする
  3. notificationUUID で冪等化する
  4. 必要に応じて App Store Server API で追加取得する
  5. 通知欠落や配送失敗の回復経路として Get Notification History を利用できるようにする
  6. 正規化した Subscription / Entitlement / BillingTransaction を更新する

Apple の公式 Node ライブラリは apple/app-store-server-library-node です。

Google

Google は Subscription lifecycle で、RTDN を受け取ったらその purchase token を用いて purchases.subscriptionsv2.get を呼び、最新状態を取得するよう説明しています。acknowledgement については、自動更新サブスクの初回購入は 3 日以内に実施が必要で、更新分は不要です。さらに About subscriptions にあるとおり、prepaid plan は初回購入だけでなく top-up も acknowledgement 対象で、短期プランでは 3 日より短い期限になる場合があります。未処理だと返金や権利剥奪が起こり得ます。

Google 側は次の構成を推奨します。

  1. Pub/Sub push を受信する
  2. Authorization ヘッダの JWT を検証する
  3. audience だけでなく、emailemail_verified、必要に応じて iss も確認する
  4. messageId で一次冪等化し、生イベントを保存する
  5. purchases.subscriptionsv2.get を呼ぶ
  6. subscriptionStateacknowledgementStatelineItemslinkedPurchaseTokenexternalAccountIdentifiers を見て状態を投影する
  7. 必要なら acknowledgement を行う

Pub/Sub push 認証は Authenticate push subscriptions を参照してください。公式サンプルでも、署名検証と audience 検証に加えて、emailemail_verified の確認が必要と案内されています。


Apple と Google の実装差分

Apple 側の要点

Google 側の要点


通知 → 内部状態マッピング

実装者がストア通知を受信した際に、どの内部状態遷移と BillingEventType を適用すべきかを一意に判断できるよう、以下のマッピングテーブルを定義します。いずれの場合も、通知受信後にストア API で最新状態を再照会し、この表の期待値と一致することを確認しなければなりません(MUST)。

Apple App Store Server Notifications V2 マッピング

notificationTypesubtype内部 SubscriptionStatusBillingEventTypeEntitlement備考
SUBSCRIBEDINITIAL_BUYACTIVE or TRIALING*PURCHASED有効化*offer 情報から TRIALING を導出(後述)
SUBSCRIBEDRESUBSCRIBEACTIVERESTARTED有効化再購読
DID_RENEW(なし)ACTIVERENEWED維持通常の自動更新成功
DID_RENEWBILLING_RECOVERYACTIVERECOVERED有効化決済失敗からの回復(支払い情報更新を含む)
DID_FAIL_TO_RENEWGRACE_PERIODIN_GRACE_PERIODENTERED_GRACE_PERIOD維持(方針次第)決済失敗 → 猶予期間に入った
DID_FAIL_TO_RENEW(なし)ACTIVE(billing retry 中)(イベント記録のみ)維持猶予期間未有効、または猶予期間なしで billing retry 開始
GRACE_PERIOD_EXPIRED(なし)ON_HOLD(内部正規化)ENTERED_ON_HOLD停止Apple では billing grace period 終了後も billing retry が継続する。本ドキュメントでは post-grace billing retry を内部状態 ON_HOLD に正規化する
EXPIREDVOLUNTARYEXPIREDEXPIRED停止ユーザーが解約し期間満了
EXPIREDBILLING_RETRYEXPIREDEXPIRED停止billing retry 期間(最大 60 日)終了後の失効
EXPIREDPRICE_INCREASEEXPIREDEXPIRED停止価格上昇の同意未完了による失効
EXPIREDPRODUCT_NOT_FOR_SALEEXPIREDEXPIRED停止商品が販売停止
DID_CHANGE_RENEWAL_STATUSAUTO_RENEW_DISABLED(status 不変)CANCELED維持(期間満了まで)ユーザーが自動更新を無効化
DID_CHANGE_RENEWAL_STATUSAUTO_RENEW_ENABLED(status 不変)RESTARTED維持ユーザーが自動更新を再有効化
DID_CHANGE_RENEWAL_PREFUPGRADE / DOWNGRADE(再照会して判定)PRODUCT_CHANGED再照会で判定プラン変更
PRICE_INCREASEPENDING(status 不変)PRICE_CHANGE_PENDING変更しない価格上昇の同意待ち
PRICE_INCREASEACCEPTED(status 不変)PRICE_CHANGE_ACCEPTED変更しない同意済み
REFUND(なし)REVOKEDREFUNDED即時停止返金成立
REVOKE(なし)REVOKEDREVOKED即時停止Family Sharing 解除など

注意:

  • DID_FAIL_TO_RENEW の subtype が空の場合は、猶予期間が無効化されている、または猶予期間外の billing retry を意味します。gracePeriodExpiresDateisInBillingRetry を renewal 情報から確認し、猶予期間中か post-grace billing retry かを判定すべきです(SHOULD)。
  • GRACE_PERIOD_EXPIRED は Apple 上の名前付き状態 ON_HOLD を意味するものではありません。一次情報上は「billing grace period を抜け、billing retry が継続している」状態です。本ドキュメントでは Google の account hold と揃えて扱いやすくするため、内部状態 ON_HOLD に正規化しています。

Google Play RTDN マッピング

Google の RTDN は notificationType が整数値で届きます。以下のマッピングに従い、受信後に purchases.subscriptionsv2.getsubscriptionState を再照会しなければなりません(MUST)。

notificationType名称内部 SubscriptionStatusBillingEventTypeEntitlement備考
1SUBSCRIPTION_RECOVEREDACTIVERECOVERED有効化猶予期間 / account hold からの回復(支払い情報更新を含む)
2SUBSCRIPTION_RENEWEDACTIVERENEWED維持自動更新成功
3SUBSCRIPTION_CANCELEDCANCELEDCANCELED維持(期間満了まで)ユーザー解約、または同意未完了の opt-in 値上げなど
4SUBSCRIPTION_PURCHASEDACTIVE or PENDINGPURCHASED再照会で判定新規購入(pending transaction の場合あり)
5SUBSCRIPTION_ON_HOLDON_HOLDENTERED_ON_HOLD停止account hold に入った
6SUBSCRIPTION_IN_GRACE_PERIODIN_GRACE_PERIODENTERED_GRACE_PERIOD維持(方針次第)決済失敗 → 猶予期間に入った
7SUBSCRIPTION_RESTARTEDACTIVERESTARTED有効化Play Store から再有効化
8SUBSCRIPTION_PRICE_CHANGE_CONFIRMED(status 不変)PRICE_CHANGE_ACCEPTED変更しないdeprecated。新規実装では type 19 を主に参照する
9SUBSCRIPTION_DEFERRED(再照会)(イベント記録のみ)維持更新日延長
10SUBSCRIPTION_PAUSEDPAUSEDPAUSED停止一時停止が発効
11SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED(status 不変)(イベント記録のみ)維持一時停止の予約 / 取消
12SUBSCRIPTION_REVOKEDREVOKEDREVOKED即時停止返金 / チャージバック
13SUBSCRIPTION_EXPIREDEXPIREDEXPIRED停止購読失効
19SUBSCRIPTION_PRICE_CHANGE_UPDATED(status 不変)PRICE_CHANGE_PENDING or PRICE_CHANGE_ACCEPTED or PRICE_CHANGE_CANCELED変更しないlineItems[].autoRenewingPlan.priceChangeDetails を再照会して判定
20SUBSCRIPTION_PENDING_PURCHASE_CANCELED(再照会して判定)(イベント記録のみ)再照会で判定pending 購入がキャンセルされた。既存契約の変更途中であれば linkedPurchaseToken 側の現行契約を確認する
22SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED(status 不変)PRICE_CHANGE_PENDING or PRICE_CHANGE_ACCEPTED変更しないKR の price step-up consent。priceStepUpConsentDetails を再照会して判定

スコープ注記: 本ドキュメントでは、bundle 変更の type 17 (SUBSCRIPTION_ITEMS_CHANGED) と installment 向けの type 18 (SUBSCRIPTION_CANCELLATION_SCHEDULED) は対象外とします。該当商品を扱う場合は別途マッピングを追加してください。

決済失敗 → 猶予期間 → 猶予終了 → account hold / billing retry の検知フロー: Google では type 6 で猶予期間開始を検知し、type 5 で account hold(猶予期間終了)を検知し、type 1 で回復を検知します。Apple では DID_FAIL_TO_RENEW / GRACE_PERIOD で猶予期間開始、GRACE_PERIOD_EXPIRED で猶予期間終了後の billing retry 継続を検知し、DID_RENEW / BILLING_RECOVERY で回復を検知します。


価格変更対応

基本原則

価格変更は、価格条件の変更通知実際に新価格で課金が行われた更新を分離して扱わなければなりません(MUST)。App Store Connect や Play Console で価格変更を設定した時点、あるいは価格変更通知を受け取った時点では、まだユーザーの課金が新価格へ切り替わっていない場合があります。したがって、価格変更の予定や同意待ちを検知しただけで Entitlement を変更してはなりませんMUST NOT)。

バックエンドでは、次の 2 層に分けて保持すべきです(SHOULD)。

  • 現在有効な価格スナップショット: 現在の契約がどの通貨・金額で更新されているか
  • 将来適用予定の価格変更: 次回以降に適用予定の価格、同意要否、同意状態、適用予定日時

この分離により、価格変更の同意待ち、適用済み、取り消し、価格維持、失効を監査可能にできます。

Apple の価格変更

Apple では、Manage pricing for auto-renewable subscriptions にあるとおり、国・地域ごとに将来の価格変更を 1 件ずつ予約でき、既存加入者の価格を維持することもできます。価格上昇が Auto-renewable subscription price increase thresholds のしきい値を超える場合は、既存加入者の同意が必要になります。

サーバ実装では、価格変更を次のように扱うのが適切です。

  • Managing Price Increases for Auto-Renewable Subscriptions に沿って、価格上昇時は App Store Server Notifications V2App Store Server API を併用する
  • 価格上昇で同意が必要な場合、通知タイプ PRICE_INCREASE のサブタイプ PENDING / ACCEPTED を受け取り得る
  • 現在の同意状態は Get All Subscription Statuses で取得できる priceIncreaseStatus や、renewal 情報から確認する
  • ユーザーが同意せず期限を迎えた場合、購読は価格上昇起因で失効し得るため、通常の voluntary cancel とは別に追跡する

実装上は、Apple の価格変更通知を受けたら、originalTransactionId をキーに現在の購読を特定し、pending の価格変更レコードを作成または更新します。新価格が実際に適用されたことは、次回更新時の transaction / renewal 情報を見て確定し、その時点で Subscription の現行価格スナップショットを更新します。

Google の価格変更

Google Play では、Change subscription prices にあるとおり、新規購入者向け価格既存加入者の legacy cohort を分けて扱います。既存加入者への価格変更は、price decreaseopt-in price increaseopt-out price increase の 3 系統で考えると整理しやすいです。

  • opt-in price increase: ユーザーの明示同意が必要
  • opt-out price increase: 対象地域・条件を満たす場合に利用可能。ユーザーが解約やプラン変更をしない限り新価格へ移行
  • price decrease: 値下げ。価格変更は次回更新以降に反映される

Google は opt-in price increase について、開始後 7 日間は Play から既存加入者へ通知されず、その間に自社アプリから先行案内できると案内しています。opt-out price increase は、国によって 30 日または 60 日の事前通知期間で運用されます。

バックエンドでは、価格変更系 RTDN を受けたあとも、通常どおり purchases.subscriptionsv2.get を真実の源泉として再照会します。SubscriptionPurchaseV2 では、lineItems[].autoRenewingPlan 配下に次の情報があります。

  • recurringPrice: 現在の継続価格
  • priceChangeDetails.newPrice: 新しい継続価格
  • priceChangeDetails.priceChangeMode: 値下げ / opt-in 値上げ / opt-out 値上げ
  • priceChangeDetails.priceChangeState: OUTSTANDING / CONFIRMED / APPLIED / CANCELED
  • priceChangeDetails.expectedNewPriceChargeTime: 新価格が適用される予定時刻
  • priceStepUpConsentDetails: 同意が必要な step-up の詳細

イベント処理としては次の整理が適切です。

  • ユーザーが opt-in の値上げを承認したら、SUBSCRIPTION_PRICE_CHANGE_UPDATED(type 19)を受け取り、再照会後に pending price change を ACCEPTED 相当へ更新する
  • 値下げ、または値上げ適用後の更新は SUBSCRIPTION_RENEWED として扱われるため、実際に新価格で更新された更新イベントをもって現行価格スナップショットを更新する
  • opt-in 値上げが承認されないまま更新日時を迎えた場合、SUBSCRIPTION_CANCELED として扱われるため、同意未完了起因の churn として分類する。KR の offer phase 由来の step-up については SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED(type 22)と priceStepUpConsentDetails を別系統として扱う

データモデルへの反映

価格変更を扱う場合、BillingTransaction に単一の PRICE_CHANGED を持つだけでは不足します。最低でも、pending / accepted / applied / canceled を区別できる必要があります。あわせて、購読ごとに 現在有効な継続価格 を保持しておくと、課金条件の比較や監査が容易になります。

そのため、本ドキュメントのスキーマ例では次を追加します。

  • Subscription.currentCurrencyCode
  • Subscription.currentRecurringPriceMicros
  • SubscriptionPriceChange テーブル
  • BillingEventTypePRICE_CHANGE_PENDING / PRICE_CHANGE_ACCEPTED / PRICE_CHANGE_CANCELED を追加

推奨フロー

  1. App Store Connect / Play Console で価格変更を設定する
  2. Apple 通知または Google RTDN を受ける
  3. ストア API で現在の価格変更状態を再取得する
  4. SubscriptionPriceChange を upsert し、pending / accepted / canceled を更新する
  5. Entitlement は変更しない
  6. 次回更新で新価格の適用を確認したら Subscription.currentRecurringPriceMicros を更新する
  7. 同意未完了による失効やキャンセルは、通常の churn と区別してイベント化する

商品カタログ・オファー・プラン変更設計

商品カタログ運用の基本方針

商品カタログは、課金機能の実装詳細ではなく運用対象そのものとして扱わなければなりません(MUST)。Apple では Overview for configuring In-App PurchasesIn-App Purchase information のとおり、商品定義や表示情報にレビューと審査状態が絡みます。Google でも About subscriptionsManage your product catalog にあるように、productId に加えて basePlanId / offerId を前提に商品群を管理します。

バックエンドでは、少なくとも次を明文化しなければなりません(MUST)。

  • 自社 Plan と Apple / Google の商品定義の対応表
  • 商品の新設、廃止、非公開化、差し替えの手順
  • 既存商品に意味変更を入れず、意味が変わる場合は新商品として追加する方針
  • 価格、表示名、提供機能が変わっても、過去トランザクションの意味を後から書き換えない方針
  • 商品設定変更時に、アプリ、バックエンド、CS、分析、会計の各系へどの影響が出るか

カタログ価格の取得設計

未契約ユーザーに月額を表示するためのカタログ価格と、契約中ユーザーの Subscription.currentRecurringPriceMicros(現在の継続価格)は別概念として扱わなければなりません(MUST)。

一次情報源(クライアント側):

  • Apple: StoreKit 2 の Product API で、ユーザーの地域に応じたローカライズ済み価格を取得する
  • Google: BillingClient.queryProductDetailsAsync で、base plan / offer ごとの価格情報を取得する
  • クライアント側で取得した価格がユーザーに表示される正式な価格であり、通貨・地域・税込み表示はストアが管理する

バックエンドキャッシュ(補助用途):

  • StoreProductMapping.displayCurrencyCode / displayPriceMicros にカタログ価格を保持する
  • 用途: 管理画面での価格表示、CS 対応、分析、プッシュ通知での価格言及
  • 同期方法: クライアントが購入証跡を送信する際にストア API レスポンスに含まれる価格情報を添付し、バックエンドが StoreProductMapping を更新する。あるいはストアコンソールの価格変更時に手動更新する
  • カタログ価格は表示補助であり、課金判定のロジックに使用してはなりません(MUST NOT

オファー設計

トライアルや値引きは Subscription.status だけでは表現しきれません。Apple の introductory offer、promotional offer、offer code、Google の base plan / offer のように、価格条件と資格条件は商品本体とは別レイヤーで扱うべきです(SHOULD)。

実装ドキュメントには次を含めなければなりません(MUST)。

  • どの offer をどの Plan に対して使うか
  • 初回限定、再加入向け、キャンペーン向けなどの利用条件
  • offer 適用可否をストアに委ねる部分と、自社バックエンドやアプリで制御する部分
  • TRIALING を内部導出状態として使う場合の判定条件
  • 価格表示、分析、CS 画面で 通常価格と offer 適用価格を区別する方針

プラン変更マトリクス

upgrade / downgrade / crossgrade / resubscribe は、単に「プラン変更」でまとめず、切替タイミングと権利切替タイミングを表として定義しておかなければなりません(MUST)。Google は linkedPurchaseToken が重要であり、Apple では originalTransactionId を軸に subscription group 内の契約変化を追います。

最低限、次の観点を定義すべきです(SHOULD)。

  • 変更前プランと変更後プラン
  • 変更要求時点で何を UI に表示するか
  • 旧契約の Entitlement をいつ止めるか
  • 新契約の Entitlement をいつ有効にするか
  • 履歴テーブルへどのイベントを記録するか
  • 価格差調整や日割りはストア結果をどう投影するか

プラン変更は価格変更と同様に、変更意図実際に更新が成立した事実を分離して記録すべきです(SHOULD)。


トライアル(無料期間)設計

TRIALING 状態の導出ロジック

TRIALING は Apple / Google のストア API が直接返す標準状態名ではないため、以下のフィールドから内部導出しなければなりません(MUST)。

Apple(JWSTransactionDecodedPayload):

  • offerType1(introductory offer)であること
  • offerDiscountTypeFREE_TRIAL であること
  • 上記 2 条件を満たす場合、Subscription.statusTRIALING に設定し、trialEndsAtexpiresDate に設定する

Google(purchases.subscriptionsv2.get レスポンス):

  • lineItems[].offerPhasefreeTrial が含まれている line item を確認する
  • freeTrial を含む line item が存在する場合、Subscription.statusTRIALING に設定し、trialEndsAt を当該 lineItem.expiryTime に設定する
  • lineItems[].offerDetails.basePlanId / offerId は、どの base plan / offer で契約したかの識別には使えるが、現在 free trial phase にいるかの判定は offerPhase を優先する

トライアル利用歴の判定

ユーザーがトライアルを消費済みかどうかの判定は、以下の方法を組み合わせるべきです(SHOULD)。

クライアント側(事前判定):

  • Apple(StoreKit 2): Product.SubscriptionInfo.isEligibleForIntroOffer で、ユーザーが introductory offer の適格者かどうかを判定できる。ストアが Apple ID 単位で管理するため、自社バックエンドへの問い合わせなしに判定可能
  • Google: ProductDetails.SubscriptionOfferDetailsofferId に紐づくフリートライアルが、ユーザーにとって利用可能かどうかはストアが subscription group 単位で制御する。BillingClient.queryProductDetailsAsync で返される offer の一覧にフリートライアル offer が含まれていなければ、利用済みと推定できる

バックエンド側(補助判定):

  • BillingTransactioneventType = PURCHASED かつトライアル開始を示す記録が存在するかを照会する
  • Subscription テーブルに過去 status = TRIALING のレコードがあったかを確認する
  • クライアント側判定を信頼の第一ソースとしつつ、バックエンド側はクロスプラットフォーム(Apple ⇔ Google 間)でのトライアル重複利用を防ぐための補助チェックとして使用する

注意: Apple と Google はそれぞれ独立にトライアル適格性を管理しているため、Apple でトライアルを使ったユーザーが Google でも使えてしまう問題があります。これを防ぎたい場合は、バックエンド側で BillingAccount に対して provider 横断でトライアル消費フラグを管理しなければなりません(MUST、クロスプラットフォーム制御が要件の場合)。


ユーザーと課金情報の紐付け

推奨方針

バックエンドで BillingAccount.accountToken のような UUID を発行し、これを購入時にストアへ埋め込みます。

  • Apple: appAccountToken
  • Apple 互換入力: applicationUsername に UUID
  • Google: setObfuscatedAccountId
  • 必要なら setObfuscatedProfileId

Apple は WWDC セッション Implement proactive in-app purchase restore でこの方向性を説明しており、Google は Integrate the Google Play Billing Library into your appobfuscatedAccountId を使った紐付けを案内しています。

復元時の扱い

復元は「クライアントが買ったと言ってきた情報」を信用する処理ではなく、ストア情報で再確認して結び直す処理です。

  • Apple
    復元時は、可能であれば App Store Server API と signed transaction を優先します。receipt は互換用途に限定します。
  • Google
    購入復元や再購読時も、purchase token を起点に purchases.subscriptionsv2.get を呼んで状態確定します。linkedPurchaseToken の有無や新旧 token の関係も考慮します。

アカウント例外系の扱い

通常系よりも事故になりやすいのは、未ログイン購入、別アカウントへの誤復元、アカウント統合、退会後の再ログインです。実装ドキュメントでは、次のポリシーを明示しなければなりません(MUST)。

  • 未ログイン状態で発生した購入は、一時的に「未請求購入」として保持し、認証後にのみ BillingAccount へ確定紐付けする
  • すでに別の BillingAccount に結び付いた Apple originalTransactionId や Google purchaseToken は、自動で他アカウントへ移し替えない
  • アカウント統合時は、Subscription の所有者変更と Entitlement の有効化を同時に行わず、移管手順と監査ログを分ける
  • 退会時も、法務 / 会計 / 不正対策の観点から必要な課金履歴は保持し、アプリ上の個人識別子だけを切り離せるようにする
  • CS による手動介入は、「直接ステータスを書き換える」のではなく、再照合・再投影・移管申請といった限定操作に絞る

このポリシーが曖昧だと、同じストア購入が複数の自社アカウントにぶら下がる事故が起きやすくなります。


クライアントとバックエンドの責務分担

クライアントの責務

クライアントは、購入開始に必要なストア固有情報を正しく渡し、購入結果を早くサーバへ通知する責務を持ちます。具体的には次の役割です。

  • Apple の appAccountToken、Google の setObfuscatedAccountId / setObfuscatedProfileId を購入開始前に設定する
  • 購入完了、復元、エラー、キャンセル、保留を UI に反映する
  • 購入直後に receipt / transaction / purchase token などの証跡をサーバへ送る
  • アプリ再起動やクラッシュ後に、未送信の購入証跡を再送する
  • 「購入済みだがまだ反映中」の状態を UI と文言で許容する

バックエンドの責務

バックエンドは、ストア検証・冪等化・履歴記録・権利投影の最終責任を持ちます。

  • ストア API または署名済み情報で購入を検証する
  • 通知受信、クライアント申告、定期ジョブのいずれから来ても同じ投影ロジックへ流す
  • Google の acknowledgement を期限内に完了させる
  • Subscription / Entitlement / BillingTransaction を一貫して更新する
  • 通知順序逆転、再送、部分失敗、タイムアウトを吸収する

境界設計上の注意

実装ドキュメントには、次の失敗時シナリオも含めなければなりません(MUST)。

  • 購入成功後にクライアントからサーバ通知できない
  • サーバ通知前に RTDN / App Store Server Notification が先着する
  • acknowledgement は済んだが権利投影に失敗する
  • 権利投影は済んだがクライアントが結果を受け取れない

この境界が曖昧だと、購入反映遅延、二重付与、未 ack 返金の温床になります。


API 設計

推奨エンドポイント

目的MethodPath認証冪等キー
Apple 通知受信POST/webhooks/apple/notifications署名検証notificationUUID
Google RTDN 受信POST/webhooks/google/rtdnPub/Sub JWT 検証messageId + 業務キー
Apple 購入証跡登録POST/billing/apple/ingestBearerIdempotency-Key
Google 購入証跡登録POST/billing/google/ingestBearerIdempotency-Key
現在の権利取得GET/me/entitlementBearerなし
管理者再照合POST/admin/billing/reconcileAdminIdempotency-Key

購入復元について: 購入復元(restore)は /billing/apple/ingest および /billing/google/ingest と兼用します(MUST)。リクエストボディの action フィールド(例: "purchase" / "restore")で新規購入と復元を区別し、バックエンド側は同一の検証・投影ロジックへ流します。復元時もストア API で再照会し、appAccountToken / obfuscatedAccountId の一致を確認してから BillingAccount への紐付けを行わなければなりません(MUST)。新規購入と復元で投影結果が変わらない(冪等)ことをテストで保証すべきです(SHOULD)。

NestJS の基本的なエンドポイント設計は Controllers を参照してください。

Apple 通知受信例

import { Body, Controller, HttpCode, Post } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
import { SignedDataVerifier } from "@apple/app-store-server-library";

type AppleNotificationDto = {
  signedPayload: string;
};

@Controller()
export class AppleWebhookController {
  constructor(
    private readonly prisma: PrismaClient,
    private readonly verifier: SignedDataVerifier,
  ) {}

  @Post("/webhooks/apple/notifications")
  @HttpCode(200)
  async handle(@Body() body: AppleNotificationDto): Promise<void> {
    // ResponseBodyV2DecodedPayload is the return type from the library.
    const decoded = await this.verifier.verifyAndDecodeNotification(body.signedPayload);

    const notificationUuid = decoded.notificationUUID;
    if (!notificationUuid) {
      throw new Error("Missing notification UUID");
    }

    const environment = String(
      (decoded as { data?: { environment?: string }; environment?: string }).data?.environment ??
        (decoded as { environment?: string }).environment ??
        "PRODUCTION",
    ).toUpperCase();

    await this.prisma.inboundWebhookEvent.upsert({
      where: {
        provider_providerEventId: {
          provider: "APPLE",
          providerEventId: notificationUuid,
        },
      },
      create: {
        provider: "APPLE",
        providerEventId: notificationUuid,
        environment,
        verified: true,
        payload: decoded as unknown as object,
      },
      update: {},
    });

    // Enqueue async projection update here.
  }
}

実装上の注意(署名検証失敗時のレスポンス): Apple は非 2xx レスポンスを受け取るとリトライを行います。署名検証失敗時に 2xx を返すかどうかは運用方針として定義すべきです。無意味な再送を避けるため 2xx で握る設計を採る場合でも、監査ログ、アラート、隔離キュー、再調査手段を必須にしてください(SHOULD)。同じ考慮は Google Pub/Sub push にも適用されます。

利用ライブラリ:

Google RTDN 受信例

import {
  Body,
  CanActivate,
  Controller,
  ExecutionContext,
  HttpCode,
  Injectable,
  Post,
  UnauthorizedException,
  UseGuards,
} from "@nestjs/common";
import { OAuth2Client } from "google-auth-library";
import { PrismaClient } from "@prisma/client";

type PubSubPushBody = {
  message: {
    data: string;
    messageId: string;
  };
  subscription: string;
};

@Injectable()
class PubSubPushAuthGuard implements CanActivate {
  private readonly client = new OAuth2Client();

  constructor(
    private readonly expectedAudience: string,
    private readonly expectedServiceAccountEmail: string,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const req = context.switchToHttp().getRequest<{
      headers: Record<string, string | string[] | undefined>;
    }>();

    const authorization = req.headers.authorization;
    if (!authorization || Array.isArray(authorization) || !authorization.startsWith("Bearer ")) {
      throw new UnauthorizedException("Missing bearer token");
    }

    const token = authorization.slice("Bearer ".length);
    const ticket = await this.client.verifyIdToken({
      idToken: token,
      audience: this.expectedAudience,
    });

    const payload = ticket.getPayload();
    if (!payload) {
      throw new UnauthorizedException("Invalid token");
    }

    if (payload.email !== this.expectedServiceAccountEmail) {
      throw new UnauthorizedException("Unexpected service account email");
    }

    if (payload.email_verified !== true) {
      throw new UnauthorizedException("Email is not verified");
    }

    if (payload.iss !== "https://accounts.google.com" && payload.iss !== "accounts.google.com") {
      throw new UnauthorizedException("Unexpected issuer");
    }

    return true;
  }
}

@Controller()
export class GoogleRtdnController {
  constructor(private readonly prisma: PrismaClient) {}

  @Post("/webhooks/google/rtdn")
  @UseGuards(
    new PubSubPushAuthGuard(
      "https://example.com/webhooks/google/rtdn",
      "pubsub-push@example-project.iam.gserviceaccount.com",
    ),
  )
  @HttpCode(200)
  async handle(@Body() body: PubSubPushBody): Promise<void> {
    const json = Buffer.from(body.message.data, "base64").toString("utf8");
    const notification = JSON.parse(json) as {
      packageName: string;
      eventTimeMillis: string;
      subscriptionNotification?: {
        notificationType: number;
        purchaseToken: string;
      };
    };

    const businessKey = [
      notification.subscriptionNotification?.purchaseToken ?? "",
      notification.subscriptionNotification?.notificationType ?? "",
      notification.eventTimeMillis,
    ].join(":");

    await this.prisma.inboundWebhookEvent.upsert({
      where: {
        provider_providerEventId: {
          provider: "GOOGLE",
          providerEventId: body.message.messageId,
        },
      },
      create: {
        provider: "GOOGLE",
        providerEventId: body.message.messageId,
        providerBusinessKey: businessKey,
        environment: "UNKNOWN",
        verified: true,
        payload: notification as unknown as object,
      },
      update: {},
    });

    // Enqueue async projection update here.
  }
}

実装上の注意(DI と設定管理): 上記の PubSubPushAuthGuard ではコンストラクタ引数で expectedAudienceexpectedServiceAccountEmail を受け取っていますが、本番では NestJS の ConfigService 経由で環境変数から注入すべきです(SHOULD)。new PubSubPushAuthGuard(...) によるインスタンス化はサンプル簡略化のためであり、そのままの利用は避けてください。

参考リンク:


データモデル設計

データモデル設計上の要点

設計上、特に重要な要点は次の 3 点です。

  1. Apple は verifyReceipt 主軸ではなく App Store Server API 主軸で扱う
  2. Google の PENDING と acknowledgement を状態モデルに反映する
  3. PostgreSQL の NULL と複合 unique の相性を考慮する

Prisma スキーマ例

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider     = "postgresql"
  url          = env("DATABASE_URL")
  relationMode = "foreignKeys"
}

enum BillingProvider {
  APPLE
  GOOGLE
  WEB
}

enum SubscriptionStatus {
  PENDING
  ACTIVE
  TRIALING
  IN_GRACE_PERIOD
  ON_HOLD
  PAUSED
  CANCELED
  EXPIRED
  REVOKED
}

enum AcknowledgementStatus {
  NOT_REQUIRED
  PENDING
  ACKNOWLEDGED
}

enum BillingEventType {
  PURCHASED
  RENEWED
  RECOVERED
  CANCELED
  RESTARTED
  PAUSED
  RESUMED
  ENTERED_GRACE_PERIOD
  ENTERED_ON_HOLD
  EXPIRED
  REVOKED
  REFUNDED
  PRICE_CHANGE_PENDING
  PRICE_CHANGE_ACCEPTED
  PRICE_CHANGED
  PRICE_CHANGE_CANCELED
  PRODUCT_CHANGED
}

enum PriceChangeState {
  PENDING
  ACCEPTED
  APPLIED
  CANCELED
}

enum PriceChangeMode {
  PRICE_DECREASE
  PRICE_INCREASE
  OPT_OUT_PRICE_INCREASE
}

enum IntervalUnit {
  MONTHLY
  YEARLY
}

model User {
  id             String          @id @default(uuid())
  email          String?         @unique
  createdAt      DateTime        @default(now())
  updatedAt      DateTime        @updatedAt

  billingAccount BillingAccount?
}

model BillingAccount {
  id            String         @id @default(uuid())
  userId        String         @unique
  accountToken  String         @unique @default(uuid())
  createdAt     DateTime       @default(now())
  updatedAt     DateTime       @updatedAt

  user          User           @relation(fields: [userId], references: [id], onDelete: Cascade)
  subscriptions Subscription[]
  entitlements  Entitlement[]
}

model Plan {
  id              String               @id @default(uuid())
  code            String               @unique
  name            String
  intervalUnit    IntervalUnit
  isActive        Boolean              @default(true)
  createdAt       DateTime             @default(now())
  updatedAt       DateTime             @updatedAt

  storeMappings   StoreProductMapping[]
  subscriptions   Subscription[]
  entitlements    Entitlement[]
}

model StoreProductMapping {
  id            String           @id @default(uuid())
  provider      BillingProvider
  planId        String

  productId     String
  basePlanId    String           @default("")
  offerId       String           @default("")

  // Catalog price cache: synced from store console or client-reported data.
  // These fields serve as a backend cache for display purposes.
  displayCurrencyCode  String?
  displayPriceMicros   BigInt?
  displayPriceUpdatedAt DateTime?

  externalKey   String           @unique
  createdAt     DateTime         @default(now())

  plan          Plan             @relation(fields: [planId], references: [id], onDelete: Cascade)

  @@unique([provider, productId, basePlanId, offerId])
  @@index([provider, planId])
}

model Subscription {
  id                      String                  @id @default(uuid())
  provider                BillingProvider
  billingAccountId        String
  planId                  String

  status                  SubscriptionStatus      @default(PENDING)
  acknowledgementStatus   AcknowledgementStatus   @default(NOT_REQUIRED)
  isTestPurchase          Boolean                 @default(false)

  autoRenew               Boolean                 @default(true)
  currentCurrencyCode     String?
  currentRecurringPriceMicros BigInt?
  currentPeriodStartAt    DateTime?
  currentPeriodEndAt      DateTime?
  trialEndsAt             DateTime?
  cancelAt                DateTime?
  canceledAt              DateTime?
  lastVerifiedAt          DateTime?
  createdAt               DateTime                @default(now())
  updatedAt               DateTime                @updatedAt

  billingAccount          BillingAccount          @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
  plan                    Plan                    @relation(fields: [planId], references: [id], onDelete: Restrict)

  appleIdentity           AppleSubscriptionIdentity?
  googleTokens            GooglePurchaseToken[]
  transactions            BillingTransaction[]
  priceChanges            SubscriptionPriceChange[]
  entitlementLinks        Entitlement[]

  @@index([billingAccountId, provider])
  @@index([status, currentPeriodEndAt])
  @@index([provider, status])
  @@index([provider, acknowledgementStatus])
}

model AppleSubscriptionIdentity {
  id                       String        @id @default(uuid())
  subscriptionId           String        @unique
  originalTransactionId    String        @unique
  latestTransactionId      String?
  appAccountToken          String?
  createdAt                DateTime      @default(now())
  updatedAt                DateTime      @updatedAt

  subscription             Subscription  @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)
}

model GooglePurchaseToken {
  id                       String        @id @default(uuid())
  subscriptionId           String
  purchaseToken            String        @unique
  linkedPurchaseToken      String?
  isCurrent                Boolean       @default(true)
  createdAt                DateTime      @default(now())
  updatedAt                DateTime      @updatedAt

  subscription             Subscription  @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)

  @@index([subscriptionId, isCurrent])
}

model BillingTransaction {
  id                       String           @id @default(uuid())
  subscriptionId           String
  provider                 BillingProvider
  eventType                BillingEventType
  transactionKey           String           @unique
  effectiveAt              DateTime
  expiresAt                DateTime?
  orderId                  String?
  rawPayload               Json
  createdAt                DateTime         @default(now())

  subscription             Subscription     @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)

  @@index([subscriptionId, effectiveAt])
  @@index([provider, eventType, effectiveAt])
}

model SubscriptionPriceChange {
  id                       String            @id @default(uuid())
  subscriptionId           String
  provider                 BillingProvider
  mode                     PriceChangeMode
  state                    PriceChangeState
  currencyCode             String
  oldAmountMicros          BigInt?
  newAmountMicros          BigInt
  expectedChargeAt         DateTime?
  effectiveAt              DateTime?
  sourceEventKey           String?
  createdAt                DateTime          @default(now())
  updatedAt                DateTime          @updatedAt

  subscription             Subscription      @relation(fields: [subscriptionId], references: [id], onDelete: Cascade)

  // Keep lookup efficient by subscription and state.
  // Enforce "at most one pending row per subscription" with a partial unique index
  // in PostgreSQL (or Prisma partial indexes where available for your version).
  @@index([subscriptionId, state])
  @@index([provider, effectiveAt])
}

model Entitlement {
  id                       String           @id @default(uuid())
  billingAccountId         String
  planId                   String
  sourceSubscriptionId     String?

  isActive                 Boolean          @default(false)
  validFrom                DateTime?
  validUntil               DateTime?
  createdAt                DateTime         @default(now())
  updatedAt                DateTime         @updatedAt

  billingAccount           BillingAccount   @relation(fields: [billingAccountId], references: [id], onDelete: Cascade)
  plan                     Plan             @relation(fields: [planId], references: [id], onDelete: Restrict)
  sourceSubscription       Subscription?    @relation(fields: [sourceSubscriptionId], references: [id], onDelete: SetNull)

  // A single user MUST have at most one Entitlement per Plan at any time.
  @@unique([billingAccountId, planId])
  @@index([billingAccountId, isActive])
}

model InboundWebhookEvent {
  id                       String           @id @default(uuid())
  provider                 BillingProvider
  providerEventId          String
  providerBusinessKey      String?
  environment              String           @default("UNKNOWN") // "SANDBOX", "PRODUCTION", or "UNKNOWN"
  verified                 Boolean          @default(false)
  payload                  Json
  receivedAt               DateTime         @default(now())
  processedAt              DateTime?
  processingError          String?

  @@unique([provider, providerEventId])
  @@index([provider, providerBusinessKey])
  @@index([provider, receivedAt])
  @@index([environment])
}

model IdempotencyKey {
  id                       String           @id @default(uuid())
  key                      String           @unique
  scope                    String
  createdAt                DateTime         @default(now())
  expiresAt                DateTime

  @@index([scope, expiresAt])
}

このスキーマの意図

  • StoreProductMappingdisplayCurrencyCode / displayPriceMicros / displayPriceUpdatedAt を追加し、カタログ価格のバックエンドキャッシュとして使用する。一次情報源はクライアント側のストア API(Apple: Product API、Google: BillingClient.queryProductDetailsAsync)であり、バックエンドはキャッシュおよびフォールバック用途(SHOULD)。
  • Plan.intervalUnitIntervalUnit enum(MONTHLY / YEARLY)として定義している。将来的に課金周期を追加する場合は enum を拡張する。
  • StoreProductMapping では basePlanId / offerId を nullable にせず "" をデフォルトにしている。
    • PostgreSQL では Unique Indexes のとおり、既定では NULL は等価扱いされない。
    • 必要に応じて NULLS NOT DISTINCT や部分インデックスも検討できる。
  • BillingTransaction.transactionKey の生成ルールは provider ごとに次のとおり定める(MUST)。
    • Apple: APPLE:{originalTransactionId}:{transactionId}
    • Google: RTDN 受信時の messageId / eventTimeMillis ではなく、purchases.subscriptionsv2.get で再取得した購読リソースから生成する。第一候補は GOOGLE:{purchaseToken}:{lineItem.latestSuccessfulOrderId} とし、latestSuccessfulOrderId を取得できない場合は GOOGLE:{purchaseToken}:{lineItem.productId}:{lineItem.expiryTime} のように line item 単位で作る。latestOrderId は deprecated であり、成功・pending・declined を含む最後の order を指し得るため、成功取引の主キーには使わない。
    • 他の provider を追加する場合は同様に {PROVIDER}:{一意識別子の組み合わせ} とする。
  • Apple の外部識別子は AppleSubscriptionIdentity に切り出し、Google の purchase token は GooglePurchaseToken で履歴管理する。
  • Subscription.status には PENDING を含める。
  • acknowledgementStatus を保持し、Google の新規 purchase acknowledgement を明示管理する。
  • Subscription には @@index([provider, status])@@index([provider, acknowledgementStatus]) を追加し、provider 別のステータスフィルタリング(PENDING 一覧取得、acknowledgement 救済ジョブなど)を効率化している。
  • InboundWebhookEventenvironment フィールドを追加し、Apple では SANDBOX / PRODUCTION を、Google では RTDN 受信時点では UNKNOWN を保持できるようにする。Google のテスト購入判定は purchases.subscriptionsv2.gettestPurchase を使う。
  • Entitlement には @@unique([billingAccountId, planId]) を設け、同一ユーザー・同一プランに同時に 1 件のみ存在することを保証する(MUST)。
  • SubscriptionPriceChange では、履歴保持のため APPLIED / CANCELED を複数件残せるようにしつつ、PENDING の同時多重だけを禁止する必要がある(MUST)。PostgreSQL の partial unique index、または同等の DB 制約で state = 'PENDING' のみ一意制約をかける。
  • Subscription には現在有効な継続価格を保持し、将来の価格変更は SubscriptionPriceChange へ分離する。これにより、同意待ちと適用済みを混同せずに監査できる。Google のライセンステスター購入は isTestPurchase などの専用フラグで明示区別する。

Prisma の関連ドキュメント:


バックエンド処理フロー

新規購入

  1. クライアントが購入を開始する
  2. Apple は appAccountToken、Google は setObfuscatedAccountId を付与する
  3. ストア側の購入完了後、クライアントから証跡を送るか、サーバ通知を待つ
  4. バックエンドはストア API / 署名済み情報で検証する
  5. Subscription を更新する
  6. Entitlement を有効化する
  7. BillingTransaction を記録する

自動更新

  • Apple は Notifications V2 と App Store Server API を使う
  • Google は RTDN と purchases.subscriptionsv2.get を使う
  • いずれも通知だけでなく、必要に応じてストア情報で最終確認する

価格変更

  1. ストア側で価格変更が計画される
  2. Apple は価格変更通知、Google は RTDN と Developer API で pending 状態を把握する
  3. SubscriptionPriceChange を更新する
  4. 同意待ちや予定登録の段階では Entitlement を変更しない
  5. 実際の更新で新価格が適用されたことを確認したら Subscription.currentRecurringPriceMicros を更新する
  6. 同意未完了で失効した場合は churn 理由を価格変更起因として記録する

決済失敗

Google の状態を基準にすると、Entitlement 更新ルールは以下のように整理できます。

  • IN_GRACE_PERIOD: 維持または縮退
  • ON_HOLD: 停止
  • EXPIRED: 停止
  • REVOKED: 即時停止

支払い情報の更新検知

ユーザーがクレジットカード等の支払い方法を更新し、billing retry が成功した場合の検知方法は以下のとおりです。

Apple:

  • DID_RENEW 通知の subtype BILLING_RECOVERY を受信する → 決済失敗からの回復を示す
  • DID_CHANGE_RENEWAL_STATUS / DID_CHANGE_RENEWAL_PREF は、ユーザーの renewal 設定変更(自動更新の有効 / 無効、プラン変更)を通知するが、支払い方法変更そのものの通知ではない点に注意
  • 回復を検知したら、Subscription.statusACTIVE に戻し、Entitlement を有効化し、BillingEventType.RECOVERED を記録する

Google:

  • RTDN type 1 SUBSCRIPTION_RECOVERED を受信する → 猶予期間または account hold からの回復を示す
  • 回復を検知したら、purchases.subscriptionsv2.getsubscriptionStateSUBSCRIPTION_STATE_ACTIVE であることを確認し、Subscription.statusACTIVE に戻す
  • Entitlement を有効化し、BillingEventType.RECOVERED を記録する

投影ロジックの統一: 支払い情報更新による回復も、通常の billing retry 成功による回復も、バックエンド側では同一の RECOVERED イベントとして扱い、同じ投影ロジックで処理しなければなりません(MUST)。

返金・取消・チャージバック

Google は Fight fraud and abuse で voided purchases を扱っています。Apple / Google いずれも、返金系イベントを受けた場合は BillingTransaction を追加し、SubscriptionEntitlement を再計算する構成が適切です。


猶予期間(Billing Grace Period)設定

Apple の猶予期間

Enable billing grace period for auto-renewable subscriptions に従い、App Store Connect で設定します。

  • 選択可能な期間: 3 日、16 日、28 日
  • 設定はアプリレベルで適用され、アプリ内のすべての auto-renewable subscription に共通して適用される
  • weekly subscription は、16 日 / 28 日を選択しても実際の猶予期間は最大 6 日に制限される
  • 対象更新タイプとして「All Renewals」(無料 offer からの移行を含む)または「Only Paid to Paid Renewals」を選択できる
  • Production 環境と Sandbox 環境を個別に有効化できる
  • 変更が反映されるまで最大 24 時間かかる場合がある

Google の猶予期間

Google Play Console の base plan 設定で、subscription ごとに猶予期間を設定します。

  • 選択可能な期間: 3 日、7 日、14 日、30 日About subscriptions 参照)
  • base plan 単位で設定するため、同一アプリ内でも subscription ごとに異なる期間を設定可能

要件「3 日」の実現

Apple、Google ともに 3 日間の猶予期間を設定可能です。ただし以下に注意しなければなりません(MUST)。

  • Apple はアプリ全体で 1 つの猶予期間設定であるため、将来 weekly subscription を追加した場合も同じ 3 日が適用されることを確認する
  • Google は base plan 単位であるため、すべての base plan に対して個別に 3 日を設定する
  • ストアコンソールでの設定だけでなく、バックエンドが IN_GRACE_PERIOD 状態を正しく投影し、Entitlement を維持するロジックの実装も必須である

バックエンド側の猶予期間対応

猶予期間中のユーザーに対して Entitlement を維持するか縮退させるかはプロダクト方針次第ですが、以下は実装しなければなりません(MUST)。

  • Subscription.status = IN_GRACE_PERIOD を設定する
  • Subscription.currentPeriodEndAt を猶予期間終了日時に更新する(Apple: gracePeriodExpiresDate、Google: 再照会結果の expiryTime
  • BillingTransactionENTERED_GRACE_PERIOD イベントを記録する
  • 猶予期間終了時(Apple: GRACE_PERIOD_EXPIRED、Google: type 5 SUBSCRIPTION_ON_HOLD)に Entitlement を停止する

バッチ / ジョブ設計

基本方針

ジョブ設計では、通常の状態変化は通知駆動、ジョブは補正・復旧・監査という役割分担を守らなければなりません(MUST)。ここでいうジョブには、cron 型バッチだけでなく、キューを使った非同期ワーカーも含みます。

必須に近いジョブ

  1. イベント投影ワーカー
    InboundWebhookEvent やクライアント起点の検証要求を受け、ストア再照会と DB 投影を非同期に実行します。同期 API のタイムアウトを避けつつ、同じ投影ロジックへ集約できます。
  2. 失敗イベント再処理ジョブ
    processingError が残っている通知や、途中で API 照会に失敗したイベントを再処理します。
  3. 再照合ジョブ
    アクティブ購読、更新日が近い購読、直近で異常があった購読を対象に、Apple / Google へ再照会して状態差分を補正します。
  4. acknowledgement 救済ジョブ
    Google の acknowledgementState = PENDING を監視し、初回購入や prepaid top-up を期限内に救済します。

要件次第で追加するジョブ

  • Apple 通知履歴回収ジョブ
    Get Notification History を使い、通知欠落期間を回収します。
  • 価格変更追跡ジョブ
    同意待ちや適用待ちの価格変更を再照会し、pending / accepted / applied を更新します。
  • 返金 / 取消再照合ジョブ
    直近の refund / revoke / voided purchase を再確認し、権利剥奪漏れを検知します。
  • 分析集計ジョブ
    Active subscriber、trial conversion、price change churn などの派生指標を日次で集計します。

ジョブ設計上のルール

  • ジョブはすべて冪等にする
  • Subscription 単位またはストア業務キー単位でロック戦略を決める。具体的には、PostgreSQL の advisory lock、SELECT ... FOR UPDATE、またはアプリケーションレベルの排他制御(例: Redis を用いた分散ロック)から選択する(SHOULD
  • 同一購読への重複ジョブ投入を抑制する
  • 失敗時は DLQ 相当へ退避し、再実行可能にする
  • 「ジョブが成功したこと」ではなく、最終的に DB 投影が正しいことを監視する

ストア API レート制限への対応

再照合ジョブやバックフィル時に Apple App Store Server API および Google Developer API のレート制限(quota)を超過するリスクがあります(MUST 考慮)。以下の方針で対応すべきです(SHOULD)。

  • ジョブ内の API 呼び出しにスロットリング(例: token bucket、固定間隔待機)を組み込む
  • バックフィル時は対象を優先度順にバッチ化し、1 回のジョブ実行で処理する件数に上限を設ける
  • API quota の残量を監視し、閾値を下回った場合はジョブを一時停止する
  • Apple の Get Notification History は 1 時間あたりのリクエスト数に制限があるため、回収ジョブの実行間隔を考慮する

全件同期バッチを主役にしない理由

毎晩全購読をフルスキャンして更新する設計は、API コスト、遅延、将来の複数決済経路統合の面で不利です。通常処理は通知駆動で行い、全件級の走査は障害復旧や監査用途に限定するのが適切です。


障害復旧とバックフィル

復旧方針

通知欠落、Pub/Sub 停止、App Store Server Notifications の配送不全、DB 障害、外部 API 障害が起きても、ストア再照会から状態を再構築できることを前提に設計しなければなりません(MUST)。

復旧手順には次を含めます。

  • どの期間にどの通知が欠落したかを特定する
  • Apple は Get Notification History、Google は purchases.subscriptionsv2.get と既存 token 群を使って再照合する
  • InboundWebhookEvent の生 payload を起点に再投影する
  • 影響範囲が大きいときは、アクティブ購読と更新日近傍の購読から優先復旧する
  • 不完全な情報で Entitlement を変更しない

バックフィル対象の優先順位

  1. 現在 isActive = trueEntitlement を持つ購読
  2. currentPeriodEndAt が近い購読
  3. 直近で processingError が出た購読
  4. acknowledgementStatus = PENDING の Google 購読
  5. 価格変更 pending 中の購読
  6. refund / revoke / chargeback 直後の購読

ランブックとして残すべき事項

  • 手動再照合 API の使い方
  • 通知欠落期間の特定方法
  • 復旧時のモニタリング項目
  • 復旧後に会計 / CS / 分析へ共有すべき影響
  • 再発防止の観点で残す監査情報

テスト戦略

テストレイヤーの概要

テストは以下の 3 レイヤーに分離しなければなりません(MUST)。

  1. バックエンド単体テスト: 投影ロジックを pure function 化し、ストア API をモック化して自動実行する。CI に組み込み、全コミットで回帰検証する
  2. Sandbox / ライセンステスター結合テスト: 実際のストア環境(Apple Sandbox、Google ライセンステスター)を使い、通知受信 → 再照会 → DB 投影の E2E を検証する
  3. 本番リプレイテスト: 本番で受信した生 payload を匿名化し、バックエンド単体テストのフィクスチャとして継続投入する

バックエンド単体テスト設計

通知マッピングテーブル検証

「通知 → 内部状態マッピング」セクションで定義した全組み合わせに対して、以下の構造でテストを作成しなければなりません(MUST)。

  • Apple V2 の全 notificationType + subtype 組み合わせ(約 20 パターン)ごとに、生 payload の JSON フィクスチャファイルを用意する
  • Google RTDN の全 notificationType(少なくとも 1〜13, 19, 20, 22。対象商品を扱うなら 17, 18 も含む)ごとに、生 payload の JSON フィクスチャファイルを用意する
  • 各フィクスチャを投影ロジックに通した結果、期待する SubscriptionStatusBillingEventTypeEntitlement.isActive になることを検証する
  • ストア API 再照会部分はモック化し、purchases.subscriptionsv2.get や App Store Server API のレスポンスもフィクスチャ化する

テスト関数の構造例:

// Projection logic MUST be a pure function separated from DB access.
type ProjectionInput = {
  currentSubscription: Subscription;
  storeApiResponse: StoreApiResponse; // mocked
  inboundEvent: InboundWebhookEvent;
};

type ProjectionOutput = {
  newStatus: SubscriptionStatus;
  eventType: BillingEventType;
  entitlementActive: boolean;
  // ... other fields
};

function projectSubscriptionState(input: ProjectionInput): ProjectionOutput;

優先すべきテストケース

  1. 状態遷移テスト
    PENDING -> ACTIVE -> IN_GRACE_PERIOD -> ON_HOLD -> EXPIRED のような投影ロジックを pure function 化して検証する。マッピングテーブルの全行をカバーすること(MUST)。
  2. 冪等性テスト
    同じ Apple 通知(同一 notificationUUID)、同じ Google messageId を複数回送っても二重反映されないことを確認する。
  3. purchase token 連鎖テスト
    linkedPurchaseToken が返る upgrade / downgrade / resubscribe で、新 token に権利を移し、旧 token 側の権利が残らないことを確認する。
  4. acknowledgement テスト
    Google の自動更新サブスク初回購入で acknowledgementStatePENDING から完了状態へ遷移することを確認する。renewal は acknowledgement 不要であること、prepaid plan では top-up も対象になることを分けて検証する。
  5. 価格変更テスト
    Apple の PRICE_INCREASE の同意待ち / 同意済み / 価格変更起因の失効、Google の opt-in 承認 / 未承認、opt-out 値上げ、値下げ適用を分けて検証する。価格変更予定の登録だけでは Entitlement が変化しないことも確認する。

要件固有のテストケース

以下の各シナリオに対して、前提条件・操作・期待結果を明示した単体テストを作成しなければなりません(MUST)。

トライアル(無料期間):

#シナリオ前提条件投入する通知/イベント期待結果
T-1初回購入でトライアル付与BillingAccount にトライアル履歴なしApple: SUBSCRIBED / INITIAL_BUYofferType=1, offerDiscountType=FREE_TRIALstatus=TRIALING, trialEndsAt が設定される, Entitlement.isActive=true
T-2トライアル終了後に ACTIVE 遷移status=TRIALING, trialEndsAt が過去Apple: DID_RENEW(subtype なし)status=ACTIVE, Entitlement.isActive=true
T-32 回目の購入でトライアル非付与BillingTransaction にトライアル開始イベントありApple: SUBSCRIBED / INITIAL_BUYofferType なし)status=ACTIVETRIALING にならない)
T-4Google トライアル付与初回購入Google: type 4 SUBSCRIPTION_PURCHASED、再照会で offerPhase.freeTrial を含む line item ありstatus=TRIALING, trialEndsAt が設定される

猶予期間(Grace Period):

#シナリオ前提条件投入する通知/イベント期待結果
G-1決済失敗で猶予期間に入るstatus=ACTIVEApple: DID_FAIL_TO_RENEW / GRACE_PERIODstatus=IN_GRACE_PERIOD, Entitlement.isActive=true, BillingEventType=ENTERED_GRACE_PERIOD
G-2猶予期間中に回復status=IN_GRACE_PERIODApple: DID_RENEW / BILLING_RECOVERYstatus=ACTIVE, Entitlement.isActive=true, BillingEventType=RECOVERED
G-3猶予期間終了status=IN_GRACE_PERIODApple: GRACE_PERIOD_EXPIREDstatus=ON_HOLD, Entitlement.isActive=false, BillingEventType=ENTERED_ON_HOLD
G-4Google 猶予期間に入るstatus=ACTIVEGoogle: type 6 SUBSCRIPTION_IN_GRACE_PERIODstatus=IN_GRACE_PERIOD, Entitlement.isActive=true
G-5Google 猶予期間終了 → account holdstatus=IN_GRACE_PERIODGoogle: type 5 SUBSCRIPTION_ON_HOLDstatus=ON_HOLD, Entitlement.isActive=false
G-6Google account hold から回復status=ON_HOLDGoogle: type 1 SUBSCRIPTION_RECOVEREDstatus=ACTIVE, Entitlement.isActive=true

購入復元:

#シナリオ前提条件操作期待結果
R-1正常な復元既存の Subscription あり/billing/apple/ingestaction: "restore"既存の BillingAccount に紐付き、Entitlement が有効化される
R-2別アカウントの購入を復元試行originalTransactionId が別の BillingAccount に紐付き済み/billing/apple/ingestaction: "restore"移し替えが行われず、エラーが返る
R-3新規購入と復元の冪等性同一購入に対して action: "purchase"action: "restore" を連続送信2 回とも /billing/apple/ingest結果が同一(重複レコードなし)

カタログ価格:

#シナリオ操作期待結果
P-1価格キャッシュ更新/billing/apple/ingest にストア API レスポンスの価格情報を含めて送信StoreProductMapping.displayPriceMicros が更新される
P-2カタログ価格は課金判定に使われないdisplayPriceMicros を変更Subscription.currentRecurringPriceMicrosEntitlement に影響しない

リコンシリエーション

Google は Subscription lifecycle にあるとおり purchase token が加入から失効後 60 日まで Developer API で有効なため、期限切れ直後から一定期間内の照合を優先する必要があります。Apple / Google とも通知欠落を前提に、日次または複数回 / 日の再照合ジョブを持つのが安全です。

Sandbox / ライセンステスター結合テスト

ストア連携のテスト環境は、どの層を検証したいかによって使い分けなければなりません(MUST)。クライアント内の購入 UI やローカル状態だけを見たいのか、実際のストア商品・通知・サーバ再照会まで含めた E2E を見たいのかで、選ぶべき環境が異なります。

テスト環境の使い分け

ストア環境主な用途向いている検証向いていない検証
AppleStoreKit Testing in Xcodeローカル構成での高速検証購入 UI、クライアント状態遷移、エラー分岐、ユニット / CI実ストア商品、App Store Server Notifications、サーバ照合
AppleSandbox実商品 + サーバ間連携の検証サーバ通知、App Store Server API、grace period、billing retry、storefront、Family Sharing配布後ベータの現実的 UX 検証
AppleTestFlight配布済みベータでの実機検証実機 UX、配布後の購入フロー確認、日次更新のベータ検証長い renewal パターン、柔軟な更新速度変更
GoogleライセンステスターPlay 経由の標準 E2E購入、更新、解約、RTDN、pending transaction国変更、trial 再テスト、価格変更シミュレーションの効率化
GooglePlay Billing Labライセンステスターを補助する専用ツールgrace period / account hold 加速、price change、trial / intro offer の再テスト、Play country 変更単独での本番同等 E2E(ライセンステスター設定なしでは不十分)

原則として、Apple は「Xcode → Sandbox → TestFlight」, **Google は「ライセンステスター → Play Billing Lab 補助」**の順に使い分けるのが最も事故が少ないです。

Apple テスト環境

1. StoreKit Testing in Xcode

StoreKit Testing in Xcode は、App Store Connect に商品を作成する前でも、ローカルに構成した StoreKit Configuration を使って課金フローを検証できる環境です。ローカルで高速に反復できるため、購入 UI、復元 UI、状態遷移、エラー表示、購読更新のクライアント処理を最初にここで潰すべきです(SHOULD)。

ただし、これはローカルテスト環境であり、実際の App Store 商品や App Store Server Notifications V2 の配送確認には向きません。したがって、バックエンドの通知受信、署名検証、App Store Server API 再照会、DB 投影の確認は Sandbox で別途実施しなければなりません(MUST)。

推奨用途:

  • 購入ボタン押下から Transaction 更新までのクライアント検証
  • restore UI / 失敗 UI / リトライ UI の確認
  • Billing Retry on Renewal、Billing Grace Period、StoreKit エラーのローカル再現
  • CI での自動テスト(StoreKitTest フレームワーク利用)

参考リンク:

2. Apple Sandbox

Apple Sandbox は、実際の App Store 商品情報とサーバ間トランザクションを使う統合テスト環境です。サーバ通知、署名検証、App Store Server API 再照会、Sandbox 固有の加速更新、billing retry / grace period、storefront 差分、Family Sharing、interrupted purchase を含む E2E は、原則としてこの環境で検証します(MUST)。

Apple Sandbox は以下の仕様を持つため、テスト計画に反映しなければなりません(MUST)。

更新速度の加速: Sandbox ではサブスクリプションの更新が加速されます。更新速度は App Store Connect の「Sandbox Account Settings」でテスターアカウントごとに設定できます。既定は 1 か月 = 5 分で、この加速率は billing retry と grace period の長さにも反映されます。

自動更新の上限: Sandbox のサブスクリプションは最大 12 回自動更新した後、13 回目で自動更新が停止します。長期間の反復テストでは、購入履歴のクリアまたは別 Sandbox アカウントの使用を前提に計画します。

storefront 切替: Sandbox アカウントごとに App Store の国 / 地域を変更できます。価格表示、課税、販売可否、storefront 依存ロジックがある場合は、少なくとも JP と非 JP の 2 つ以上で確認すべきです(SHOULD)。

interrupted purchase: Sandbox アカウント設定で interrupted purchase を有効化すると、利用規約同意や支払い方法更新を要求されるケースを再現できます。課金シート中断時の UI と再開フローを確認すべきです(SHOULD)。

Family Sharing: Sandbox Test Family を作成することで、Family Sharing 対象の auto-renewable subscription / non-consumable の共有を検証できます。Family Sharing を有効化する商品を扱う場合、共有付与と共有解除の両方をテストしなければなりません(MUST)。

購入履歴クリア: Sandbox では購入履歴をクリアできるため、トライアルや初回購入フローを同一アカウントで再試行できます。トライアル判定・初回限定オファーの再テストでは、履歴クリア手順をテスト計画に組み込むべきです(SHOULD)。

billing issue のシミュレーション手順:

  1. App Store Connect で Billing Grace Period を Sandbox 環境に対して有効化する
  2. iOS デバイスの設定 → App Store → Sandbox Account Settings → 対象アカウントで「Allow Purchases & Renewals」をオフにする
  3. 次回更新時に決済が失敗し、billing retry 状態に入る
  4. grace period が有効であれば、DID_FAIL_TO_RENEW / GRACE_PERIOD 通知を受信する
  5. その後 GRACE_PERIOD_EXPIRED が来た場合は、本ドキュメントの内部正規化では ON_HOLD として扱う
  6. 「Allow Purchases & Renewals」をオンに戻すと、回復時に DID_RENEW / BILLING_RECOVERY を受信する

Sandbox で必ず確認すべき E2E:

  • App Store Server Notifications V2 の Sandbox 通知受信
  • environment=Sandbox の識別と署名検証
  • SUBSCRIBEDDID_RENEWEXPIRED の基本遷移
  • DID_FAIL_TO_RENEW / GRACE_PERIODGRACE_PERIOD_EXPIREDDID_RENEW / BILLING_RECOVERY
  • PRICE_INCREASEDID_CHANGE_RENEWAL_STATUSDID_CHANGE_RENEWAL_PREF
  • Family Sharing による権利付与 / 共有解除
  • interrupted purchase の中断 / 再開

App Store Server Notifications の通知疎通確認手順:

  1. App Store Connect で Sandbox 用の App Store Server Notifications URL が正しく設定され、到達可能な HTTPS エンドポイントになっていることを確認する
  2. サーバ側で Sandbox 通知の受信ログ、署名検証ログ、notificationUUID の保存、失敗時アラートを有効にする
  3. App Store Server API の Request a Test Notification を呼び出し、testNotificationToken を取得する
  4. 続けて Get Test Notification Status を呼び出し、送信結果が success になっていることを確認する
  5. バックエンドで Sandbox のテスト通知を実際に受信し、JWS 検証、environment=Sandbox の識別、監査ログ保存まで正常に完了することを確認する
  6. 受信できない場合は、App Store Connect の通知 URL 設定、TLS 証明書、WAF / firewall、逆プロキシ、JWS 検証失敗ログを順に切り分ける

この確認は、実課金イベントの前に 通知基盤そのものの疎通試験 として実施しておくと、通知が来ない 問題の切り分けが大幅に容易になります(SHOULD)。

storefront 切替のテスト手順:

  1. App Store Connect の Users and Access > Sandbox で対象 Sandbox アカウントを開き、Country or Region を検証対象 storefront に変更する
  2. 端末側で Sandbox Apple Account をサインアウトして再サインインし、storefront 変更を反映させる
  3. アプリを起動して商品一覧を取得し、価格表示、販売可否、ローカライズ文言、storefront 依存ロジックが想定どおり切り替わることを確認する
  4. 購入を実行し、サーバ側で受信した transaction / notification の storefront 関連情報、価格スナップショット、税区分や監査ログの保存値を確認する
  5. 少なくとも JP と非 JP の 2 storefront 以上で同じ確認を行い、表示差異だけでなく権利付与結果が不変であることを検証する

interrupted purchase のテスト手順:

  1. App Store Connect の Users and Access > Sandbox で対象 Sandbox アカウントを開き、Interrupt Purchases for This Tester を有効にする
  2. 端末で対象 Sandbox Apple Account にサインインした状態で購入フローを開始する
  3. 購入途中でアプリ外の対応を要求される中断状態が発生することを確認し、この時点でバックエンドが権利を付与しないことを確認する
  4. 利用規約同意や支払い方法更新など、要求されたアクションを端末側で完了する
  5. アプリへ戻って購入を再開し、最終的に transaction 完了、サーバ通知受信、再照会、DB 投影、権利付与まで正常につながることを確認する
  6. テスト後は Interrupted Purchases 設定を無効化し、通常フローへの影響が残らないことを確認する

Family Sharing のテスト手順:

  1. App Store Connect で Sandbox Apple Account を少なくとも 2 つ作成し、必要に応じて全員の Country or Region を同じ storefront に揃える
  2. Users and Access > Sandbox > Family Sharing から Sandbox Test Family を作成し、1 つを organizer、残りを family member として追加する
  3. Family Sharing 対象の auto-renewable subscription または non-consumable を organizer アカウントで購入する
  4. family member 側の端末で同じ Family に参加した Sandbox Apple Account にサインインし、共有権利がアプリに反映されることを確認する
  5. サーバ側では、共有による権利付与が通常購入と混同されず、共有元と共有先を識別できるログ / 状態になっていることを確認する
  6. App Store Connect または端末の Sandbox Account Settings で共有を無効化、または family member を Family から外し、共有権利が解除されることを確認する

参考リンク:

3. TestFlight

TestFlight で配布したアプリは自動的に sandbox 環境で動作します。したがって、実機配布後のベータ検証で購入フローを確認するには有効です。ただし、TestFlight のサブスクリプション更新速度は Sandbox Account Settings の可変レートとは異なり、期間に関係なく 24 時間ごとに更新、最大 6 回で停止します。したがって、長い renewal シーケンスや短時間での繰り返し検証には向きません。

また、billing retry の開始 / 終了など一部のシナリオは、TestFlight アプリであっても Sandbox Apple Account を使って検証する必要があります。TestFlight のみで全シナリオをカバーできると考えてはなりません(MUST NOT)。

TestFlight を使う目的:

  • 配布済みベータビルドでの実機購入 UX 検証
  • App Store Connect 上の商品設定ミスの早期発見
  • ベータテスター導線での購入 / 復元導線確認

TestFlight に期待しすぎてはいけないこと:

  • Sandbox のような柔軟な更新速度変更
  • 長い期間を短時間で圧縮した反復テスト
  • billing retry など Sandbox Apple Account 設定に依存する全シナリオの単独再現

TestFlight の実施手順:

  1. App Store Connect で対象ビルドを TestFlight 配布し、検証対象の In-App Purchase が App Store Connect 側で有効になっていることを確認する
  2. テスター端末で TestFlight ビルドをインストールし、通常の購入フローと復元フローが表示されることを確認する
  3. TestFlight 上で billing retry や renewal rate 変更を試したい場合は、端末の Media & Purchases から本番 Apple Account をサインアウトし、Developer Settings で Sandbox Apple Account にサインインする
  4. 対象商品を購入し、クライアント UI、サーバ通知受信、App Store Server API 再照会、DB 投影が正常に完了することを確認する
  5. 24 時間ごとの加速更新に合わせて DID_RENEW 系イベント、権利継続、更新回数上限到達後の自動更新停止を確認する
  6. billing retry を検証する場合は、Sandbox Apple Account Settings で失敗シナリオを有効化し、retry 開始 / 回復 / 失効の各段階で内部状態が想定どおり更新されることを確認する

参考リンク:

Google テスト環境

1. ライセンステスター

Google Play の課金 E2E は、まずライセンステスターを基礎に組み立てます。ライセンステスターは課金されないテスト用支払い方法を使え、RTDN・Developer API・実商品設定を含めた標準的な検証ができます。

注意点として、テスト用サブスクリプションは最大 6 回更新後に終了します。また、ライセンステスターでは Resubscribe ボタンが常に有効であるため、Resubscribe 無効時の UX を確認したい場合は非ライセンステスターの別アカウントが必要です。

ライセンステスターで最低限確認すべき項目:

  • 新規購入 → acknowledgement → RTDN type 4 / 2
  • 解約 → type 3 → 有効期限満了 → type 13
  • 再購読 → type 7 または新規トークン発行を伴う遷移
  • linkedPurchaseToken を伴う upgrade / downgrade / resubscribe
  • testPurchase フラグの保存

非ライセンステスター確認の手順:

  1. 対象アプリを internal test などの Google Play テストトラックへ配布し、検証端末へ Play 経由でインストールできる状態にする
  2. 課金に使う Google アカウントを 2 つ用意し、片方はライセンステスター、もう片方は ライセンステスターに登録しない 非ライセンステスターにする
  3. まずライセンステスターで購入し、Resubscribe ボタンが常に有効であることを確認する
  4. 次に非ライセンステスターのアカウントで同じ導線を確認し、Google Play Console の設定どおりに Resubscribe 可否や購読管理 UI が表示されることを確認する
  5. renewal 期間、価格表示、購入ダイアログ、購読センター導線などがテスト専用の短縮ロジックに依存していないことを確認する
  6. 大きな変更を加えたリリース前には、少なくとも 1 回は非ライセンステスターで購入フローと購読管理導線を再確認する

ライセンステスターは開発効率の面で必須ですが、本番に近い UX の最終確認は非ライセンステスターでも行うという運用ルールを置くのが安全です(SHOULD)。

参考リンク:

2. Play Billing Lab

Play Billing Lab は、ライセンステスターを補助する公式ツールです。以下のような、素のライセンステスターだけでは回しにくい検証を効率化できます。

  • Play country の変更
  • trial / introductory offer の再テスト
  • subscription price change のテスト
  • grace period / account hold を含む状態遷移の加速
  • 条件付きで real payment methods のテスト

ただし、Play Billing Lab の一部設定は 2 時間で失効します。また、real payment methods を有効化すると他のライセンステスター機能や Billing Lab 設定が失われ、返金しない限り手数料が発生し得るため、通常の回帰テスト経路には入れないほうが安全です(SHOULD)。

3. pending transaction のテスト

PENDING を内部状態として扱う以上、遅延決済のテストは必須です(MUST)。Google はライセンステスター向けに、数分後に自動承認または自動キャンセルされる delayed form of payment の test instrument を提供しています。

pending transaction のテスト手順:

  1. ライセンステスターで対象商品を購入する
  2. 支払い方法に「Slow test card, approves after a few minutes」または「Slow test card, declines after a few minutes」を選ぶ
  3. 購入直後は subscriptionState = PENDING として扱い、Entitlement を付与しない
  4. 数分後に購入成功なら PURCHASED / ACTIVE に遷移し、初めて権利付与する
  5. 数分後に購入失敗なら SUBSCRIPTION_PENDING_PURCHASE_CANCELED などを経由して権利付与なしで終了する

PENDING の間に権利付与や acknowledgement を誤って行わないことを、必ず確認しなければなりません(MUST)。

4. grace period / account hold のテスト

grace period のテスト手順(ライセンステスター):

  1. Google Play Console で対象 base plan の grace period を設定する
  2. ライセンステスターアカウントで承認テストカードを使い、月額サブスクリプションを購入する
  3. Google Play アプリ → アカウント → 定期購入 → 対象サブスクリプション → 支払い方法を「Test instrument, always declines」に変更する
  4. 月額プランの代表例では、約 5 分後に決済が失敗し RTDN type 6 SUBSCRIPTION_IN_GRACE_PERIOD に入る
  5. この時点で Entitlement.isActive = true が維持されることを確認する
  6. 支払い方法を承認テストカードに戻し、type 1 SUBSCRIPTION_RECOVERED で回復することを確認する

account hold のテスト手順(ライセンステスター):

  1. 上記 grace period テストの途中まで実行する
  2. 支払い方法を変更せずに待機し、grace period 終了後に RTDN type 5 SUBSCRIPTION_ON_HOLD を受信する
  3. Entitlement.isActive = false に変わることを確認する
  4. 支払い方法を承認テストカードに戻し、type 1 SUBSCRIPTION_RECOVERED で復帰することを確認する
5. trial / introductory offer / price change のテスト

Google は trial / introductory offer の再テスト、および subscription price change のテストを Play Billing Lab で支援しています。したがって、TRIALING 判定や priceChangeDetails の投影を実装している場合、通常購入だけでなく以下を含めて検証すべきです(SHOULD)。

  • 同一アカウントで trial / intro offer を繰り返し再テストできること
  • lineItems[].offerPhase.freeTrial を使った TRIALING 判定
  • type 19 SUBSCRIPTION_PRICE_CHANGE_UPDATED 受信後に priceChangeDetails を再照会すること
  • 価格変更通知だけでは Entitlement を変更しないこと

trial / introductory offer のテスト手順(Play Billing Lab):

  1. 検証端末に Play Billing Lab をインストールし、課金に使う Google アカウントと同じアカウントでログインする
  2. その Google アカウントが対象アプリのライセンステスターとして登録済みであることを確認する
  3. Play Billing Lab で Test free trial or introductory offer を有効化して Apply し、必要に応じて Play Store キャッシュをクリアして反映を待つ
  4. アプリで対象サブスクリプションを購入し、再照会結果の lineItems[].offerPhase.freeTrial から status=TRIALINGtrialEndsAt が投影されることを確認する
  5. 同じアカウントで購入履歴がある状態でも再度 trial / intro offer を検証できることを確認し、通常の「新規ユーザーのみ」制約に依存しない回帰テスト経路を確立する
  6. Play Billing Lab の設定は 2 時間で失効し得るため、長時間のテストでは設定再適用が必要なことをチェックリストに明記する

subscription price change のテスト手順(Play Billing Lab):

  1. Play Billing Lab をライセンステスターと同じ Google アカウントで開き、Dashboard の Subscription settings から Manage を選択する
  2. 価格変更を試したいアクティブな subscription を選び、新しい価格を入力する
  3. テスト要件に応じて User opt-out の有無を選択して Apply する
  4. アプリ側では既存権利を維持したまま、バックエンドが RTDN type 19 SUBSCRIPTION_PRICE_CHANGE_UPDATED を受信し、purchases.subscriptionsv2.getpriceChangeDetails を再照会することを確認する
  5. SubscriptionPriceChange などの pending 状態が作成または更新され、価格変更通知だけでは Entitlement を変えないことを確認する
  6. ユーザー承諾または適用時点のイベント後にのみ、現行価格スナップショットや課金条件が更新されることを確認する
  7. 価格変更テストは Play Billing Lab とライセンステスターを用いて行い、実際に本番商品価格を変更して試験しないことを運用ルールとして明記する
6. real payment methods のテスト

実運用に近い購入 UX を確認したい場合、Play Billing Lab による real payment methods のテストも可能です。ただし、権限付与が必要であり、通常のライセンステスター機能を失ううえ、購入後に返金しないと課金や手数料が発生し得ます。したがって、これは回帰テストではなく、リリース前の限定的な受け入れテストとしてのみ扱うべきです(SHOULD)。

テスト環境での代表的な加速時間

ストア環境代表例
AppleSandbox既定は 1 か月 = 5 分。Sandbox Account Settings で変更可能。自動更新は最大 12 回
AppleTestFlight期間に関係なく 24 時間ごとに更新。最大 6 回
Googleライセンステスター月額は約 5 分更新、最大 6 回更新後に終了
Googleライセンステスター + Billing Labgrace period / account hold / price change / trial 再テストを加速・補助

最低限の必須 E2E ケース

ストア別に最低でも次のケースを結合テストへ含めるべきです(MUST)。

Apple:

  • SUBSCRIBED 初回購入
  • DID_RENEW 通常更新
  • DID_FAIL_TO_RENEW / GRACE_PERIOD
  • GRACE_PERIOD_EXPIRED
  • DID_RENEW / BILLING_RECOVERY
  • EXPIRED / VOLUNTARY
  • PRICE_INCREASE / PENDING
  • DID_CHANGE_RENEWAL_STATUS / AUTO_RENEW_DISABLED
  • DID_CHANGE_RENEWAL_PREF / UPGRADE or DOWNGRADE
  • Family Sharing 共有 / 解除
  • interrupted purchase

Google:

  • type 4 SUBSCRIPTION_PURCHASED
  • type 2 SUBSCRIPTION_RENEWED
  • type 6 SUBSCRIPTION_IN_GRACE_PERIOD
  • type 5 SUBSCRIPTION_ON_HOLD
  • type 1 SUBSCRIPTION_RECOVERED
  • type 3 SUBSCRIPTION_CANCELED
  • type 13 SUBSCRIPTION_EXPIRED
  • PENDING → 成功
  • PENDING → キャンセル
  • type 19 SUBSCRIPTION_PRICE_CHANGE_UPDATED
  • trial / intro offer
  • linkedPurchaseToken を伴う upgrade / downgrade

参考リンク:

本番リプレイテスト

本番環境で受信した InboundWebhookEvent の生 payload を匿名化し、バックエンドテストのフィクスチャとして継続活用すべきです(SHOULD)。

  • 匿名化ルール: appAccountTokenpurchaseTokenoriginalTransactionIdorderId などの識別子をダミー値に置換する。notificationTypesubtypesubscriptionStateexpiryTime などの状態フィールドはそのまま保持する
  • リプレイ手順: 匿名化した payload を InboundWebhookEvent テーブルに挿入し、投影ワーカーを実行する。結果を期待値と照合する
  • 新規パターンの蓄積: 本番で初めて観測された通知パターン(例: 未知の subtype、想定外の状態遷移)は、匿名化してテストスイートに追加する運用フローを設ける

Sandbox / Production 環境分離

バックエンドは Sandbox 環境と Production 環境で通知やトランザクションの挙動が異なるため、以下を定義しなければなりません(MUST)。

Apple:

  • App Store Connect で Sandbox 専用の通知 URL を設定できる。Production と Sandbox で通知エンドポイントを分離するか、同一エンドポイントで environment フィールド(Sandbox / Production)で振り分けるかを決定する
  • SignedDataVerifier の初期化時に environment を指定する必要がある。Sandbox と Production で別インスタンスを用意するか、受信時に動的に判定する

Google:

  • ライセンステスターによるテスト購入は本番と同じ Pub/Sub トピックに届く。ただし RTDN ペイロード自体には Sandbox / Production 相当の環境値は含まれない。受信時点では InboundWebhookEvent.environment = "UNKNOWN" とし、後続の purchases.subscriptionsv2.gettestPurchase を確認して Subscription.isTestPurchase などへ反映するべきです(MUST
  • ライセンステスターの購入かどうかは、purchases.subscriptionsv2.get のレスポンスに含まれるテスト購入フラグ(testPurchase フィールド)で判定する

共通:

  • InboundWebhookEventenvironment カラム(String)を追加し、Apple では SANDBOX / PRODUCTION を、Google では少なくとも UNKNOWN を記録できるようにする
  • テスト購入データの定期クリーンアップジョブを設け、Sandbox レコードを一定期間後に削除する(SHOULD
  • 本番 DB にテスト購入を混入させない場合は、DB レベルで分離(別スキーマ / 別データベース)する選択肢もある

自動テストと手動テストの線引き

以下の基準で自動化範囲を決定すべきです(SHOULD)。

自動化する(CI に組み込む):

  • 通知マッピングテーブルの全行に対する投影ロジックの検証
  • 冪等性の検証(同一 notificationUUID / messageId の重複投入)
  • linkedPurchaseToken 連鎖の権利移転
  • acknowledgement の状態遷移
  • 復元の冪等性(action: "purchase"action: "restore" の結果同一性)
  • Entitlement の一意制約違反が発生しないことの検証
  • SubscriptionPriceChange の一意制約違反が発生しないことの検証

手動テスト(リリース前チェックリスト):

  • Apple Sandbox での billing issue シミュレーション → grace period → 回復の E2E
  • Google ライセンステスターでの grace period → account hold → 回復の E2E
  • StoreKit Testing in Xcode でのトライアル付与 → トライアル終了 → 通常更新の確認
  • 未ログイン購入、誤復元、アカウント切替の例外シナリオ
  • アプリ強制終了、オフライン復帰、未送信購入証跡の再送
  • ストアコンソールでの価格変更 → 通知受信 → SubscriptionPriceChange の更新確認
  • 本番 payload を使ったリプレイテストの新規パターン追加

管理画面・CS・運用設計

管理画面で見えるべき情報

本番運用では、DB を直接参照しなくても購読状態を追える管理画面が必要です。少なくとも次を検索・表示できると運用しやすくなります。

  • ユーザー ID、メールアドレス、BillingAccount.accountToken
  • Apple originalTransactionId、Google purchaseTokenorderId
  • 現在の Subscription 状態と Entitlement 状態
  • 価格変更 pending / accepted / applied の状態
  • 直近の BillingTransactionInboundWebhookEvent
  • acknowledgement 状態、最終再照合時刻、処理エラー

CS の手動操作方針

CS が直接 Subscription.status を上書きする運用をしてはなりません(MUST NOT)。許可する操作は次のような限定アクションに絞ります。

  • ストア再照合の実行
  • 通知再投影の実行
  • アカウント移管申請の起票
  • 内部メモと監査ログの記録

これにより、サポート都合の手修正が後続の再照合で打ち消される事故を避けられます。


財務リコンシリエーション

SubscriptionEntitlement は利用可否のためのデータであり、会計上の売上そのものではありません。したがって、ストア通知ベースのイベント履歴と、入金・手数料・税・通貨換算を含む財務データは別に照合する必要があります。

本ドキュメントには次を含めるべきです(SHOULD)。

  • ストア側レポートと BillingTransaction の照合単位
  • 通貨コード、micros、税 / 手数料差分の扱い
  • 返金、取り消し、チャージバックを売上指標にどう反映するか
  • KPI 用の MRR / 解約率と会計値との差分説明
  • 月次締めで必要な再計算ルール

課金基盤として正しくても、財務数字が一致しないと運用は成立しません。


データ保持・プライバシー・監査

データ保持

生の通知 payload、JWS、purchase token、orderId は、復旧と監査に有用ですが、無期限保存が常に最適とは限りません。少なくとも次を決めなければなりません(MUST)。

  • 生 payload の保存期間
  • 誰が raw payload を閲覧できるか
  • token や識別子をログへ出すときのマスキング方針
  • 退会後の識別子と課金履歴の分離方針

監査ログ

人手で行った再照合、移管、運用フラグ変更、価格変更設定反映などは、誰が何をしたかを監査ログに残さなければなりません(MUST)。


ユーザー通知方針

ストア通知とアプリ通知は別物です。ユーザー体験を安定させるため、少なくとも次の局面でどのチャネルから何を伝えるかを決めておくべきです(SHOULD)。

  • 購入完了直後だがサーバ反映待ち
  • PENDING のまま確定していない
  • IN_GRACE_PERIOD に入った
  • ON_HOLD に入った
  • 解約予約が入っている
  • 価格変更の同意が必要
  • 復元しようとしたが別アカウントの購入だった

ストアから届くメールや OS 標準導線に任せる範囲と、自社で補助通知する範囲を分けて設計すると混乱が減ります。


運用監視と分析指標

オペレーション監視

監視項目としては以下を推奨します。

  • Webhook 受信数
  • 署名 / JWT 検証失敗率
  • RTDN 受信後の API 照会失敗率
  • PENDINGIN_GRACE_PERIODON_HOLDEXPIRED の件数推移
  • acknowledgement 未完了件数
  • 価格変更の pending 件数、承認率、価格変更起因の churn 件数
  • リコンシリエーションで修復された件数

特に Google は PENDING と acknowledgement 未完了が実収益とユーザー権利に直結するため、専用ダッシュボードを持つ価値があります。

分析指標

運用監視とは別に、プロダクト判断用の指標も定義しておくべきです(SHOULD)。

  • Active subscriber
  • Trial conversion rate
  • Grace recovery rate
  • Account hold recovery rate
  • Refund rate
  • Chargeback rate
  • Price change churn
  • Plan change mix

定義が曖昧なまま分析基盤へ流すと、課金数値がチーム間で一致しなくなるため、事前に定義を確定しなければなりません(MUST)。


ストアポリシー・審査・変更管理

ストアポリシー

本ドキュメントは実装設計書ですが、アプリ内課金はストアポリシーと分離できません。Apple は App Review GuidelinesIn-app purchase で、デジタル商品販売時の基本要件を示しています。日本向けには Payment options on the App Store in JapanChanges to iOS in Japan のように、地域固有の課金導線ルールが追加される場合もあります。Google 側も Google Play’s billing system を起点に、販売方法や外部課金導線の扱いを確認する必要があります。

したがって、何をアプリ内課金で売るべきか、どの地域で外部決済を併用できるか、審査メモに何を書くかは、実装前のチェック項目として別表で管理すべきです(SHOULD)。

変更管理

商品追加、価格変更、offer 追加、ロジック修正、Webhook 仕様変更は、すべて課金事故につながり得ます。最低限、次のチェックリストを持たなければなりません(MUST)。

  • ストアコンソール設定変更のレビュー者
  • アプリ側表示確認
  • バックエンド対応の有無
  • テストケース追加の有無
  • CS / 分析 / 会計への周知
  • リリース後の監視項目

課金に関する設定変更をコード変更より軽く扱ってはなりません(MUST NOT)。


将来の Web 決済追加に備える設計

将来 Web 決済を追加する場合でも、既存のモバイル IAP 設計を壊さないためには、支払い取得手段と権利投影ロジックを分離しなければなりません(MUST)。具体的には次の構造を維持します。

  • Plan
  • Subscription
  • Entitlement
  • BillingTransaction
  • InboundWebhookEvent
  • provider ごとの adapter

この構造であれば、Apple / Google / Web いずれも「外部イベントを正規化して同じ投影ロジックへ流す」構成にできます。

WEB provider の段階的導入: BillingProvider enum には WEB が定義済みですが、Web 決済の実装が未完了の段階では、StoreProductMappingSubscription の作成時に provider が WEB である場合をバリデーションで拒否すべきです(SHOULD)。未実装の決済経路が誤って使用されることを防止します。

複数課金経路の共存ルール

複数経路を扱う場合は、課金履歴を混ぜないが、権利は統合的に判断するという方針が扱いやすいです。実装ドキュメントには次を含めなければなりません(MUST)。

  • 同一ユーザーが Apple / Google / Web を同時保有できるか
  • 同じ Plan 系統に複数契約がある場合の優先順位
  • 二重課金発生時の扱いと返金フロー
  • ストアごとの解約・返金責任範囲
  • 分析・会計で経路別にどう切り分けるか

Entitlement を 1 つに集約するとしても、取引履歴と財務の源泉は provider ごとに保持しなければなりません(MUST)。


最終的な推奨事項

  • 文書の位置づけは「調査レポート」ではなく、アプリ内課金の実装・運用設計ドキュメントとする。
  • Apple は App Store Server API + Notifications V2 + signed transaction を主軸にし、verifyReceipt は互換用途に留める。
  • Google は RTDN をトリガとして使い、必ず purchases.subscriptionsv2.get を source of truth にする。
  • 購入時のユーザー紐付けは、Apple appAccountToken、Google setObfuscatedAccountId を前提にする。
  • SubscriptionEntitlement は分離し、さらに BillingTransactionInboundWebhookEvent を持つ。
  • 商品カタログ、offer、プラン変更マトリクスを、ストア設定ではなくバックエンド設計対象として扱う。
  • 未ログイン購入、誤復元、アカウント統合などの例外系を明文化する。
  • 通知駆動を主処理としつつ、再照合・再処理・acknowledgement 救済・価格変更追跡のジョブを持つ。
  • 価格変更は SubscriptionPriceChange のような専用の pending 状態として保持し、実際の課金更新時にのみ現行価格へ反映する。
  • Google の PENDINGacknowledgementStatelinkedPurchaseTokenpriceChangeDetails を状態設計に必ず反映する。
  • 管理画面、CS 運用、財務リコンシリエーション、監査ログまで含めて課金基盤を設計する。
  • PostgreSQL の nullable unique に依存しない。nullable な複合 unique をそのまま使うのではなく、非 null 正規化または別テーブル化で設計する。

参考リンク

Apple

Google Play

Prisma / PostgreSQL / NestJS