メニューを開く

テーマ

このページの内容

ドキュメント

Part 1. 本編

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

Part 1. 本編

1. 最初に押さえること

アプリ内課金を初めて実装するときは、細かい API 名より先に、次の 4 点を正しく理解しておくことが重要です。

1-1. ストアが真実の源泉である

Apple と Google の課金における真実の源泉は、ストア側の状態です。
自社 DB は、その状態を自社サービスで使いやすい形にした投影結果として扱います。

したがって、クライアントから届いた transactionIdpurchaseToken をそのまま信用してはいけません。
必ずバックエンドからストア API または署名済みデータを使って再確認します。

1-2. 「決済がある」ことと「使わせてよい」ことは別である

課金の世界では、次のような状態が存在します。

  • 購入は存在するが、まだ支払いが完了していない
  • 決済失敗中だが、猶予期間中なので使わせる
  • 自動更新はオフだが、期限までは使わせる
  • 返金済みなので即時停止する

そのため、契約状態利用可否は分けて持ちます。

  • Subscription: ストア上の契約状態
  • Entitlement: 自社サービスの利用可否

1-3. 購入直後のクライアント通知だけに依存してはいけない

購入直後は、クライアントからサーバへ購入情報が送られてくるのが理想です。
しかし現実には、次のようなことが起こります。

  • 購入後にアプリがクラッシュする
  • 通信が切れてサーバ通知に失敗する
  • Webhook が先に届く
  • サーバ更新後にクライアントが結果を受け取れない

そのため、クライアント申告・サーバ通知・定期ジョブの 3 本柱で設計します。

1-4. 初回リリースでは「全部盛り」にしない

初学者が最も迷いやすいのは、価格変更や高度なオファーまで最初から実装しようとすることです。
初回リリースでは、まず次の 5 点だけを成立させます。

  1. 新規購入を正しく反映できる
  2. 更新・解約・返金を追従できる
  3. Google の acknowledgement を漏らさない
  4. 購入復元で誤付与しない
  5. テスト環境で再現できる

この章の一次情報


2. このドキュメントのスコープ

本ドキュメントは、自動更新サブスクリプションを初めて実装する開発者向けに、最初のリリースで必要な設計を整理したものです。

2-1. 本ドキュメントが主に扱うもの

  • Apple App Store / Google Play の自動更新サブスクリプション
  • バックエンドでの検証
  • Webhook / RTDN を使った状態反映
  • 購入復元
  • Google acknowledgement
  • 決済失敗・猶予期間・返金
  • サンドボックス / ライセンステスターでのテスト
  • NestJS / Prisma で実装しやすいデータモデル

2-2. 本ドキュメントで対象外とするもの

次の項目は意図的に深掘りしません。

  • 消耗型課金の在庫管理
  • ファミリー共有の個別 UX 最適化
  • 外部決済 / Web 決済とのポリシー差分の詳細
  • 収益会計・税務処理の詳細
  • 高度なプロモーションオファー戦略
  • Play の prepaid plan を前提にした詳細実装(本ドキュメントでは参考情報にとどめる)

2-3. 設計上の前提

  • 自社アカウントとストア購入をできる限り購入時点で結び付ける
  • 自社 DB はスナップショットだけでなく履歴も保存する
  • 処理は冪等である
  • 失敗時に再実行できる設計にする
  • 初回リリースでの基本経路:
    • Apple: App Store Server API + App Store Server Notifications V2
    • Google: purchases.subscriptionsv2.get + RTDN

3. 実装前の準備と要件整理

実装に入る前に、ストア側・開発環境側の準備と、課金機能として最低限満たすべき要件を先に整理します。
ここが曖昧だと、コード実装に入ってからコンソール設定・識別子設計・責務分担の不足で手戻りが発生します。

3-1. Apple 側の準備

項目必須度目的
App Store Connect でアプリ作成MUSTIAP 設定の前提
自動更新サブスク作成MUST販売する商品を定義する
価格・ローカライズ・利用可能地域の設定MUST商品取得・審査・販売の前提
introductory offer / free trial の設定MUST初回契約時の無料期間をストア側で提供する
Billing Grace Period の設定MUST決済失敗時に猶予期間中の利用継続を可能にする
App Store Server Notifications URL 設定MUST更新・返金・解約・回復・猶予終了を自動反映する
App Store Server API 用の Issuer ID / Key ID / private key / bundleId の整理MUSTJWT を作りサーバから API を呼ぶ
appAppleId の整理SHOULDApp Store signed data 検証で使うことがある(特に production 側)
Sandbox テスターの準備MUST実機テスト用
TestFlight 配布の準備SHOULD実運用に近い QA

Apple で初学者が特に迷いやすい点

  • verifyReceipt は現行の主経路ではない
    Apple の新規実装では App Store Server APIApple 署名済みの transaction / subscription 情報 を中心に扱います。背景は 9-2. verifyReceipt を主経路にしない理由 でまとめます。
  • App Store Server API 用の情報と、signed data 検証用の情報は少し役割が違う
    API 呼び出しで中心になるのは Issuer ID / Key ID / private key / bundleId です。
    一方、appAppleId は signed data の検証で使うことがある情報で、同じ「資格情報」でも用途が異なります。
  • appAccountToken は UUID である
    自社ユーザーとの紐付け用に、購入時に UUID を渡す仕組みです。
  • 無料期間はバックエンドが自動付与するのではなく、App Store Connect の introductory offer で設定する
    初回契約時の free trial はストア機能です。1 人が redeem できる introductory offer は subscription group ごとに 1 回です。購入 UI では StoreKit の product / offer 情報を表示し、バックエンドはその結果を検証して反映します。詳細は 11-4. 無料トライアルと初回無料期間の考え方 を参照してください。
  • Billing Grace Period は App Store Connect で app 単位に有効化する
    App Store Connect では 3 / 16 / 28 日を選べます。対象は All Renewals または Only Paid to Paid Renewals、環境は Sandbox のみ / Production と Sandbox から選べます。
    ただし、実際に適用される猶予期間は設定値とは異なる場合があります。weekly subscription では 3 日または 6 日、monthly 以上では 3 / 16 / 28 日 です。weekly subscription で 16 日または 28 日を選んでも、実効上の猶予は 6 日になります。
  • 月額表示は StoreKit から取得する
    Apple 側の表示価格は Product.displayPrice を使います。月額プランならその価格をそのまま表示し、年額プランの「月あたり」は UI 側の換算値として扱います。詳細は 11-6. 表示価格はストア SDK から取得する を参照してください。
  • TestFlight の IAP は sandbox で動く
    本番課金にはなりません。

3-2. Google 側の準備

項目必須度目的
Play Console でアプリ作成MUST課金設定の前提
Google Play Billing 有効化MUST商品設定の前提
サブスク商品・base plan の作成MUST販売商品を定義する
subscription offer / free trial の設定MUST初回契約時の無料期間や導入価格を提供する
grace period / account hold 設定MUST決済失敗時の利用継続期間と停止移行を制御する
Play Console に対象アプリが存在し、パッケージ名が一致していることMUSTテスト購入の前提
internal / closed などのテストトラック公開SHOULDQA や配布しやすい形で検証する
ライセンステスター設定MUSTテスト購入を無料で行う
Google Play Developer API 利用設定MUSTサーバ検証と acknowledgement
サービスアカウント / 権限設定MUSTDeveloper API を安全に呼ぶ
Pub/Sub topic + RTDN 設定MUST更新・返金・決済失敗・回復・猶予終了を自動反映する
Pub/Sub subscription 方式の選定(push / pull)MUSTRTDN の受信方式を決める
Push subscription 認証設定 または pull subscriber 実装準備MUST受信経路を保護し、継続的に処理できるようにする

Google で初学者が特に迷いやすい点

  • トラック公開は便利だが、常に必須ではない
    ライセンステスターは、Play Console に対象アプリが存在し、パッケージ名が一致していれば、debug build の sideload でも課金テストできます。
    ただし、QA や実運用に近い検証では internal / closed track を使うほうが分かりやすいです。

  • RTDN だけでは状態は分からない
    RTDN は「状態が変わった」という通知であって、完全な購買状態そのものではありません。
    受信後に必ず purchases.subscriptionsv2.get を呼びます。

  • RTDN の受信方式は push / pull のどちらでもよいが、本書は push を前提にする
    Google Play の RTDN は Pub/Sub topic に publish され、その先の subscription は push / pull のどちらでも構成できます
    ただし、RTDN は大量メッセージ最適化が主目的ではないため、Google は迷う場合は push を推奨しています。
    本書では、NestJS の Web API サーバで受けやすい push subscription + HTTPS endpoint を前提に説明します。
    pull を採る場合は、/webhooks/google/rtdn の代わりに 常駐 worker / StreamingPull subscriber で同じ責務を実装します。

  • acknowledgement が必要なのは、new purchase token を伴う purchase である
    Google では、初回購入だけでなく、plan change や re-signup のように新しい purchase token が発行される purchase も 3 日以内に acknowledge する必要があります。
    一方で、renewal は acknowledge 不要です。

  • purchases.subscriptions.get は新規実装の主経路にしない
    Google は新規統合では purchases.subscriptionsv2.get を使うよう案内しています。

  • 無料期間は Play Console の base plan / offer で設定する
    auto-renewing subscription の offer では free trial や introductory price を設定できます。offer の eligibility は Google Play が管理するか、アプリ実行時に判定できます。購入時は ProductDetails から選んだ offerToken を使います。詳細は 11-4. 無料トライアルと初回無料期間の考え方、trial 利用歴の考え方は 11-5. トライアル利用歴あり / なしの判定 を参照してください。

  • grace period / account hold は Play Console で設定する
    現行の一次情報では、auto renewing base plan の grace period は default enabled と案内されています。設定可能な猶予日数は Play Console で調整または無効化できますが、0 days にしても最低 24 時間の silent grace period は別途存在します。これは、Play Console 上で設定する grace period とは別概念です。

    また、account hold も既定で有効で、2025-12-01 以降の既定値は自動計算です。初期計算は 60 days - grace period duration で、既存の既定 30 日設定も自動計算へ切り替わります。したがって、固定日数を前提にせず、実際の状態は purchases.subscriptionsv2.get の結果で判定してください。

    本プロジェクトでは、Google Play の grace period は 3 日で設定する前提とします。ただし、silent grace period はこれとは別に存在するため、実装では 3 days 固定の前提だけで状態判定せず、常に最新のストア状態を再照会します。

  • 月額表示は Billing Library の ProductDetails から取得する
    queryProductDetailsAsync() で取得した ProductDetails を使って表示価格を組み立てます。年額プランの「月あたり」は UI 側の換算値として扱います。詳細は 11-6. 表示価格はストア SDK から取得する を参照してください。

  • Android の復元は専用 OS API というより既存購入の再同期で考える
    queryPurchasesAsync() を接続成功時や onResume() で呼び、取得した purchaseToken をバックエンドへ送って再照合します。
    ただし、queryPurchasesAsync() だけで復元や状態把握を完結させてはいけません。詳細な考え方は 12-9. クライアント側の復元導線 で整理します。

3-3. 課金機能に関する要件整理

レビュー観点になっている最低要件を、本書ではどのように解釈し、初回リリースで何を MUST / SHOULD として扱うかをまとめます。

ここでの MUST は、最低要件そのものへの直接対応と、それを実運用で安定して成立させるための土台を含みます。
一方、SHOULD は、初回リリースで未実装でも直ちに成立しなくなるわけではないものの、運用、CS 対応、障害復旧、品質保証の観点で早めに必要になる項目です。

また、旧来の「共通チェック」で扱っていた項目のうち、実質的に要件そのものになっているものは本節へ統合します。
とくに、ストア商品と内部 Plan の対応付けクライアントからサーバへ渡す購入証跡の受け渡し仕様 は、単なる確認事項ではなく、初回設計時点で固めるべき必須要件として扱います。

優先度区分要件本書での扱い / 具体的に必要なこと主な参照節
MUST商品マッピングストア商品 ID と内部 Plan の対応を明示できることApple の productId、Google の productId / basePlanId / offerId と、自社の Plan を対応付ける。ストア ID を業務ロジックへ直結させない。4 / 6 / 16
MUST購入証跡連携クライアントからサーバへ購入証跡を送れることApple は transactionId、Google は purchaseToken をサーバへ送る。入力仕様はプラットフォームごとに分け、バックエンド再照会の入口を明確にする。4 / 8-3 / 20
MUST商品・価格ストアから価格を取得できること表示価格の真実の源泉はストア SDK。月額プランはその価格をそのまま使い、年額などの 月あたり は UI 側の換算値として扱う。3-1 / 3-2 / 11-6
MUST初回特典初回契約時に無料期間を自動付与できることバックエンドが独自に無料権利を作るのではなく、Apple の introductory offer / Google の free trial・offer を ストア設定 で有効化し、その結果を通常購入として検証・反映する。3-1 / 3-2 / 11-4
MUST利用履歴トライアル利用歴あり / なしを判定できること購入 UI の eligibility はストアを真実の源泉としつつ、自社 DB に hasStartedTrialAtLeastOnce などの永続的な履歴根拠を持つ。11-5 / 16-10
MUST購入復元購入を復元できることApple は AppStore.sync()、Google は queryPurchasesAsync() を入口にしつつ、最終判断は必ずバックエンドの再照会で行う。12 / 23
MUSTストア設定猶予期間を設定・有効化できることApple は App Store Connect、Google は Play Console で設定する。Google は別概念として silent grace period もあるため、3日固定 を実装へ埋め込まない。3-1 / 3-2 / 10-7
MUST契約状態正常 / 猶予期間中 / 猶予期間終了後を判別できることApple は IN_GRACE_PERIODBILLING_RETRY を区別し、Google は IN_GRACE_PERIODON_HOLD を区別する。11-1 / 11-2 / 24
MUST決済異常検知決済エラー発生を検知できること通知は再照会のきっかけとして使い、最終判断は常にストア再照会結果で行う。8-5 / 8-6 / 24
MUST回復検知支払い情報の更新や再決済による回復を検知できること実装上は 支払い手段の編集事実そのもの ではなく、支払い問題からの回復検知 として扱う。11-7 / 24
MUST猶予期間終了検知猶予期間の終わりを検知できることApple は GRACE_PERIOD_EXPIRED 後の billing retry 移行を、Google は silent grace period 後の最終状態を再照会で確定する。10-7 / 11-2 / 24
MUST返金・取消返金、取消、チャージバックを検知できること購入成功後に無効化されるケースを拾えないと、権限が残り続ける。11-3 / 24
MUST権限反映返金、取消、失効を利用権限へ反映できることSubscription の状態更新だけでなく、Entitlement も確実に停止する。11-1 / 11-3 / 24
MUST状態確定ストア再照会を正本として最終状態を確定できることクライアント通知や Webhook / RTDN だけで確定しない。ストアが真実の源泉である。1-1 / 5-2 / 20
MUST冪等性同じ通知や購入イベントを複数回受けても壊れないこと再送や二重送信を前提に、同一イベントの重複処理に耐える。5-2 / 21
MUST順不同耐性通知やクライアント送信の到着順が前後しても正しく処理できることクライアント申告、通知、定期ジョブがどの順で来ても最終状態が一致する設計にする。1-3 / 5-2 / 25
MUST紐付けストア購入と自社アカウントを安定して紐付けできることApple は appAccountToken、Google は setObfuscatedAccountId 系を使う。7 / 16-10
MUST再紐付け再インストール、機種変更、再購読後でも既存ユーザーへ再紐付けできること復元時の誤付与防止を含む。自動紐付けできない場合は手動確認へ逃がす。12 / 23 / 28
MUST通知処理Apple 通知 / Google RTDN を受信して状態更新のきっかけにできること更新、返金、決済失敗、回復、猶予終了を自動追従するために必要。9 / 10 / 21 / 24
MUSTGoogle 固有acknowledgement を漏らさないことGoogle では new purchase token を伴う purchase に対して acknowledgement が必要で、renewal は不要。3-2 / 10-6 / 22
MUST再処理一時失敗した通知や状態反映処理を再実行できること一過性障害で取りこぼさないための再試行経路が必要。25 / 26
MUST欠落補正通知欠落時でも定期再照合や手動再照合で補正できることWebhook / RTDN の欠落を前提に補正ジョブを持つ。25 / 26 / 28
MUST監査保存通知 payload、再照会結果、投影結果を追跡できる形で保存できること問い合わせ、障害調査、誤付与調査のために必要。14 / 29
MUST履歴管理最新状態だけでなく履歴を保存できることBillingTransaction や受信イベント履歴を持ち、後追い検証できるようにする。6 / 16 / 29
MUST環境分離Sandbox / Test / Production の資格情報、通知経路、データを分離できること誤課金、誤検証、テストデータ混入を防ぐ。3-1 / 3-2 / 13
MUST利用可否提供クライアントがサーバ確定の利用可否を取得できることクライアントがストア状態を直接解釈せず、サーバの read model を参照する。5-4 / 8 / 19
MUSTテストテスト環境で end to end を通せること購入、更新、復元、決済失敗、猶予、回復、返金まで主要経路を検証できる状態にする。3 / 13 / 27
SHOULDUI / UX「購入済みだが未反映」の文言や導線を用意できること反映遅延時の混乱を減らす。12 / 13
SHOULD返金申請導線ストア所定の導線で返金申請を開始できること優先度は返金検知・権限反映より下である。12 / 28
SHOULD競合防止別アカウントへの誤紐付けや二重紐付けを防止できることとくに復元時・再購読時に重要である。7 / 12 / 23
SHOULD管理・調査transactionId / originalTransactionId / purchaseToken などで調査できることCS や運用が個別事象を追えるようにする。14 / 28 / 29
SHOULD手動復旧管理画面や運用 API から再照合・再投影できること障害時の救済手段として有効である。26 / 28
SHOULD監視通知停止、反映遅延、未処理の異常状態を検知できること運用開始後の事故検知を早める。14 / 25 / 29

補足:

  • 返金・取消検知権限反映状態確定冪等性順不同耐性 は、最低要件の各項目を実運用で成立させるための土台である。
  • 商品マッピング購入証跡連携 は、初回実装では見落とされやすい一方で、後から崩すと全面的な設計修正になりやすいため、最初から必須要件として明示する。
  • Google 固有の acknowledgement は「Google だけの細かい話」ではなく、初回リリースで落としてはいけない必須要件である。
  • 監査保存管理・調査手動復旧監視 は運用開始後に重要度が急上昇しやすいため、初回リリース時点でも設計余地を残しておくのが望ましい。

この章の一次情報


4. 初回リリースの最短実装順

初学者にとっては、設計順ではなく実装順で見るほうが理解しやすいです。
以下の順で進めることを推奨します。

Phase 0. テスト環境と商品を先に成立させる

実装に入る前に、実際に購入できるテスト環境を先に作ります。課金は API を書き始めてから商品設定を考えると、原因切り分けが難しくなります。

  • App Store Connect / Play Console で対象商品を作成する
  • Apple Sandbox テスター / TestFlight、Google ライセンステスターで購入できる状態にする
  • パッケージ名 / bundle ID / product ID / basePlanId / offerId の対応を確認する
  • クライアントで商品一覧取得と購入画面表示まで先に疎通する

Phase 1. 商品と課金主体の設計を決める

最初に決めるのは DB ではなく、何を、誰に、どう結び付けるかです。

  • 自社 Plan を定義する
  • Apple の productId と Google の productId/basePlanId/offerIdPlan に対応付ける
  • 購入前の段階で、現在ログイン中のユーザー(以下、現在ユーザー)に対応する BillingAccount を作成する
  • BillingAccount 作成時に accountToken を生成する
  • アプリは購入開始前までに現在ユーザーの accountToken を取得する
  • 購入開始 API 呼出し時に、同じ accountToken をストア SDK の引数へ埋め込む
    • Apple: appAccountToken
    • Google: setObfuscatedAccountId / setObfuscatedProfileId

Phase 2. クライアントからサーバへ購入証跡を送れるようにする

まずは購入直後の最短反映を作ります。

  • Apple: 購入完了後に transactionId を送る
  • Google: 購入完了後に purchaseToken を送る
  • ingest API では accountToken を必須入力にせず、認証済みの現在ユーザーを基準に処理する
  • サーバ側エンドポイント
    • POST /billing/apple/ingest
    • POST /billing/google/ingest

Phase 3. サーバからストアを再照会する

ここが課金実装の中核です。

  • Apple:
    • Get Transaction Info
    • 必要に応じて Get All Subscription Statuses
  • Google:
    • purchases.subscriptionsv2.get

Phase 4. Subscription / Entitlement / 履歴を更新する

最初のリリースで最低限必要なのは次の 3 つです。

  • Subscription: 最新状態
  • Entitlement: 利用可否
  • BillingTransaction: 履歴

Phase 5. Google acknowledgement を入れる

Google はここを後回しにしてはいけません。
entitlement 付与後に実行する反映処理の一部として実装します。対象は、new purchase token を伴う purchase です。

Phase 6. クライアントが利用可否を取得できるようにする

クライアントは個々のストア状態を直接解釈せず、
サーバが決めた利用可否を取りに行くのが安全です。

この導線は、専用の GET /me/entitlement でも、既存の画面取得 API に entitlement を含める形でも構いません。
重要なのは、クライアントが参照する利用可否の read model をサーバ側で一貫して提供することです。

Phase 7. Webhook / RTDN を入れる

新規購入だけでなく、次の自動反映に必要です。

  • 更新
  • 解約
  • 返金
  • 決済失敗
  • 回復

Phase 8. 購入復元を入れる

新規購入が安定してから復元へ進みます。
復元は「既存のストア契約を、どの内部ユーザーへ結び直すか」の問題であり、
新規購入より難しいです。

Phase 9. 定期ジョブと補正処理を入れる

最後に、安全装置として次を入れます。

  • 未 ack 購入の再試行
  • 通知未達の補正
  • 期限到来前後の再照合
  • 監視アラート

4-1. なぜこの順番なのか

この順序にしている理由は明確です。

  • 先に新規購入を通す
  • 次にクライアントが利用可否を取得できる導線を作る
  • その後で通知追従に広げる
  • 最後に復元と補正で事故率を下げる

つまり、最初に成功体験を作り、その後で堅牢化するためです。


5. 全体アーキテクチャ

5-1. 全体像

flowchart LR
    A[Client App] -->|purchase result / restore result| B[Backend ingest API]
    C[App Store Server Notifications] --> D[Webhook Receiver]
    E[Google RTDN via Pub/Sub] --> D
    B --> F[Store Verification]
    D --> F
    G[Periodic Reconcile Job] --> F
    F --> H[Projection Logic]
    H --> I[(Subscription)]
    H --> J[(Entitlement)]
    H --> K[(BillingTransaction)]
    H --> L[(InboundWebhookEvent)]
    M[Client App] -->|screen API or GET /me/entitlement| N[Client-facing Read API]
    N --> J

5-2. 処理の原則

どの入口から入ってきても、最終的には同じ投影ロジックに流します。

  • クライアント申告
  • Apple 通知
  • Google RTDN
  • 定期ジョブ

これにより、実装が次のようになります。

  • 検証ロジックが一箇所にまとまる
  • 通知と手動再実行で結果がぶれない
  • テストしやすい
  • 冪等化しやすい

5-3. バックエンドの責務

バックエンドの責務は次の 5 つです。

  1. 購入証跡の真正性を確認する
  2. ストア状態を自社 DB に投影する
  3. Google の acknowledgement を期限内に行う
  4. 利用可否を API として返す
  5. 再送・順序逆転・部分失敗を吸収する

5-4. クライアントの責務

クライアントの責務は限定します。

  • 購入開始時に正しい識別子を埋め込む
  • 購入後に証跡をすみやかにサーバへ送る
  • アプリ再起動後に未送信の証跡を再送する
  • 商品価格や offer 文言は StoreKit / Google Play Billing Library から取得して表示する
  • Apple では Restore Purchases 導線、Google では既存購入の再同期導線を用意する
  • 利用可否の最終判断をサーバに委ねる

6. 最小ドメインモデル

この章では、初回リリースで最低限必要な概念だけに絞って整理します。
ここで扱うのは 概念モデル であり、DB テーブルへの具体的な分解は 16 章で扱います。

6-1. Plan

自社の販売プランです。
課金周期は汎用的な期間表現ではなく、販売する商品期間そのものを表す billingPeriod で持ちます。

例:

  • PRO_MONTHLY + MONTHLY
  • PRO_YEARLY + YEARLY

ストア固有の ID を直接業務ロジックに埋め込まず、
内部プランコードと対応付ける形にします。

6-2. StoreProductMapping

各ストアの商品と内部 Plan の対応です。

項目
providerAPPLE / GOOGLE
internalPlanCodePRO_MONTHLY
appleProductIdpro_monthly
googleProductIdpro.monthly
googleBasePlanIdmonthly
googleOfferIdtrial7d

6-3. BillingAccount

課金の帰属先です。
BillingAccount は provider ごとの概念ではなく、Apple / Google をまたいで同一人物の課金を束ねる単位として扱います。多くのサービスでは User と 1:1 でも構いませんが、将来を考えると別概念にしておくほうが安全です。

また、BillingAccount初回購入時に作るのではなく、購入前の段階で先に作成しておきます。accountToken もその時点で生成し、Apple の appAccountToken と Google の obfuscatedExternalAccountId 系に同じ値を共通利用できるようにしておくと、provider をまたいだ照合方針を揃えやすくなります。

6-4. Subscription

ストア上の契約状態です。
最低限、次の状態を内部で扱えるようにします。

なお、Google では purchase token が差し替わることがあるため、DB 設計では Subscription に加えて token 履歴を別テーブルまたは同等の形で保持することがあります。
これは新しい業務概念を増やすというより、Google 固有の系列管理を安全に永続化するための分解です。

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

TRIALING は内部状態です。無料トライアル期間中で entitlement は有効だが、通常課金開始前であることを区別したい場合に用います。不要なら ACTIVE に畳んでも構いませんが、本ドキュメントでは後続章と整合させるため TRIALING を明示的に扱います。
BILLING_RETRY は内部正規化状態です。主に Apple で、Billing Grace Period 終了後または Billing Grace Period を有効化していない場合の支払い再試行中で entitlement なしを表すために用います。App Store Server API では billing retry state(status = 3)や isInBillingRetryPeriod などの事実を保持し、内部状態では BILLING_RETRY として扱います。なお、Billing Grace Period 中は別状態であり status = 4 です。
ON_HOLD は内部正規化状態です。Google の account hold を表すために用います。Apple には同名の公式状態はありません。Apple の billing retry を ON_HOLD と同一視すると Google の account hold と混同しやすいため、本書では区別します。

6-5. Entitlement

自社サービスの利用可否です。

最低限ほしい項目:

  • billingAccountId
  • planId またはそれに相当する内部 plan 識別子
  • isActive
  • startsAt
  • endsAt
  • reason

6-6. BillingTransaction

履歴です。
「最新状態だけ持つ」設計は避けます。

最低限保存したい情報:

  • provider
  • transaction identifier / purchase token
  • original transaction / linked token
  • event type
  • purchasedAt / expiresAt / revokedAt
  • raw payload
  • environment

6-7. InboundWebhookEvent

受信イベントの監査ログです。

  • provider
  • providerEventId
  • providerBusinessKey
  • verified
  • environment
  • payload
  • firstReceivedAt
  • processedAt
  • processResult

6-8. 推奨する考え方

初学者は、まず次だけ守れば十分です。

  • Subscription契約の最新像
  • Entitlement使わせるかどうか
  • BillingTransaction履歴
  • InboundWebhookEvent監査ログ

なお、物理 DB 設計では、Google 固有の token 履歴管理のために、これらの概念を複数テーブルへ分解することがあります。
その具体例が 16 章の GooglePurchaseToken です。


7. 識別子と紐付けの考え方

課金実装で最も事故が起きやすいのは、決済 API の呼び出しそのものではなく、
誰の購入かを誤ることです。

7-1. Apple で主に使う識別子

識別子用途備考
transactionId個々の取引を識別復元・更新でも変わる
originalTransactionId同一購読系列の識別継続判定に重要
appAccountToken自社課金主体と紐付けBillingAccount 作成時に生成し、購入開始時に埋め込む UUID
notificationUUID通知の冪等化Webhook の重複排除に使う

Apple の一次情報では、appAccountToken購入結果の App Store transaction に紐付く UUIDです。
したがって、これは「購入後にサーバへ送る補助値」ではなく、BillingAccount 作成時に生成しておき、購入開始 API 呼出し時に StoreKit へ埋め込む値として理解するのが正確です。購入時に設定すると、後で transaction information 側にも同じ値が返ってきます。

7-2. Google で主に使う識別子

初回実装でまず押さえるべき識別子は、次の 6 つです。

識別子用途備考
purchaseToken現在の購買系列の識別サーバ検証の主キー
linkedPurchaseToken旧 purchase との接続plan change や re-signup で旧契約を辿るときに使う
setObfuscatedAccountId(...)自社課金主体紐付けBillingAccount.accountToken を購入開始時に設定する
setObfuscatedProfileId(...)自社プロフィール紐付け複数プロフィールがある場合にクライアントで設定する
externalAccountIdentifiers.obfuscatedExternalAccountIdDeveloper API 側で返るアカウント識別子購入開始時に埋めた BillingAccount.accountToken が返る
messageIdPub/Sub メッセージ IDRTDN 受信の冪等化に使う

注意: クライアント SDK の setter 名は setObfuscatedAccountId / setObfuscatedProfileId ですが、
purchases.subscriptionsv2.get で返る JSON のフィールド名は
externalAccountIdentifiers.obfuscatedExternalAccountId / externalAccountIdentifiers.obfuscatedExternalProfileId です。

BillingAccount は provider ごとの概念ではないため、同じ BillingAccount.accountToken を Apple の appAccountToken と Google の obfuscatedExternalAccountId に共通利用して構いません。 同一ユーザーが Apple / Google の両方で課金しても、BillingAccount 自体は 1 つでよい、という整理です。

補足: purchases.subscriptions.acknowledge の request body には、externalAccountIds.obfuscatedAccountId / obfuscatedProfileId を載せられます。
ただし、これは resubscription purchase でのみ設定できる補助経路です。通常の初回購入では、クライアントの BillingFlow で setObfuscatedAccountId(...) / setObfuscatedProfileId(...) を設定する経路を主に使います。

補足: 初回実装では後回しでもよい識別子

  • externalAccountIdentifiers.obfuscatedExternalProfileId
    プロフィール単位の紐付けが必要な場合に使います。
  • outOfAppPurchaseContext.expiredExternalAccountIdentifiers
    前回購読が期限切れになった後に Google Play subscriptions center から再購読した purchase にだけ現れる補助手掛かりです。
    acknowledgement 後に削除されるため、初回実装では「見つかれば使う」程度で十分です。

7-3. 紐付けの優先順位

初回リリースでは、認証済みの現在ユーザーと、ストア再照会結果から得た識別子・契約系列の両方を見て判定する方針を明示してください。
つまり、識別子が一致したから即採用ではなく、現在ユーザーに紐づく BillingAccount と既存の契約系列に矛盾がないかまで見て、紐付け可否を決めます。

Apple

  1. 現在ユーザーの BillingAccount.accountToken と、ストア再照会で得た appAccountToken が一致する
  2. その BillingAccount が、同じ originalTransactionId 系列をすでに保持している
  3. 既知の transactionId 系列から同じ BillingAccount へ安全に到達できる
  4. どれにも当てはまらなければ自動紐付けを行わず、手動確認する

Google

  1. 現在ユーザーの BillingAccount.accountToken と、ストア再照会で得た externalAccountIdentifiers.obfuscatedExternalAccountId が一致する
  2. linkedPurchaseToken から辿った既存契約系列が、同じ BillingAccount にぶら下がっている
  3. outOfAppPurchaseContext.expiredExternalAccountIdentifiers を補助手掛かりとして使う
  4. どれにも当てはまらなければ自動紐付けを行わず、手動確認する

初回実装では、まず externalAccountIdentifiers.obfuscatedExternalAccountIdlinkedPurchaseToken を確実に扱えれば十分です。
outOfAppPurchaseContext は、前回購読がすでに期限切れになった後の再購読でだけ使う補助情報として考えると理解しやすいです。

補足: accountToken を使うタイミング

accountToken は、購入完了後に ingest API へ送るための値ではありません。
購入前に BillingAccount と一緒に作成し、購入開始時にストア SDK へ埋め込んでおくための値です。購入完了後の ingest では、主に transactionId / purchaseToken を送り、バックエンドがストア再照会結果の識別子と現在ユーザーの BillingAccount を照合します。

sequenceDiagram
    autonumber
    actor User as User
    participant App as Client App
    participant API as Backend API
    participant DB as App DB
    participant Store as App Store / Google Play

    User->>App: Sign in / open purchase screen
    App->>API: Prepare billing identity
    API->>DB: Create User / BillingAccount if needed
    DB-->>API: billingAccountId, accountToken
    API-->>App: accountToken
    App->>Store: Start purchase with accountToken embedded
    Store-->>App: transactionId or purchaseToken
    App->>API: POST /billing/*/ingest (authenticated request + transactionId or purchaseToken)
    API->>DB: Resolve current user's BillingAccount
    API->>Store: Re-query store state
    Store-->>API: purchase state + account identifier
    API->>API: Compare returned identifier and existing contract lineage
    API->>DB: Upsert Subscription / Entitlement / BillingTransaction
    API-->>App: projection result

7-4. やってはいけないこと

  • メールアドレスをそのまま obfuscatedAccountId に入れる
  • appAccountToken を未設定のまま運用開始する
  • 復元時に「見つかった購入を全部いまのログインユーザーに付ける」
  • 1 台の端末だけを根拠にユーザー帰属を決める

この章の一次情報


8. API 設計

8-1. 推奨エンドポイント一覧

API名の付け方

本ドキュメントでは、API 名の語感を次のように揃えます。

  • POST /billing/*/ingest のようなクライアント起点で内部状態へ反映する入口は、〜購入情報反映API と呼ぶ
  • POST /webhooks/*/* のようなストア通知を受ける入口は、〜通知受信API と呼ぶ
  • GET /me/* のようなクライアント参照用 read model は、〜取得API と呼ぶ
  • POST /admin/*/reconcile のような管理系の再実行入口は、〜再照合API と呼ぶ

この命名により、API 名だけで 誰が呼ぶのか何をするのか が分かりやすくなります。

まず前提として、MUST なのは endpoint 名ではなく責務です。
課金実装では、クライアントが現在の entitlement を取得できること自体は必須ですが、その実現方法は次のどちらでも構いません。

  • 専用の GET /me/entitlement を設ける
  • 既存の XXX画面取得API や BFF のレスポンスに entitlement を含める

そのため、以下の表では 専用 endpoint として追加が必要なもの と、責務として満たすべきもの を分けて考えます。

8-1-1. MUST

初回リリースでも、少なくとも次の責務に対応する入口は必要です。
本書で主に扱う Apple 通知は Webhook、Google 通知は push subscription 前提では、対応する endpoint は次のとおりです。

API名目的MethodPath認証位置づけ
Apple購入情報反映APIApple 購入証跡登録POST/billing/apple/ingestBearer購入直後 / 復元時の入口
Google購入情報反映APIGoogle 購入証跡登録POST/billing/google/ingestBearer購入直後 / 復元時の入口
Apple通知受信APIApple の通知受信POST/webhooks/apple/notifications署名検証通知ドリブン補正
Google通知受信APIGoogle RTDN の通知受信POST/webhooks/google/rtdnPub/Sub JWT 検証push subscription を採る場合の通知ドリブン補正

加えて、クライアントが現在の利用可否を取得できる read model を提供すること は MUST です。
ただし、それは専用の GET /me/entitlement を新設してもよいですし、既存の画面取得 API に entitlement を含める形でも構いません。

なお、Google 通知を pull 方式 で受ける場合は、POST /webhooks/google/rtdn という HTTP endpoint 自体は不要です。必要なのは通知受信の責務であり、その受け皿は subscriber worker でも構いません。

8-1-2. SHOULD

初回リリースでも強く推奨するが、業務要件によっては後追い実装も許容できるものです。

API名目的MethodPath認証位置づけ
利用可否取得API現在の利用可否を専用 endpoint として取得するGET/me/entitlementBearerentitlement の再利用性が高い場合の専用 read model
課金状態再照合API課金状態の手動再照合POST/admin/billing/reconcileAdminCS / 運用の救済、障害復旧、手動補正

8-1-3. 条件付きで追加を検討してよいもの

要件によっては、次の endpoint を追加すると運用しやすくなります。

重要度API名目的MethodPath追加を検討する条件
SHOULD契約詳細取得API契約詳細表示用 read modelGET/me/billing/subscription設定画面などで、専用 entitlement endpoint より詳しい契約情報を見せたい
補足-復元専用 endpoint は原則不要--本ドキュメントでは /billing/*/ingestaction=restore を渡して共通化する

8-1-4. 購入証跡の検証(レシート検証)とは

「購入証跡の検証」 は、クライアントから受け取った transactionIdpurchaseToken をもとに、バックエンドからストア API や署名済みデータへ再照会して購入証跡を確認することを指します。
「レシート検証」 という語を使う場合も、本ドキュメントでは Apple の旧来の verifyReceipt を主軸とする実装ではなく、この購入証跡の検証を指します。verifyReceipt を主経路にしない理由は 9-2 で整理します。

1-1 節で述べたとおり、クライアントから届いた証跡をそのまま信用せず、必ずバックエンドからストアへ再照会します。具体的な API は次のとおりです。

  • Apple: transactionId を使って App Store Server API や Apple 署名済みデータを確認する
  • Google: purchaseToken を使って purchases.subscriptionsv2.get を確認する

/billing/apple/ingest/billing/google/ingest は、その入口となる API です。
ingest API 自体は accountToken を必須入力にせず、認証済みの現在ユーザーを基準にtransactionId / purchaseToken を主入力として扱います。

8-2. ingest API を provider ごとに分ける理由

Apple と Google は、クライアントから持ち込まれる証跡も、サーバ側で再照会する方法も異なります。

  • Apple は transactionId を中心に扱う
  • Google は purchaseToken を中心に扱う
  • 復元時の識別子や再照合手順も provider ごとに異なる

そのため、初学者にとっては 1 本の endpoint に統合せず provider ごとに分ける ほうが理解しやすく、実装の責務も明確です。

8-3. Apple購入情報反映API の設計方針

API 名: Apple購入情報反映API

Apple で発生した購入や復元の入口です。
クライアントが持ち込んだ transactionId を起点に、サーバ側で Apple へ再照会し、内部状態へ反映します。

この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。

  • Apple 専用 endpoint として分ける
  • actionpurchase / restore を受けられる形にする
  • ingest 入力の主軸は transactionId とし、現在ユーザーは認証済みコンテキストから特定する
  • クライアント申告だけで entitlement を付与しない
  • ストア再照会で得た appAccountToken と既存 originalTransactionId 系列の両方を見て紐付け可否を判定する
  • 新規購入と復元で、検証と projection の本体を共通化する
  • 初回購入の最短反映を担いつつ、通知到着前でも反映できる入口にする

リクエスト例、サーバ側の詳細動作、主な呼び出し場面、シーケンス図は 20-5. POST /billing/apple/ingest の詳細 にまとめます。

8-4. Google購入情報反映API の設計方針

API 名: Google購入情報反映API

Google Play の購入や復元の入口です。
クライアントが持ち込んだ purchaseToken を起点に、サーバ側で purchases.subscriptionsv2.get を呼び、内部状態へ反映します。現在ユーザーは認証済みコンテキストから特定し、obfuscatedExternalAccountId購入開始時に埋め込まれてストア再照会結果から返ってくる値として扱います。

この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。

  • Google 専用 endpoint として分ける
  • actionpurchase / restore を受けられる形にする
  • ingest 入力の主軸は purchaseToken とし、現在ユーザーは認証済みコンテキストから特定する
  • 公開 API の最小必須は purchaseToken を中心に考える
  • externalAccountIdentifiers.obfuscatedExternalAccountIdlinkedPurchaseToken 系列の両方を見て紐付け可否を判定する
  • entitlement 判定はストア再照会結果で確定する
  • new purchase token を伴う purchase では acknowledgement 要否判定を組み込む

リクエスト例、必須項目の考え方、サーバ側の詳細動作、主な呼び出し場面、シーケンス図は 20-6. POST /billing/google/ingest の詳細 にまとめます。

8-5. Apple通知受信API の設計方針

API 名: Apple通知受信API

Apple Server Notifications V2 の受信口です。
これは初回購入の主入口ではなく、状態変化の追従、通知ドリブン補正、未反映イベントの早期補正 のために置きます。

この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。

  • 通知受信 API は ingest API と責務を分ける
  • 通知本文をそのまま真実の源泉にせず、再照会のきっかけとして使う
  • 署名検証、冪等化、監査保存を前提にする
  • 重い処理は同期応答に載せず非同期化する

受信時の詳細手順、場面別の説明、シーケンス図は 21-7. Apple通知受信API の詳細 にまとめます。

8-6. Google通知受信API の設計方針

API 名: Google通知受信API

Google RTDN の受信口です。
これは Google 側の状態変化を早く取り込むための通知入口であり、通知本文だけで状態確定する API ではありません。

この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。

  • RTDN は状態変化通知として扱い、最終状態は purchases.subscriptionsv2.get で再照会する
  • push subscription を採る場合の HTTPS endpoint として設ける
  • pull 方式を採る場合は、同じ責務を常駐 worker 側へ移す
  • JWT 検証、冪等化、監査保存を前提にする

受信時の詳細手順、push / pull の読み替え、主な呼び出し場面、シーケンス図は 21-8. Google通知受信API の詳細 にまとめます。

8-7. 利用可否取得API を専用 endpoint として切り出す場合

クライアントが現在の利用可否を取得できること自体は MUST ですが、専用 endpoint を切るかどうか は設計判断です。
専用の GET /me/entitlement を設けてもよいですし、既存の画面取得 API に entitlement を含める形でも構いません。

この節で重要なのは、次の判断基準です。

  • entitlement を複数画面や複数クライアントから再利用するなら、専用 endpoint が向く
  • 既存の BFF や画面取得 API に自然に含められるなら、無理に分離しなくてもよい
  • どちらを選んでも、クライアントが参照する利用可否の read model はサーバ側で一貫して提供する

最小レスポンス例、持たせないほうがよい責務、シーケンス図は 20-7. GET /me/entitlement の詳細 にまとめます。

8-8. 課金状態再照合API の設計方針

API 名: 課金状態再照合API

これは CS / 運用向けの手動再照合入口です。
初回リリースの MUST ではありませんが、障害復旧や個別問い合わせ対応では非常に有効です。

この endpoint を 8 章で押さえるべきポイントは、次の設計判断です。

  • 直接 DB を書き換える入口ではなく、ストア再照会 + 再投影 の入口にする
  • クライアント向け API と分け、管理権限を前提にする
  • 運用判断で局所的に再実行できる救済手段として位置付ける

入力の考え方、サーバ側の詳細動作、主な呼び出し場面、シーケンス図は 20-9. POST /admin/billing/reconcile の詳細 にまとめます。
また、運用上の位置付けは 26. 障害復旧とバックフィル28. 管理画面・CS・運用権限 と合わせて読むと整理しやすくなります。

8-9. 追加を検討してよい endpoint

8-9-1. GET /me/billing/subscription

設定画面などで entitlement より詳しい契約情報を見せたい場合は、契約詳細表示用の read model を別に持つと整理しやすくなります。
ただし、これも MUST ではなく、画面要件に応じた条件付き追加です。

この endpoint を追加するなら、次の方針が自然です。

  • entitlement より詳しい契約情報を返す
  • ストア生データをそのまま返さず、画面向け view model として整形する
  • クライアントが契約状態の細部を直接解釈しなくて済むようにする

レスポンス例、返却候補、主な呼び出し場面、シーケンス図は 20-8. GET /me/billing/subscription の詳細 にまとめます。

8-9-2. 復元専用 endpoint を増やすべきか

本ドキュメントでは、復元は /billing/*/ingestaction=restore を渡して扱います。
そのため、初回リリースで POST /billing/apple/restorePOST /billing/google/restore を別に増やす必要はありません。復元時の紐付け判断そのものは 23. Restore / Account Linking 詳細 で扱います。

復元専用 endpoint を分けると、購入と復元で検証ロジックや projection ロジックが分岐しやすくなり、挙動差や実装漏れが起きやすくなります。

8-10. レスポンスで返すべきこと

初回リリースでは、次の 4 つがあれば十分です。

  • 反映結果
  • 現在の利用可否
  • 反映完了か保留か
  • ユーザー向け表示に使う最小限の理由

例:

{
  "result": "PROJECTED",
  "entitlement": {
    "isActive": true,
    "reason": "ACTIVE",
    "effectiveUntil": "2026-03-31T12:00:00Z"
  }
}

または保留中:

{
  "result": "PENDING",
  "entitlement": {
    "isActive": false,
    "reason": "PENDING"
  }
}

8-11. HTTP ステータス設計の考え方

初回リリースでは、次の原則で十分です。

  • 200: 正常反映、または冪等再実行
  • 202: 非同期で反映中
  • 400: 入力不正
  • 401: 認証不正
  • 409: 別アカウントに既に紐付いており自動復元不可
  • 422: ストア状態は取得できたが付与不可
  • 500: 内部障害

POST /billing/*/ingest の応答とアプリ側の基本動作

POST /billing/*/ingest は、初回購入・復元・再送の共通入口です。
クライアントは主に transactionId または purchaseToken を送り、現在ユーザーは認証済みコンテキストから特定します。accountToken 系識別子の役割分担は 7-3. accountToken とストア側識別子の関係 で整理したとおり、購入開始時にストアへ埋め込み、ingest 時はストア再照会結果との照合に使う ものとして扱います。

アプリ側の基本動作は、次の表で整理できます。

応答主な意味アプリ側の基本動作有料機能開放再試行証跡保持課金状態DB更新
200 + isActive=true正常反映、または冪等再実行成功として画面更新し、必要なら read model を再取得するする不要不要する
200 + isActive=false正常応答だが PENDING などで未開放確認中として表示し、後で再取得するしない状況により可推奨する場合がある
202非同期で反映中確認中 UI を出し、後で再取得するしない推奨まだしない想定
400入力不正実装不備やローカル不整合として扱うしない不要原則不要しない
401認証不正再ログインを促し、復帰後に再送するしない必要しない
409別アカウント競合により自動紐付け不可自動再試行せず、別アカウント確認やサポート導線を案内するしない不要推奨しない
422ストア状態は取得できたが、状態上いまは付与不可状態に応じて未開放のまま案内するしない原則不要原則不要しない、または非アクティブ更新のみ
500内部障害結果不明として扱い、後で再送するしない必要未確定
通信失敗 / タイムアウトレスポンス未受信結果不明として扱い、後で再送するしない必要未確定

補足:

  • 初回購入で中心になるのは 200202 である。
  • 409 Conflict は主に復元時の別アカウント競合で返す想定である。初回購入でも識別子競合があれば返しえるが、正常な初回購入フローでは発生頻度は低い。
  • 証跡を消してよいのは、少なくとも 200 で成功し、アプリ側で最終状態を確認できた後である。
  • 500 と通信失敗は、サーバ側で途中まで進んでいる可能性があるため、DB 更新は未確定として扱うのが安全である。
  • 422 は照合失敗ではなく、ストア状態は読めたが現時点では付与できないという意味で使う。

9. Apple 実装

9-1. Apple で採るべき基本方針

Apple では、次の方針を採ります。

  • verifyReceipt を主経路にしない
  • App Store Server API を中心にする
  • App Store Server Notifications V2 を有効化する
  • appAccountToken を購入時に必ず入れる
  • 通知欠落に備えて Get Notification History を用意する

9-2. verifyReceipt を主経路にしない理由

8-1-4 節で述べたとおり、本ドキュメントでの「レシート検証」は App Store Server API を主経路とする検証を指します。
Apple は verifyReceipt を deprecated としており、サーバ検証では App Store Server APIApple 署名済み transaction / subscription 情報 を使うよう案内しています。

初学者向けの結論としては、次で十分です。

  • 新規実装の中心は App Store Server API
  • 既存資産との互換や段階的移行が必要なときのみ receipt 系を補助的に使う

つまり、verifyReceipt を完全禁止の API として扱う必要はありませんが、新規設計の真実の源泉や主経路には置かない、という整理です。

9-3. 新規購入フロー

sequenceDiagram
    participant App as iOS App
    participant BE as Backend
    participant Apple as App Store

    App->>BE: Get current accountToken before purchase
    BE-->>App: accountToken
    App->>Apple: Purchase with appAccountToken
    Apple-->>App: transaction result
    App->>BE: POST /billing/apple/ingest (authenticated request + transactionId)
    BE->>Apple: Get Transaction Info
    BE->>Apple: Get All Subscription Statuses (if needed)
    BE->>BE: Compare appAccountToken and contract lineage
    BE->>BE: Project Subscription / Entitlement / History
    BE-->>App: projected result

9-4. 通知フロー

Apple の通知は V2 を前提にします。
V2 の通知ボディは signedPayload で届き、JWS を verify and decode したうえで扱う必要があります。
data.signedTransactionInfodata.signedRenewalInfo も同様に、デコード済み JSON をそのまま信用せず、JWS として検証・デコードします。
Apple は App Store Server Library を提供しており、初回実装ではこれを使って検証処理を組むと安全です。

受信時の基本手順

  1. signedPayload を JWS として verify and decode する
  2. notificationUUID で冪等化する
  3. signedTransactionInfo / signedRenewalInfo も必要に応じて verify and decode する
  4. 必要に応じて App Store Server API へ再照会する
  5. DB に投影する

9-5. 環境の扱い

Apple は Sandbox と Production で環境が分かれます。
transactionId をパラメータに取るエンドポイントは、その transactionId を生成した環境と同じ環境で呼ぶ必要があります。
ただし、実装方針としては まず Production URL で呼び、4040010 TransactionIdNotFoundError を受けたら Sandbox で再試行する 形にしておくのが、Apple の一次情報に沿った安全なやり方です。
Sandbox の transactionId を Production に投げたり、その逆を固定的に決め打ちすると正常に取得できないため、本番コードでは production-first / sandbox fallback を採用するのが望ましいです。
ローカルや検証用途で環境が明確に分かっている場合だけ、明示的に Sandbox URL を使っても構いません。

9-5-1. transactionId だけで環境を決め打ちしない

クライアントから渡された transactionId だけを見て、最初から Sandbox / Production を決め打ちするのは避けます。
特に、購入直後の ingest や運用時の手動再照合では、どちらの環境で生成された取引かをサーバ側で安全に確定できるまで未知として扱うほうが安全です。
そのため、Apple 実装では production-first / sandbox fallback を標準にし、呼び出し結果から最終環境を確定する方針にしておくと、復元や障害調査でも挙動をそろえやすくなります。

9-5-2. signed data 検証と API 呼び出しで必要な材料は少し違う

App Store Server API の呼び出しでは、主に issuerIdkeyIdprivateKeybundleId を使います。
一方、通知や transaction JWS の signed data 検証 では、Apple Root 証明書群、bundleId、環境、必要に応じて appAppleId などが関わります。
どちらも Apple 連携の資格情報ですが、API を呼ぶための材料署名を検証するための材料 は責務が異なるため、設定や service の責務も分けて考えると実装が整理しやすくなります。

9-6. 通知欠落への備え

Apple は V2 を使っている場合、Get Notification History により
サーバが受け損ねた通知を取得できます。

Apple の案内では、この履歴は

  • Production: 過去 180 日
  • Sandbox: 過去 30 日

を対象に取得できます。

9-7. テスト通知

Apple には通知疎通確認用に、次の API があります。

  • Request a Test Notification
  • Get Test Notification Status

testNotificationToken は、一次情報上 最大 6 か月確認に使えます。

9-8. Apple の billing retry と grace period の考え方

Apple では、購読更新の支払いに失敗したとき、Billing Grace Period を有効化しているかどうかで利用可否の扱いが変わります。

  • Billing Grace Period 中: 有料コンテンツへのアクセスを継続する
  • Billing Retry だが Grace Period ではない: 初回リリースでは Entitlement.isActivefalse とする
  • 復旧後: ストア再照会または通知反映で entitlement を戻す

初回リリースでは、Apple の支払い問題系をひとまとめにせず、grace period の有無で判定する方針にしておくと誤解が少なくなります。
本ドキュメントでは、保守的な既定方針として grace でない billing retry は false を採ります。

9-9. Apple で最低限保持したい識別子

  • transactionId
  • originalTransactionId
  • appAccountToken
  • webOrderLineItemId(必要に応じて)
  • environment

9-10. Apple の初回リリースでの実践的な結論

4 章の方針に沿い、初回リリースでは次を成立させます。

  1. 購入時に appAccountToken を設定する
  2. クライアントから transactionId を送る
  3. サーバで Get Transaction Info を呼ぶ
  4. 必要に応じて Get All Subscription Statuses で補う
  5. 通知は V2 を受ける
  6. 通知欠落時は Get Notification History で補正する

この章の一次情報


10. Google 実装

10-1. Google で採るべき基本方針

Google では、次の方針を採ります。

  • サーバ検証は purchases.subscriptionsv2.get を主経路にする
  • RTDN はトリガとして受ける
  • RTDN を受けたら必ず Developer API を呼ぶ
  • new purchase token を伴う purchase は acknowledgement を必ず行う
  • 購入時に obfuscatedAccountId を設定する

10-2. purchases.subscriptionsv2.get を主経路にする理由

Google の一次情報では、バックエンド状態更新のために
RTDN の purchase token を使って purchases.subscriptionsv2.get を呼ぶよう案内しています。
さらに、purchases.subscriptions.get は新規統合での主経路としては非推奨です。

10-3. 新規購入フロー

sequenceDiagram
    participant App as Android App
    participant Play as Google Play
    participant BE as Backend

    App->>BE: Get current accountToken before purchase
    BE-->>App: accountToken
    App->>Play: Launch billing flow with obfuscatedAccountId
    Play-->>App: purchaseToken
    App->>BE: POST /billing/google/ingest (authenticated request + purchaseToken)
    BE->>Play: purchases.subscriptionsv2.get
    BE->>BE: Compare obfuscatedExternalAccountId and contract lineage
    BE->>BE: Project Subscription / Entitlement / History
    BE->>Play: purchases.subscriptions.acknowledge
    BE-->>App: projected result

10-4. RTDN の理解

RTDN は「完全な purchase state」ではありません。
通知には、主に次のようなものが入ります。

  • packageName
  • eventTimeMillis
  • subscriptionNotification.notificationType
  • purchaseToken

ここに完全な状態は入っていません。
したがって、RTDN を受けた後は必ず purchases.subscriptionsv2.get を呼びます。

10-4-1. Pub/Sub は push / pull のどちらでもよい

Google Play の RTDN は Pub/Sub topic に publish され、その先の subscription は push / pull のどちらでも構成可能です。基本方針は 3-2. Google 側の準備 で述べたとおりで、本書では push subscription を前提に説明します。

pull subscription を採る場合は、サーバ側で常駐 worker を動かし、Pub/Sub からメッセージを取得して処理します。この場合、Webhook endpoint の代わりに subscriber worker が RTDN 受信口になります。

10-4-2. 本書が push 前提で説明する理由

RTDN の受信方式に迷う場合は、3-2. Google 側の準備 で述べたとおり push を前提にすると整理しやすいです。
そのため、本書でも初回リリース向けの分かりやすさを優先し、次の前提で説明します。

  • Pub/Sub topic を作る
  • push subscription を作る
  • /webhooks/google/rtdn で受ける
  • 認証済みリクエストとして正しく受け付けられることを確認する
  • purchaseToken を使って purchases.subscriptionsv2.get を呼ぶ

10-5. purchase token の扱い

Google の一次情報では、purchaseToken
購読開始から、失効後 60 日まで Developer API に使えると案内されています。

最低限は次の 3 点です。

  • 現在の purchaseToken を保存する
  • linkedPurchaseToken があれば旧契約と結び付ける
  • トークンは差し替わり得る前提で設計する

linkedPurchaseToken が重要になる代表例は、次のケースです。

  • re-signup(canceled but non-lapsed subscription、つまり解約済みだが有効期限がまだ到来していない購読への再加入)
  • upgrade
  • downgrade
  • auto-renewing と prepaid の相互変換
  • prepaid top-up

一方で、前回購読がすでに期限切れになった後の再購読linkedPurchaseToken ではなく、
outOfAppPurchaseContext.expiredPurchaseToken
outOfAppPurchaseContext.expiredExternalAccountIdentifiers 側で扱います。

prepaid は本ドキュメントの主対象外です。ここでの記述は、将来拡張を見据えた参考情報です。
outOfAppPurchaseContext も、初回実装では「見つかれば補助手掛かりに使う」程度で十分です。

10-6. acknowledgement

Google で最重要の運用要件の 1 つです。

acknowledgement が必要な purchase

acknowledgement が必要なのは、new purchase token を伴う purchase です。
これには、少なくとも次が含まれます。

  • 初回購入
  • plan change
  • re-signup
  • prepaid top-up(本ドキュメントでは参考情報)

これらの purchase は、purchases.subscriptionsv2.get の結果として entitlement 付与可能な状態になってから 3 日以内に acknowledgement が必要です。
実装では、典型的には subscriptionStateSUBSCRIPTION_STATE_ACTIVE などの付与可能状態で、かつ acknowledgementStateACKNOWLEDGEMENT_STATE_PENDING の purchase を ack 対象として扱います。
PENDING の間は acknowledge してはいけません。
Google Play の 3 日ルールは、保留中の購入が最終的に課金成立した時点で開始します。
怠ると、Google Play は自動返金・取り消しを行います。

更新

renewal は acknowledgement 不要です。

実行条件

Google では、purchases.subscriptionsv2.get の結果で entitlement 付与可能な subscriptionState であり、かつその purchase の acknowledgementStateACKNOWLEDGEMENT_STATE_PENDING の場合に acknowledgement を行います。

実装上の原則

  • entitlement 付与と acknowledgement は同じユースケース内で扱う
  • acknowledgement は entitlement 付与後に行う
  • ただし片方だけ失敗する可能性を前提に再試行手段を持つ
  • 未 ack を検知するジョブを持つ

10-7. 支払い問題と猶予期間

Google では、状態遷移を次のように扱います。

  • SUBSCRIPTION_STATE_ACTIVE
  • SUBSCRIPTION_STATE_IN_GRACE_PERIOD
  • account hold 相当
  • SUBSCRIPTION_STATE_EXPIRED

一次情報では、grace period 中は entitlement を維持すべきとされています。
一方で account hold に入った後は、利用を止める前提で設計します。

加えて、現行仕様では grace period は default enabledaccount hold も default enabled です。account hold の既定長は 2025-12-01 以降は自動計算となり、初期値は 60 days - grace period duration です。したがって、実装では「30 日固定」などの前提を埋め込まず、purchases.subscriptionsv2.get の返却状態を真実の源泉にします。

10-8. テスト購入の識別

purchases.subscriptionsv2.get のレスポンスには testPurchase が含まれ得ます。
テスト環境では、これを監査ログに残すようにしておくと原因調査がしやすくなります。

10-9. RTDN のテスト通知

RTDN には testNotification があります。
これは Google Play Console から送られる疎通確認用通知です。

10-10. Google の初回リリースでの実践的な結論

4 章の方針に沿い、Google 側では次の 6 点を成立させます。

  1. 購入時に setObfuscatedAccountId を設定する
  2. クライアントから purchaseToken を送る
  3. サーバで purchases.subscriptionsv2.get を呼ぶ
  4. new purchase token を伴う未 acknowledgement purchase なら acknowledgement する
  5. RTDN を受ける
  6. RTDN 受信後にも purchases.subscriptionsv2.get を呼ぶ

この章の一次情報


11. 状態管理と Entitlement 判定

11-0. Subscription と Entitlement の責務分離

課金設計では、ストア上の契約事実自社サービスでの利用可否を同じ列や同じテーブルで表そうとすると、意味がすぐにぶれます。
本ドキュメントで SubscriptionEntitlement を分けているのは、テーブルを増やすこと自体が目的ではありません。ストア事実と利用判定の責務を混ぜないためです。

  • Subscription は、ストア上の契約状態の最新像を保持するテーブルである。Apple / Google が返す契約事実を内部正規化して保存する責務を持つ。
  • Entitlement は、自社サービスで今そのユーザーにそのプランを使わせてよいかを保持するテーブルである。クライアントや画面 API が参照する利用可否の read model / projectionとして扱う。

この分離が必要なのは、契約状態とアクセス可否が常に一致するわけではないからです。一次情報上も、Apple の Billing Grace Period 中は購読者に有料コンテンツへのアクセスを継続させる前提で説明されており、Google でも Cancelled は期限まではアクセス継続、In grace period はアクセス継続、On hold はアクセス停止として案内されています。
したがって、Subscription.status だけで利用可否まで表そうとすると、ストアの事実自社サービスの最終判定が 1 つの概念に混ざります。

本書では、この混線を避けるために役割を明確に分けます。

  • Subscription契約状態の保持
  • Entitlement利用可否の提供

SubscriptionisActive / reason を統合しない理由

SubscriptionEntitlement.isActiveEntitlement.reason をそのまま統合すると、一見テーブルを減らせるように見えます。
ただし、それは単なるテーブル統合ではなく、契約事実テーブルと利用判定テーブルの責務混在です。

  • isActivereason は、ストアが返す生の契約事実ではなく、契約状態を自社サービスの利用可否へ投影した結果である。
  • SubscriptionisActive / reason を入れると、1 レコードの中に
    • ストア状態の記録
    • 利用可否の最終判定
    • クライアント向けの説明理由 が混在し、テーブルの意味が不安定になります。
  • その結果、status を見るべきか、isActive を見るべきか、reason を見るべきかが曖昧になり、実装や運用で解釈がぶれやすくなる。
  • とくに、GET /me/entitlement のような read model と、Subscription のような契約状態保存テーブルの責務が混線する。
  • reasonEntitlementReason として利用可否の説明用に定義するものであり、Subscription.status と同列の概念ではない。

補足: SubscriptionEntitlement は厳密な 1:1 前提にしない

初回リリースの実データでは、SubscriptionEntitlement見かけ上ほぼ 1:1 に近く見えることはあります。
ただし、概念上も将来拡張上も、これを厳密な 1:1として固定する説明は避けます。

  • Google の purchases.subscriptionsv2lineItems[] を持ち、purchase 単位と item 単位が分かれている。
  • さらに Google の subscription with add-ons では、1 回の purchase に複数 item を含められる。
  • Apple では auto-renewable subscription に Family Sharing を有効化でき、1 つの購読から複数家族メンバーへアクセス権が広がりうる構成がある。

もちろん、本ドキュメントの主対象は単一ユーザー向けの自動更新サブスクの基本設計であり、Family Sharing 最適化や add-ons 詳細を本文の主対象にはしません。
それでも、モデルの説明としては 「MVP では 1:1 に近く見えるが、概念上は 1:1 と断定しない」 という書き方にしておくほうが、一次情報や将来拡張と齟齬を起こしにくくなります。

参考一次情報:

11-1. まず internal state を決める

初回リリースでは、Apple / Google を無理に完全統一しようとせず、
次の内部状態を定義すると理解しやすくなります。

Internal State意味Entitlement
PENDING購入保留 / 支払い未確定原則 false
ACTIVE正常利用中true
IN_GRACE_PERIOD支払い問題だが猶予中true
BILLING_RETRY主に Apple の支払い再試行中。grace period は終わっている、または有効ではないfalse
ON_HOLDGoogle の account hold による停止false
PAUSED一時停止中false
CANCELED自動更新オフ、ただし有効期限前true
EXPIRED期限切れfalse
REVOKED返金・取り消しfalse

BILLING_RETRY は内部正規化状態です。Apple では App Store Server API の billing retry state(status = 3)や isInBillingRetryPeriod に対応する停止側の状態として使います。Billing Grace Period(status = 4)とは別状態です。
ON_HOLD は内部正規化状態です。Google では account hold に対応します。Apple には ON_HOLD という公式状態名はありません。本書では、Apple の billing retry と Google の account hold は別状態として扱う方針を採ります。
Apple の公式状態や通知名をより生の形で保存したい場合は、statusReason や補助フラグを別に持つ設計でも構いません。

11-2. 初回リリースでの既定方針

迷った場合は、次の既定方針を採ります。

ケース利用可否理由
新規購入直後で検証成功true付与してよい
Google PENDINGfalse決済未確定
Google grace periodtrue一次情報に沿う
Google account holdfalseON_HOLD として扱い、支払い未回復のため停止する
自動更新オフだが期限前trueまだ契約期間内
返金 / revokefalse直ちに停止
Apple Billing Grace Period 中true一次情報に沿って利用継続とする
Apple billing retry(grace 終了後 / grace 無効)falseBILLING_RETRY として扱い、利用停止とする

11-3. 返金の考え方

返金は「将来更新しない」ではなく、
すでに付与した権利を止めるべきイベントです。

そのため、CANCELEDREVOKED は分けます。

  • CANCELED: 期限までは有効
  • REVOKED: 即時停止もあり得る

11-4. 無料トライアルと初回無料期間の考え方

初回契約時に無料期間を自動付与する とは、バックエンドが任意に無料権利を作ることではありません。
Apple では App Store Connect の introductory offer、Google では Play Console の subscription offer / free trial を設定し、ストアが無料期間付き purchase として成立させた結果を通常どおり検証・反映します。

そのため初回リリースでは次を押さえます。

  • 無料期間の付与条件は ストア設定で表現する
  • クライアントは ストアから取得した offer 情報を表示し、選択された offer で購入を開始する
  • バックエンドは 無料購入だから特別扱いするのではなく、通常の purchase / transaction と同じ入口で検証し、TRIALING または ACTIVE に投影する

Apple では introductory offer は subscription product ごとに設定できますが、1 人が redeem できる introductory offer は subscription group ごとに 1 回です。Google では auto-renewing subscription の offers で free trial や introductory price を設定でき、eligibility は Google Play 管理またはアプリ実行時判定を使えます。

11-5. トライアル利用歴あり / なしの判定

要件として トライアル利用歴あり / なし が必要なら、現在トライアル中かとは別に、過去に一度でも trial を開始したことがあるかを管理します。

実務上は次の 2 層で考えるのが安全です。

  1. 購入 UI での eligibility 判定
    ストアが真実の源泉です。Apple では Product.SubscriptionInfo.isEligibleForIntroOffer を使えます。Google では Play 管理 eligibility、または app 実行時 eligibility を使い、購入可能な offer を ProductDetails から選びます。
    これは UI 上でどの offer を見せるかの判断材料 です。購入可否や entitlement の最終判断そのものを、自社だけで確定するための値ではありません。
  2. 自社要件としての履歴管理
    CS、分析、A/B テスト、既存会員向け文言分岐のために、自社 DB に hasStartedTrialAtLeastOncefirstTrialStartedAt を持つ価値があります。

実装上は、初めて無料トライアル付き購読を正しく検証できた時点で、自社側の trial 利用履歴を true にします。
ただし、購入可否の最終判断は常にストア側を優先してください。自社 DB が false でも、ストアが non-eligible と判定したら trial は出してはいけません。

11-6. 月額をどこから取得するか

課金画面に表示する価格は、クライアントがストア SDK から取得するのを基本にします。
バックエンド DB の PlanStoreProductMapping は商品対応表であり、ローカライズ済み表示価格の真実の源泉にはしません。

  • Apple: StoreKit の Product.displayPrice
  • Google: queryProductDetailsAsync() で取得した ProductDetailssubscriptionOfferDetails / PricingPhase.getFormattedPrice() / getBillingPeriod()

バックエンド API が価格文字列を返す場合も、それは表示用キャッシュや補助情報にとどめ、真実の源泉はあくまでストア SDK から取得した価格 とします。

月額プランであれば、Apple / Google ともに取得した価格をそのまま 月額 として扱えます。
一方、年額や 3 か月プランの 月あたり は、billingPeriod と価格から UI 側で換算した参考値であり、ストアが「月額そのもの」を返すわけではありません。実際の請求単位と金額を分かる形で併記してください。

また、通常価格の源泉と、free trial / introductory offer / offer の表示は別の関心事です。購入 UI では、ストア SDK から取得した通常価格に加えて、選択中の offer 内容も分かるように表示してください。

11-7. 支払い情報更新要件を実装上どう扱うか

要件でいう 支払い情報の更新を検知する は、実装上は
支払い情報が更新された事実そのものより、更新の結果として購読が回復したことを検知する
と定義して扱うのが安全です。

  • Apple: billing retry / Billing Grace Period 中にユーザーが支払い情報を更新して回復したことは、DID_RECOVER または DID_RENEW(subtype: BILLING_RECOVERY)を受けたうえで、App Store Server API の再照会結果とあわせて把握する。どちらか一方だけを唯一の正解として決め打ちせず、回復系通知は両方を処理対象にする
  • Google: ユーザーが支払い方法を修正して回復した場合、grace period 中の回復は SUBSCRIPTION_RENEWED として扱われることがある。また、account hold / pause からの回復では SUBSCRIPTION_RECOVERED を受ける。RTDN のイベント名を 1 つに決め打ちせず、purchases.subscriptionsv2.get の結果を再投影する

つまり、payment method edited のような支払い手段の編集事実そのものを直接保証する専用イベントを待つのではなく、回復イベントと最新状態再照会で要件を満たすのが現実的です。要件定義や要件表にも、可能なら 支払い情報更新そのものの検知 ではなく 支払い問題からの回復検知 と記載しておくと、実装と仕様の齟齬が起きにくくなります。
また、Google は In-App Messaging API で支払い修正導線を出せるため、grace / hold 中の離脱抑制にも使えます。


12. 購入復元と例外ケース

復元は「ボタン 1 つ」では済みません。
本質は、既存のストア契約をどの内部ユーザーへ安全に結び付けるかです。

12-1. 復元の位置づけ

復元は、毎回の通常利用で必須となる操作ではありません。
通常利用時の正規ルートは、ログイン中ユーザーに紐づく Entitlement をサーバから取得することです。

このとき Entitlement が表しているのは、

  • この端末が購入したかどうか
  • このストアアカウントが今この端末に存在するかどうか

ではなく、

  • この内部ユーザーが現在使ってよいかどうか

です。

そのため、ある端末で購入が行われ、その後に購入情報反映 API や Webhook によって自社 DB の Subscription / Entitlement が正しく更新されていれば、別端末で復元していなくても、同じ内部ユーザーとしてログインした時点で利用可否を取得できてよいです。

つまり、「復元しなくても使えてしまう」のではなく、「その購入がすでにそのユーザーへ反映済みなら、復元なしで使えるのが正しい」 と考えます。

12-2. 復元時に購入情報反映 API を呼ぶ理由

復元時にも購入情報反映 API を呼ぶのは、単にアプリが購入証跡を見つけただけでは不十分で、バックエンド側で再照合と状態反映が必要だからです。

主な理由は次のとおりです。

  • アプリが取得した購入証跡をそのまま信用せず、サーバ側でストア再照会するため
  • その購入を現在ログイン中の内部ユーザーに結び付けてよいかを判定するため
  • SubscriptionEntitlement などの DB 状態を正しく更新するため
  • 初回購入と復元で検証ロジックを分けず、共通の入口として扱うため

つまり、復元時の購入情報反映 API は、ストア上の購入事実を自社システム上の利用権へ変換するための処理です。

12-3. 復元が必要なケースと不要なケース

復元が必要になるのは、主にストア上には購入があるのに、自社 DB にまだ正しく反映されていない場合です。

たとえば次のようなケースです。

  • 購入直後に購入情報反映 API の呼び出しが失敗した
  • Webhook 通知が未到達、未処理、欠落した
  • 再インストール後にストア上の既存購入を再検出したい
  • 以前の購入が内部ユーザーへまだ安全に紐付いていない

一方で、次のような場合は、復元を毎回要求する必要はありません。

  • すでに自社 DB 上でそのユーザーの Entitlement が有効になっている
  • アプリ起動時や画面表示時にサーバから利用可否を取得できている
  • その結果として、別端末でも同じ内部ユーザーとして自然に有料機能を使える

つまり復元は、有料機能を使うための毎回必須操作ではなく、未反映・未紐付け・取りこぼしを救済するための再同期導線です。

12-4. 復元の基本方針

  • 復元専用の別ロジックは作らず、/billing/*/ingestaction=restore を渡す
  • 検証・投影の本体は新規購入と共通にする
  • 違うのは紐付けルールだけにする

12-5. 復元時の判断表

状況推奨動作
購入時の識別子と現在ログイン中ユーザーが一致自動復元
既知の元契約と同じ内部アカウントへ到達できる自動復元
別の内部アカウントに既に紐付いている自動復元しない
自動判定に十分な根拠がない手動確認
未ログイン仮保持のみ

12-6. 未ログイン購入

未ログインでも購入できる UX にする場合、バックエンド設計を明示してください。

推奨:

  • 一時的な UnclaimedPurchase を持つ
  • ここには store 側識別子だけを保存する
  • ログイン完了時に確定紐付けする
  • 確定できるまで Entitlement を付与しないか、限定的に付与する方針を決める

12-7. 別アカウント誤復元を防ぐ

最も危険なのは、端末を共有しているケースや、
ログインし直した別ユーザーに既存購入を付けてしまうケースです。

防止策:

  • appAccountToken / obfuscatedAccountId を必須化する
  • 復元時は既存系列との一致を優先する
  • あいまいな場合は 409 で止める
  • CS / 管理画面の手動救済フローを用意する

12-8. 返すべきエラー例

{
  "code": "RESTORE_REQUIRES_MANUAL_REVIEW",
  "message": "This purchase is already associated with another account."
}

12-9. クライアント側の復元導線

復元要件は、サーバの restore 設計クライアントの導線の両方があって初めて満たせます。

  • Apple
    アプリには Restore Purchases の導線を置き、StoreKit 2 では AppStore.sync() を呼びます。これにより App Store と購入状態を同期し、取得できた transaction / entitlement を通常どおりサーバへ送って再照合します。

  • Google
    専用の restorePurchases() のような OS API というより、BillingClient.queryPurchasesAsync() で既存購入を再同期する考え方です。接続成功時、アプリ起動時、foreground 復帰時、必要ならユーザーの明示操作時にも呼び、返ってきた purchaseToken をサーバへ送って再照合します。

    ただし、クライアント再同期だけで復元や現在状態の把握が完結するわけではありません。現行の Billing Library では、suspended subscription は includeSuspendedSubscriptions を有効にした場合に取得できることがありますが、呼び方や状態に依存します。さらに、canceled but not expired の購読が取得されることもあるため、クライアント結果だけで entitlement を確定してはいけません。

    Google では、ユーザーが期限前に Play の subscriptions center などから購読を再開すると SUBSCRIPTION_RESTARTED が通知されることがあり、この場合は既存の purchaseToken が継続して使われます。したがって、Google 側の最終状態は、RTDN を受けたうえでバックエンドが purchases.subscriptionsv2.get を再照会して判断します。復元導線の前提は 3-2、通知経由の追従は 10-4 も参照してください。

どちらのプラットフォームでも、クライアントが見つけた購入をそのまま信用して権利を付与しないことが重要です。最終判断は必ずバックエンドで再照会して行います。


13. テスト戦略

初学者にとっては、テストを層ごとではなく目的ごとに理解したほうが分かりやすいです。

13-1. 実装時の動作確認ガイド

この節は、CI や自動テストの設計ではなく、実装中のバックエンドを手元で確かめるための手順をまとめたものです。
「コードを書いたらまず何を叩いて確認するか」を迷わないように、Apple / Google 共通で最小限の確認手順を整理します。

13-1-1. 実課金は発生するか

通常の実装・動作確認では、次の環境を使います。

ストアテスト環境実課金備考
AppleSandboxなしSandbox テスターで購入しても実際の決済は行われない
AppleStoreKit Testing in Xcodeなしローカル完結。ストアサーバ疎通の確認には使えない
Googleライセンステスターなしテスト用支払い方法で確認できる
GooglePlay Billing Lab で real payment を有効化した場合あり通常の実装確認では使わない

したがって、通常の実装段階では Apple SandboxGoogle ライセンステスター を前提に動作確認します。

13-1-2. 購入証跡の検証エンドポイントの動作確認

まず確認したいのは、/billing/apple/ingest/billing/google/ingest が、実際の Sandbox / ライセンステスター由来の証跡で正しく動くことです。

Apple の場合

  1. Sandbox テスターでアプリから購入する
  2. クライアントが受け取った transactionId を控える
  3. /billing/apple/ingest を curl / Postman で呼ぶ
curl -X POST http://localhost:3000/billing/apple/ingest \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <user-access-token>" \
  -H "Idempotency-Key: apple-test-$(date +%s)" \
  -d '{
    "action": "purchase",
    "transactionId": "<sandbox-transaction-id>"
  }'

Google の場合

  1. ライセンステスターでアプリから購入する
  2. クライアントが受け取った purchaseToken を控える
  3. /billing/google/ingest を curl / Postman で呼ぶ
curl -X POST http://localhost:3000/billing/google/ingest \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <user-access-token>" \
  -H "Idempotency-Key: google-test-$(date +%s)" \
  -d '{
    "action": "purchase",
    "purchaseToken": "<license-tester-purchase-token>"
  }'

この確認では、ダミーの token / ID ではなく、実際にストアから払い出された証跡を使うことが重要です。

13-1-3. ストア API の直接呼び出し確認

バックエンド実装の初期段階では、アプリや自社 API を挟まず、ストア API 自体を直接呼んで疎通確認すると切り分けが楽になります。

Apple App Store Server API の確認例

curl -X GET \
  -H "Authorization: Bearer <app-store-server-api-jwt>" \
  "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/transactions/<transactionId>"

Google purchases.subscriptionsv2.get の確認例

curl -X GET \
  -H "Authorization: Bearer <oauth-access-token>" \
  "https://androidpublisher.googleapis.com/androidpublisher/v3/applications/<packageName>/purchases/subscriptionsv2/tokens/<purchaseToken>"

ここで確認したいのは、主に次の 3 点です。

  • 認証情報の生成方法が正しいか
  • 対象環境(Sandbox / Production、テスト用 packageName など)が正しいか
  • レスポンスを自分の想定どおりに解釈できるか

13-1-4. Webhook / RTDN の動作確認

Webhook / RTDN は、ローカルでの疑似 POST実通知の受信確認 を分けて考えると分かりやすいです。

Apple 通知のローカル確認例

curl -X POST http://localhost:3000/webhooks/apple/notifications \
  -H "Content-Type: application/json" \
  -d '{"signedPayload":"<test-or-fixture-jws>"}'

Google RTDN のローカル確認例

curl -X POST http://localhost:3000/webhooks/google/rtdn \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <test-or-bypass-token>" \
  -d '{
    "message": {
      "data": "<base64-encoded-rtdn-json>",
      "messageId": "test-message-001"
    },
    "subscription": "projects/example/subscriptions/rtdn-sub"
  }'

この Authorization はローカル確認用の例です。
本番相当環境では、Pub/Sub が付与する JWT を Authorization ヘッダで検証する前提で確認します。

そのうえで、外部公開環境では次も確認します。

  • Apple の Request a Test NotificationGet Test Notification Status
  • Sandbox / TestFlight 購入時の実通知受信
  • Pub/Sub push subscription からの実 RTDN 受信

13-1-5. 実装中の注意事項

  • 自作のダミー token / ID ではなく、実際の Sandbox / ライセンステスター由来の証跡で確認する
  • Apple の通知は Apple の秘密鍵で署名されるため、自作 JWS を本番用の署名検証ロジックに通すことはできない
  • 署名検証バイパスや認証バイパスを許すとしても、ローカル確認限定にし、共有の開発 / ステージング環境では有効化しない
  • Google のライセンステスター購入は、未 acknowledgement のままだと約 3 分で返金される
  • ingest と Webhook / RTDN は、どちらが先に届いても最終状態が壊れないことを早い段階で手動確認する
  • 同じ Idempotency-Key、同じ notificationUUID、同じ messageId を 2 回送って、冪等性が壊れないことを確認する

13-1-6. 無料トライアル・復元・猶予期間設定の確認

要件に次が含まれるなら、通常の購入成功テストとは別に確認します。

  • 無料トライアル
    Apple では introductory offer が対象ユーザーに出るか、Google では対象 offer が購入 UI に出るかを確認します。無料期間付き購入が成立したら、バックエンドで TRIALING または同等状態に投影できること、自社側の trial history が初回だけ更新されることを確認します。
  • 購入復元
    Apple は Restore Purchases 導線から AppStore.sync() を呼び、Google は queryPurchasesAsync() で既存購入を拾ってサーバ再照合できることを確認します。Google 側の確認観点は 12-9. クライアント側の復元導線 で整理したとおり、queryPurchasesAsync() の結果だけに依存せず、RTDN と purchases.subscriptionsv2.get 再照会でも状態追従できることまで含めます。別アカウント誤復元が起きないことも要確認です。
  • 猶予期間
    Apple は App Store Connect で Billing Grace Period をまず Sandbox で有効化してから試験し、DID_FAIL_TO_RENEW(subtype: GRACE_PERIOD)で猶予期間入りを確認し、必要に応じて GRACE_PERIOD_EXPIRED 後に billing retry へ移行したことを再照会で確認します。支払い修正後の回復は DID_RECOVER または DID_RENEW(subtype: BILLING_RECOVERY)と再照会結果で確認します。Google は Play Console の grace period / account hold 設定を確認してから試験し、grace period 中は SUBSCRIPTION_IN_GRACE_PERIOD、silent grace period 終了後は SUBSCRIPTION_ON_HOLD / SUBSCRIPTION_CANCELED / SUBSCRIPTION_EXPIRED / SUBSCRIPTION_RENEWED などの結果状態が見えることを確認します。

13-2. テストは 4 段階で考える

段階何を見るか
単体テスト状態マッピングと冪等性
ストア結合テスト実際の購入・更新・復元
通知結合テストWebhook / RTDN の受信と再照会
補正テスト再送・順序逆転・失敗回復

13-3. Apple の最小 E2E 手順

  1. Sandbox テスターを準備する
  2. 実機または TestFlight で商品取得を確認する
  3. 購入時に appAccountToken を渡す
  4. 購入する
  5. transactionId/billing/apple/ingest へ送る
  6. Subscription / Entitlement が更新されることを確認する
  7. 通知を受け取れることを確認する
  8. 復元を実行し、二重付与されないことを確認する

Apple テストで覚えておくべきこと

  • Sandbox では本番課金は発生しない
  • TestFlight の IAP も sandbox で動く
  • Sandbox アカウントの subscription renewal rate によって、更新間隔だけでなく Billing RetryBilling Grace Period の長さも変わる
  • そのため、支払い失敗や猶予期間を試すときは、Sandbox Apple Account の設定を先に確認する
  • TestFlight ではサブスク更新は加速され、各期間のサブスクは日次で最大 6 回更新される
  • 通知疎通は Request a Test NotificationGet Test Notification Status で確認できる

13-4. Google の最小 E2E 手順

  1. ライセンステスターを設定する
  2. 必要に応じて internal / closed などのテストトラックへ出す
  3. 商品が publish 済みであることを確認する
  4. 購入時に setObfuscatedAccountId を設定する
  5. 購入する
  6. purchaseToken/billing/google/ingest へ送る
  7. purchases.subscriptionsv2.get の結果で反映されることを確認する
  8. acknowledgement が成功することを確認する
  9. RTDN を受け取れることを確認する

Google テストで覚えておくべきこと

  • ライセンステスターは無料でテスト購入できる
  • Play Console に対象アプリが存在し、パッケージ名が一致している必要がある
  • ライセンステスターは sideload でもテストできるが、QA には internal / closed track が便利
  • pending purchase は entitlement を付けてはいけない
  • ライセンステスターの購入は、未 acknowledgement のままだと約 3 分で返金される
  • そのため、acknowledgement 実装の確認は通常購入テストとは別に必ず行う
  • 支払い回復の確認では、grace period 中の回復で SUBSCRIPTION_RENEWED が観測されうることを確認する
  • さらに、account hold / pause からの回復で SUBSCRIPTION_RECOVERED が観測されうることを確認する
  • 期限前に Play の subscriptions center などから再開した場合、SUBSCRIPTION_RESTARTED が観測されうることを確認する
  • いずれのケースでも、RTDN のイベント名だけで確定せず、purchases.subscriptionsv2.get の最新状態を最終判断に使う
  • Play Billing Lab を使うと price change などのテストがしやすい

13-5. 必ずやるべき異常系テスト

  • クライアント送信前に通知が先着する
  • 同じ ingest を 2 回送る
  • 同じ RTDN / Apple 通知が再送される
  • DB 更新前に acknowledgement だけ成功する
  • DB 更新後に acknowledgement が失敗する
  • 復元対象が別アカウントに紐付いている
  • pending purchase が最終的に成功 / 失敗に変わる
  • grace period 中の利用継続
  • revoke 後の即時停止

13-6. 環境分離マトリクス

項目ローカル開発 / ステージング本番
Apple API 資格情報sandbox 中心sandbox 中心production
Apple signed data verifier 設定sandbox 想定sandbox 想定production 想定
Apple 通知 URLテスト用stagingproduction
Google API 資格情報テスト用プロジェクトstagingproduction
RTDN topic / push sub分離分離分離
署名検証バイパス条件付きで許容(ローカル確認限定)禁止禁止
テスト購入判定の監査ログ有効有効有効

13-7. 単体テストで固定すべきもの

初回リリースで最も価値が高い単体テストは次です。

  • ストア状態 → internal state の変換
  • internal state → entitlement の変換
  • 同一イベント再処理時の冪等性
  • linked token / original transaction の紐付け分岐
  • 復元時の 409 / 手動確認分岐

この章の一次情報


14. 運用・監視

課金は「作って終わり」ではありません。
正常時より、失敗時にどう戻すか異常をどう早く検知するか が重要です。

補正ジョブや再照合の詳細は 26 章、管理画面の権限設計は 29 章、ログ・監査・保持方針は 30 章で扱います。
この章では、運用として最低限見える状態にしておくべきもの を、概要レベルで整理します。

14-1. 最低限見るべき運用観点

  • Apple 通知 / Google RTDN を継続受信できていること
  • 受信後の非同期投影が滞留していないこと
  • 定期ジョブが予定どおり動いていること
  • PENDING / IN_GRACE_PERIOD / BILLING_RETRY / ON_HOLD が異常に滞留していないこと
  • CS や運用者が必要時に手動再照合できること

14-2. 監視すべきメトリクス

  • 新規購入数
  • 復元成功数
  • PENDING 滞留数
  • IN_GRACE_PERIOD 滞留数
  • BILLING_RETRY 件数
  • ON_HOLD 件数
  • Apple の通知受信数 / 失敗数
  • Google RTDN の通知受信数 / 失敗数
  • 未 ack 件数
  • 反映遅延時間
  • 409 復元エラー件数
  • 失敗イベント再処理の滞留数
  • 補正ジョブの最終成功時刻

14-3. アラート条件の例

  • 未 ack 件数 > 0 が継続
  • Webhook 5xx 増加
  • RTDN 受信が急減
  • 購入は増えているのに Entitlement 有効化が減少
  • 返金 / revoke 件数が急増
  • 409 復元エラーが急増
  • バッチの最終成功時刻がしきい値を超えて古い
  • processingError が一定件数を超えて滞留

14-4. ログに残すべき情報

詳細な保持項目や監査対象は 30 章で扱います。ここでは、最低限次が追えることを重視します。

  • どの provider / environment のイベントか
  • どの transactionId / purchaseToken / originalTransactionId に関する処理か
  • どの notificationUUID / messageId / request id に対応するか
  • どの internal user / billing account に投影されたか
  • projection / acknowledgement / 再試行の結果がどうだったか
  • raw payload の保存先参照と最終エラーが何か

14-5. 管理画面で最低限見たい情報

詳細な表示項目や権限制御は 29 章で扱います。ここでは、最低限次が確認できることを重視します。

  • 現在の Subscription / Entitlement 状態
  • 元のストア識別子と直近イベント
  • 直近ジョブ実行結果と最終再照合時刻
  • 再照合や再投影の実行導線
  • 手動判断が必要なケースを見分けるための履歴

15. 初回リリース時の推奨事項

1 章〜14 章で扱った内容を、初回リリースで守るべき点に絞ってまとめます。

今回の MUST は、実装の好みではなく、本ドキュメントで扱う最低要件を満たすために外せない条件 を指します。
一方、SHOULD は品質・運用性・実装の見通しを大きく改善するが、同じ要件を別の手段でも満たし得る項目 を指します。

MUST

  • ストアを真実の源泉とする
  • SubscriptionEntitlement を分ける
  • 履歴テーブルを持つ
  • Apple は appAccountToken を入れる
  • Google は setObfuscatedAccountId を入れる
  • Apple では introductory offer / free trial を、Google では subscription offer / free trial を要件に応じて設定する
  • Apple では Billing Grace Period を、Google では grace period / account hold を要件に応じて設定する
  • 本プロジェクトでは Google の grace period を 3 日で設定する
  • Apple は App Store Server Notifications V2 を使う
  • Google は RTDN を使う
  • Google の RTDN 受信方式として push / pull のどちらを採るかを決める
  • Google の RTDN 受信経路を本番運用できる状態にする
  • Google の new purchase token を伴う未 acknowledgement purchase を acknowledgement する
  • Webhook / RTDN を冪等に処理する
  • 通知欠落補正ジョブを持つ
  • クライアントでストア価格を取得して表示できるようにする
  • クライアントで復元導線を提供する
  • トライアル利用歴を判定できる永続的根拠を持つ
  • 復元時に別アカウント誤付与を防ぐ
  • テスト環境で end-to-end を通す

SHOULD

  • 監査ログを保存する
  • クライアントが参照する利用可否 read model を 1 か所に寄せる
  • テスト通知を CI / 運用確認に組み込む

初回リリースでは後回しにしてよいもの

  • 高度な price change UX
  • 複雑な offer 戦略
  • 会計リコンシリエーションの自動化
  • Web 決済との高度な併用
  • 管理画面の細かい運用補助機能

最終結論

購入証跡をサーバで再検証し、ストア状態を Subscription / Entitlement / 履歴へ冪等に投影し、通知と補正ジョブで追従できること。