メニューを開く

テーマ

このページの内容

ドキュメント

Part 2. 実装詳細編

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

Part 2. 実装詳細編

16. DB 設計詳細

初回実装では概念を増やしすぎず、後から 再購読、補正ジョブ、監査、調査、価格変更 を足せる構成にします。
この章では、初回リリースで採用する コアテーブル を先に整理し、その後に各テーブルで主に持つカラムを説明します。
また、初回リリースでは必須ではないものの、運用・監視・障害復旧で有用な 運用補助テーブル も最後に簡潔に紹介します。

16-1. 設計原則

DB 設計では、次の原則を守ると破綻しにくくなります。

  • ストア状態は投影結果として保持する
  • 契約状態と利用可否を分離する
  • 履歴を捨てない
  • 業務キーと表示用情報を分離する
  • Apple と Google の外部識別子を無理に同一列へ押し込まない
  • 冪等更新に必要な一意制約を先に決める
  • Google の purchase token は current 値だけでなく履歴も意識する

16-2. この章での位置づけ

この章は、6 章で整理した最小ドメインモデルを DB テーブルへ具体化したもの です。
概念としては同じでも、物理設計では永続化・一意制約・再処理・監査の都合で、複数テーブルへ分解したほうが安全な場合があります。

この章で扱う 8 テーブルは、初回リリースで課金機能を安定して成立させるための コアテーブル です。
いずれも商品マッピング、課金主体、契約状態、利用可否、履歴、通知監査、再紐付けを支えるため、初回リリースでは実質的に必須です。

一方で、管理・調査、手動復旧、監視のための補助テーブルは、初回リリースでは必須ではありません。
これらは後述する 運用補助テーブル として扱います。

なお、各テーブルの説明では、カラムを 1 つの表に統合し、MUST / SHOULD 列で初回リリース時点の設計上の優先度を示します。
ここでいう MUST / SHOULD は、そのまま NOT NULL / NULL を意味するものではありません。
MUST は「初回リリースで原則として保持・更新できるように設計すべき項目」、SHOULD は「初回リリースでは省略可能だが、運用性・調査性・将来拡張のために持つ価値が高い項目」を表します。
そのため、ストア差分や状態差分により、MUST の列であっても実装上は NULL を許容したほうがよい場合があります。たとえば Apple 専用・Google 専用の識別子や、通知内容によっては常に得られるとは限らない補助識別子は、設計上は重要でも NULL 許容になることがあります。

つまり、この章での MUST / SHOULD は、DB 制約そのものではなく、初回リリース時点でどこまで持つべきか という設計上の優先度を示す表現です。表を見るときは、まず MUST / SHOULD で設計優先度を確認し、そのうえで NULL/NOT NULL 列で物理制約を確認する、という順で読むことを想定しています。

16-3. 推奨 ER 図

以下は、本書の中心となるコア構成です。
初回リリースでは、この 8 テーブルをコアとして採用する前提で読みます。

erDiagram
    User ||--o| BillingAccount : "1:1"
    BillingAccount ||--o{ Subscription : "1:N"
    BillingAccount ||--o{ Entitlement : "1:N"
    Plan ||--o{ StoreProductMapping : "1:N"
    Plan ||--o{ Subscription : "1:N"
    Plan ||--o{ Entitlement : "1:N"
    Subscription ||--o{ GooglePurchaseToken : "1:N"
    Subscription ||--o{ BillingTransaction : "1:N"
    Subscription ||--o{ InboundWebhookEvent : "0:N"

    User {
        uuid user_id PK
        varchar omitted_fields
    }

    BillingAccount {
        uuid billing_account_id PK
        uuid user_id UK
        uuid account_token UK
        boolean has_started_trial_at_least_once
        timestampz first_trial_started_at
        timestampz last_recovered_at
    }

    Plan {
        uuid plan_id PK
        varchar code UK
        varchar name
        BillingPeriod billing_period
        boolean is_active
    }

    StoreProductMapping {
        uuid store_product_mapping_id PK
        BillingProvider provider
        uuid plan_id FK
        varchar product_id
        varchar base_plan_id
        varchar offer_id
        varchar base_plan_id_normalized
        varchar offer_id_normalized
        boolean is_active
    }

    Subscription {
        uuid subscription_id PK
        BillingProvider provider
        uuid billing_account_id FK
        uuid plan_id FK
        SubscriptionStatus status
        AcknowledgementStatus acknowledgement_status
        timestampz current_period_start_at
        timestampz current_period_end_at
        timestampz grace_period_ends_at
        timestampz canceled_at
        timestampz expires_at
        varchar apple_original_transaction_id
        varchar apple_latest_transaction_id
        uuid apple_app_account_token
        varchar current_google_purchase_token
        varchar google_obfuscated_external_account_id
        varchar google_obfuscated_external_profile_id
        varchar line_items_latest_successful_order_id
        varchar status_reason
        timestampz last_verified_at
    }

    GooglePurchaseToken {
        uuid google_purchase_token_id PK
        uuid subscription_id FK
        varchar purchase_token UK
        varchar linked_from_purchase_token
        boolean is_current
        varchar line_items_latest_successful_order_id
        timestampz invalidated_at
    }

    Entitlement {
        uuid entitlement_id PK
        uuid billing_account_id FK
        uuid plan_id FK
        boolean is_active
        EntitlementReason reason
        timestampz starts_at
        timestampz ends_at
        uuid source_subscription_id FK
    }

    BillingTransaction {
        uuid billing_transaction_id PK
        uuid subscription_id FK
        BillingProvider provider
        StoreEnvironment environment
        BillingEventType event_type
        varchar transaction_key UK
        varchar store_transaction_id
        varchar order_id
        varchar purchase_token
        varchar apple_original_transaction_id
        varchar google_linked_purchase_token
        timestampz occurred_at
        timestampz purchased_at
        timestampz expires_at
        timestampz revoked_at
        json raw_snapshot
    }

    InboundWebhookEvent {
        uuid inbound_webhook_event_id PK
        uuid subscription_id FK
        BillingProvider provider
        StoreEnvironment environment
        varchar provider_event_id
        varchar provider_business_key
        varchar event_type
        boolean verified
        WebhookProcessResult process_result
        text processing_error
        json raw_payload
        timestampz first_received_at
        timestampz processed_at
    }

16-4. User の扱い

User の属性はプロジェクト依存であり、本ドキュメントでは課金に必要な関連だけを扱います。
そのため、ER 図では User の非課金属性を omitted_fields として省略表現し、メールアドレスや認証方式を前提にしません。

ただし、ここでの User ||--o| BillingAccount は、User.id が退会・再入会をまたいでも不変である前提の簡略モデルです。
もし User.id が再入会時に変わりうるなら、実装では BillingAccount をその可変な User.id に直接ぶら下げず、persistentUserId のような永続識別子へ紐づけてください。本ドキュメントの userId 表記は、その永続識別子が User.id と一致しているプロジェクトでの簡略表現として読んでください。

16-5. 初回リリースのコアテーブル一覧

6 章では概念レベルで最小構成を示しましたが、DB 設計では Google の purchase token 履歴管理を安全に扱うため、Subscription に加えて GooglePurchaseToken を独立テーブルとして加えています。

テーブル主な役割これが必要な理由ない場合の主なリスク
Plan自社の課金プラン定義ストア商品 ID を業務ロジックへ直接漏らさないためストア識別子が業務ロジックへ流出し、商品差し替えや複数ストア対応で条件分岐が崩れやすくなる
StoreProductMappingストア商品と Plan の対応付けApple / Google の商品構造差分を境界で吸収するためproduct / base plan / offer の解釈が各所へ散らばり、誤った Plan へ投影しやすくなる
BillingAccount課金の主体User と決済主体を分け、restore やアカウント連携の基点を持つため認証主体と課金主体が混ざり、復元・救済・トライアル履歴管理が不安定になる
Subscription現在の契約状態ストア状態の現在値を一箇所へ集約するため正常 / 猶予中 / 失効などの判定を毎回再計算することになり、API と運用の両方が不安定になる
GooglePurchaseTokenGoogle purchase token の履歴管理purchaseToken / linkedPurchaseToken 系列を安全に扱うためrestore、re-signup、token 差し替え、重複付与防止、障害調査が弱くなる
Entitlement自社サービスの利用可否契約状態と利用可否を分離し、アプリへ安定した利用判定を返すためストア状態の差分がアプリ側へ漏れ、猶予期間中の継続利用などを一貫して表現しづらくなる
BillingTransaction課金イベント履歴監査、問い合わせ、再投影の根拠を残すため現在値しか残らず、なぜその状態になったか説明できなくなる
InboundWebhookEvent通知受信の監査と再処理起点webhook / RTDN の欠落、再送、処理失敗を区別できるようにするため通知の冪等処理と障害復旧が弱くなり、受信済みか未処理かを判別しにくくなる

以降のカラム表では、全テーブル共通で持つ createdAt / updatedAt を省略します。
これらは原則として監査と変更追跡のための共通列として扱い、個別の意味づけがある場合だけ表の外で補足します。

また、ER 図では物理列名を意識して主キーを table_name_id 形式で表します。
一方で、以降の表と Prisma スキーマ例では可読性を優先し、planId / billingAccountId のような camelCase で表記します。

以下の NULL/NOT NULL は、本ドキュメントで推奨する DB 制約の目安です。
ストア差分や移行方針によっては調整余地がありますが、少なくとも初回リリースの叩き台として読みます。

また、以降のカラム表には 列を追加し、PostgreSQL を前提とした推奨型を併記します。
varchar の長さは、初回リリースでは次の 3 グループを基本方針にすると扱いやすいです。

  • 内部コード: varchar(64)
  • 外部 ID / order ID: varchar(128)
  • Google purchase token 系: varchar(512)

uuidbooleantimestamptzjsonb、各種 enum は、その用途に応じて個別に使い分けます。
なお、enum 名は Prisma enum / DB enum を想定した表記です。
また、デフォルト値 は 18 章の Prisma スキーマ例と読み合わせしやすいよう、uuid() / now() / true / false のような表記でそろえています。

以降のカラム表では、必要に応じて 主に影響する要件 列を追加します。
ここで挙げる要件名は 3-3 節の区分名に合わせます。なお、FK のように意味が自明な列は簡潔に書き、各テーブルの主キーや共通監査列のように目的が自明なものは とします。

ここで示した 8 テーブルは、本ドキュメントにおける標準推奨構成です。
各テーブルが主にいつ作成されるのかという観点での補足は次節で扱います。
金銭コストや初期実装簡素化を理由に、さらにテーブル数を絞りたい場合の補足は、その次の節で扱います。

16-6. コアテーブル作成タイミング

ここまでで、コアテーブル 8 種の役割と必要性は見えてきました。
一方で、設計を実装へ落とす段階では、各テーブルがいつ create されるのか を先に把握しておくと、購入前準備、初回購入反映、通知処理の関係を追いやすくなります。

この節では、各テーブルの詳細定義には立ち入らず、購入前に用意するもの、購入や復元の反映時に初めて作られるもの、イベントのたびに増えるものを俯瞰できるように整理します。

テーブル主な作成タイミング補足
Plan事前のマスタ登録時購入時に都度作るものではなく、自社の課金プラン定義として事前に用意する
StoreProductMapping事前のマスタ登録時ストア商品と内部 Plan の対応表であり、商品設定とあわせて事前登録する
BillingAccount購入前準備時初回購入で初めて必要になる概念だが、設計上は購入開始前までに作成して accountToken を利用できる状態にしておく
Subscription初回購入反映時 / 復元反映時まだ存在しない系列であれば初回購入や復元反映時に初めて作成され、既存があれば更新で現在契約状態を保つ
Entitlement初回購入反映時 / 復元反映時Subscription と同様に、まだ存在しない利用権であれば初回購入や復元反映時に初めて作成され、既存があれば利用可否の現在値として更新される
BillingTransaction課金イベント発生時購入、更新、返金、回復などの都度追加される履歴であり、現在値テーブルではない
GooglePurchaseToken初回購入反映時 / token 差し替え時Google 専用。初回購入時や token 差し替え時に追加され、token 系列の履歴を保持する
InboundWebhookEventWebhook / RTDN 受信時Apple 通知や Google RTDN を受信した都度追加し、通知監査と再処理起点を残す

各テーブルの作成タイミングは、次のように見ると整理しやすくなります。

  • 事前に用意しておくマスタ
    • Plan
    • StoreProductMapping
    • これらは購入時に初めて作るものではない。商品を販売する前に、内部のプラン定義とストア商品対応表として登録しておく。
  • 購入前に用意しておく主体
    • BillingAccount
    • BillingAccount は初回購入時に初めて意味を持つ概念だが、設計上は購入開始前に作成しておく前提である。これにより、購入開始時に accountToken をストア SDK へ埋め込める。
  • 購入や復元の反映時に作成される現在値 / 利用権
    • Subscription
    • Entitlement
    • これらは、まだ対応する現在値が存在しなければ初回購入反映時や復元反映時に作成され、既存レコードがあれば update で最新状態へ保たれる。
  • イベントのたびに追加作成される履歴
    • BillingTransaction
    • GooglePurchaseToken(Google のみ)
    • BillingTransaction は現在値ではなく、購入、更新、返金、回復などの都度追加されるイベント履歴である。一方、GooglePurchaseToken は Google のすべての課金イベントで増えるわけではなく、初回購入や token 差し替えが起きたときに系列履歴として追加される。
  • 通知受信時に作成される監査ログ
    • InboundWebhookEvent
    • これは Apple 通知や Google RTDN を受信した都度追加される監査ログであり、再送判定や処理失敗調査の起点になる。

このように、コアテーブル 8 種はすべてが「購入時にまとめて作られる」わけではありません。
マスタ、主体、現在値、履歴、通知監査 という性質ごとに作成タイミングが異なるため、その違いを意識して読むと、後続の個別テーブル説明を追いやすくなります。

16-7. コアテーブルからさらに削減したい場合

16-5 で示した 8 テーブル構成は、初回リリースで課金機能を安定して成立させるための標準推奨構成です。
本節は、その前提を崩さずに、金銭コストや初期実装簡素化を優先して要件を意図的に絞る場合の縮小案を補足するものです。通常構成の置き換えとして読むものではありません。

また、テーブル削減は単なる整理ではありません。
実際には、責務の統合、運用リスクの受容、障害調査性の低下、将来拡張性の放棄を伴う設計判断です。DB の金額差は限定的であることが多く、論点はむしろ運用性、障害調査性、将来変更容易性とのトレードオフにあります。

ここで特に重要なのは、商品マッピング要件そのものは消えないという点です。
PlanStoreProductMapping の物理テーブルを削る案を書いても、ストア商品と内部プラン概念の対応付け責務までなくなるわけではありません。縮小案は、あくまでその責務の置き場所を一時的にコードや設定へ寄せる案として理解してください。

16-7-1. どこから削減検討すべきか

削減候補は、概ね次の順で検討すると判断しやすくなります。

優先度テーブル温度感
まず検討しやすいStoreProductMappingPlan商品定義まわりの責務であり、他テーブルよりは縮小しやすい
条件付きで検討可能BillingAccountInboundWebhookEvent要件をかなり絞れば成立するが、復元・救済・運用監査の強度が落ちる
非推奨だが理屈上は削減可能EntitlementGooglePurchaseTokenBillingTransaction責務分離や監査性を崩すため、初期簡素化より後続コスト増の影響が大きい
実質的に削減不可Subscription正規化済み現在値の中心であり、ここを消すと全体が不安定になる

要するに、最初に検討すべきは商品定義まわりであり、状態管理や履歴管理の中核へ踏み込むほど非推奨です。
実務上の第一候補は StoreProductMapping のみを外部設定へ逃がす案であり、Plan の削減はその次に検討するものとして扱うのが自然です。

16-7-2. テーブル別の削減可否と失うもの

テーブル削減しやすさ削減する場合の前提条件代替手段主なデメリット
StoreProductMapping高いprovider + productId + basePlanId + offerId の解釈をアプリケーション内で 1 か所に集約できること外部ストレージの JSON や設定ファイルで商品対応表を持つDB の unique / FK 制約が使えず、設定更新ミスで誤った Plan へ投影しうる
Plan中程度初回リリースで 1 プランのみ、月額固定、年額・買い切り・複数 tier・複数 offer の追加予定が薄く、商品表示名や販売期間を DB の正規データとして扱う必要が薄いことplanCode のような安定キーを設定ファイルやアプリケーション定数として厳格に運用するPlan は内部業務概念であり、StoreProductMapping より削減コストが高い。ストア識別子が業務ロジックへ漏れやすく、複数プラン追加時に戻し工事が発生しやすい
BillingAccount中程度課金主体と認証主体を同一視してよく、User.id が永続不変で、accountTokenUser 側に直接持ってよく、未ログイン購入や将来のアカウント統合を考えないことUseraccountToken と trial 利用履歴を直接持たせる認証主体と課金主体が混ざる。復元、救済、将来のアカウント統合や複数課金主体対応で不利になる
InboundWebhookEvent中程度通知受信の監査と再処理起点を DB 以外で担保できることキュー、ログ基盤、オブジェクトストレージなどで受信 payload と処理結果を保持する通知再送、署名検証、処理失敗履歴の追跡がしづらくなり、障害調査と再処理運用が弱くなる
Entitlement低い契約事実と利用判定の責務混在を受け入れ、参照時に毎回 Subscription から利用可否を解釈してよいことSubscription を直接読んで利用可否を判定する read ロジックを各所で持つ単なるテーブル削減ではなく責務統合である。利用可否判定の参照先が不安定になり、read model と契約状態保存の責務が混線する
GooglePurchaseToken低いAndroid 対応をするが、token 差し替え履歴や系列追跡を薄くしてよいことSubscription.currentGooglePurchaseToken のような現在値だけで始めるpurchaseToken / linkedPurchaseToken 系列の管理が弱くなり、restore、re-signup、障害調査、誤付与防止が苦しくなる
BillingTransaction低い現在値中心の運用とし、時系列監査を大きく簡略化してよいことSubscription の現在値と外部ログだけで最低限の調査を行う強く非推奨である。現在値だけでは説明できない事実を失い、返金追跡、障害調査、再投影、時系列監査が弱くなる
Subscriptionほぼ不可現在契約状態を別手段で安定提供できることストア API、履歴、計算ロジックへ毎回依存する実質的に削減不可である。ここを消すと各所がストア API / 履歴 / 計算ロジックに依存し、状態判定と API 応答が不安定になる

StoreProductMapping を外部化する場合でも、商品対応表の参照口は 1 か所に集約する必要があります。
たとえば provider + productId + basePlanId + offerId の解釈が ingest、webhook、管理画面、手動復旧ロジックへ散る設計にすると、DB テーブルを 1 枚減らしても設計全体はむしろ崩れやすくなります。

また、Plan は削減候補に見えても、StoreProductMapping より慎重に扱うべきです。
Plan はストアの都合ではなく、自社の業務概念を安定化させる層であり、ここをなくすと「商品 ID の意味づけ」がストア識別子へ引きずられやすくなります。

16-7-3. SubscriptionEntitlement を統合する案の追加リスク

Entitlement を削り、SubscriptionisActivereason を直接持たせる案は、初回段階では単純に見えることがあります。
ただし、これは単にテーブルを 1 枚減らす話ではなく、利用可否の正本をどこに置くかという設計そのものを変える判断です。

まず、Subscription契約系列の現在状態を保持するテーブルであり、provider、契約状態、transaction 系識別子、purchase token 系列など、契約の追跡・再照合・系列管理をしやすい形で設計しています。
一方、Entitlement は**billingAccountId + planId 単位の現在利用権**を返す read model であり、クライアントや画面 API が「今このユーザーがこのプランを使えるか」を安定して参照しやすい形で設計しています。両者は似た情報を持つのではなく、検索軸、一意性、クエリ意図が異なるものとして分けています。

このとき isActivereasonSubscription に統合すると、どの Subscription レコードを利用可否の正本として参照すべきかが曖昧になりやすくなります。
MVP では見かけ上 1 契約と 1 利用権がほぼ 1:1 に見えても、設計上は厳密な 1:1 を前提にしていません。Subscription に利用可否を寄せると、実質的に「契約系列と利用権は常に 1:1 で扱える」という前提を強めることになり、後続の変更に弱くなります。

また、Entitlement を独立させていることで、クライアントや画面 API は GET /me/entitlement 相当の安定した利用可否 read modelを参照できます。
これを Subscription に寄せると、利用可否判定ロジックが read 側や各 API に散りやすくなり、どこで isActive をどう解釈するかがばらつきやすくなります。その結果、参照先の安定性が下がり、API、管理画面、バッチ、将来の集計処理で判定が揺れやすくなります。

さらに、Subscription 統合案は将来拡張でも不利です。
たとえば Google の lineItems[]、add-ons、Apple の Family Sharing のように、1 契約と 1 利用権が単純対応しないケースへ広がると、契約系列を持つ構造と利用可否を返す構造を分けておいたほうが自然に拡張できます。初回は動いて見えても、SubscriptionisActive / reason を寄せる設計は、こうした局面で戻し工事が大きくなりやすいです。

したがって、Entitlement を削る判断は「利用可否の正本設計そのものを変える」判断として扱うほうが適切です。
理屈上は成立しても、後で効いてくるのは責務混在そのものというより、参照の安定性、一意性、将来拡張性を先に削ってしまうことです。

16-7-4. 削減議論で誤解しやすいポイント

テーブル削減を検討するときは、単に「最新 1 件を見れば足りるか」「外部コンソールで確認できるか」だけで判断しないことが重要です。
ここで迷いやすいのは、現在状態の保持責務履歴・監査の保持責務を、同じもので代替できるように見えてしまう点です。

まず、Subscription現在の契約状態を正規化した最新像であり、BillingTransaction課金イベントの履歴です。
BillingTransaction の最新 1 件は最新イベントではあっても、必ずしも現在状態そのものではありません。課金では、通知再送、順不同到着、クライアント通知の先着 / 後着、補正ジョブによる再投影がありえます。そのため、最新イベント = 現在状態とは限りません。

Subscription をなくし、BillingTransaction の最新から毎回現在状態を解釈する設計にすると、状態判定ロジックが read 側や運用側へ散りやすくなります。
その結果、API、管理画面、バッチ、障害調査のそれぞれで解釈がぶれやすくなり、同じ購読に対して異なる判定が生じやすくなります。これは単なるテーブル数の問題ではなく、現在状態の保持責務と履歴保持責務を混ぜないための分離として理解するほうが適切です。

また、ストアコンソールは有用な確認手段ですが、BillingTransactionInboundWebhookEvent の代替にはなりません。
ストアコンソールで確認できるのは、基本的にストア側で見えている契約や課金の状態です。一方、自社運用で必要なのは、それに加えて自社が通知を受け取ったか、署名検証 / JWT 検証に成功したか、冪等処理でどう扱ったか、投影や DB 更新のどこで失敗したか、後で何を再処理すべきかを追えることです。

InboundWebhookEvent は、通知受信の監査と再処理起点を持つためのテーブルです。
これはストア通知の状態そのものを保存するためではなく、自社の受信・検証・再処理運用を成立させるための監査テーブルです。ストアコンソールだけでは、自社 API が受信した事実、検証結果、処理結果、失敗箇所までは置き換えられません。

同様に、BillingTransaction自社システムが保存した課金イベント履歴であり、現在値だけでは説明できない時系列事実を残します。
ストアコンソール参照は外部状態の確認には役立ちますが、自社システム内で何を受信し、何を検証し、どこまで投影できたかという監査や説明責任の代替ではありません。

したがって、削減議論では「コンソールで見られるか」ではなく、自社で何を監査し、何を再処理し、何を説明責任として残す必要があるかで判断することが重要です。
この観点に立つと、SubscriptionBillingTransactionInboundWebhookEvent は似た情報を重複して持っているのではなく、それぞれ異なる責務を分担していることが分かりやすくなります。

16-7-5. 現実的な縮小案

机上では多くのテーブルを削れますが、現実的には次の 3 段階で考えると判断しやすくなります。

構成主な前提条件受け入れる主なリスクコメント
縮小案 A7 テーブル構成StoreProductMapping を削減し、Plan は残す商品対応表を外部 JSON / 設定ファイルへ移し、その参照を 1 か所に集約できるDB 制約で守れず、設定更新ミスが即時に誤投影へつながりうる最も現実的で、標準構成からの乖離も小さい
縮小案 B6 テーブル構成StoreProductMappingPlan を削減する初回リリースで 1 プランのみ、月額固定、将来の複数プラン化予定が薄い。planCode のような安定キーをアプリケーション上で厳格に運用できるストア識別子が業務ロジックへ漏れやすくなり、複数プラン追加時に戻し工事が発生しやすい条件付きでは成立しうるが、戻し工事前提の実装 discipline を強く要求する
縮小案 C4〜5 テーブル構成。さらに BillingAccountInboundWebhookEvent まで削減する課金主体と認証主体を同一視し、通知監査や再処理起点を DB 以外で担保できる復元、救済、通知監査、署名検証の追跡、障害調査の強度が大きく落ちる初期簡素化の代わりに、運用・調査・復旧コストを前倒しで受け入れる案である

縮小案 C より先、すなわち EntitlementGooglePurchaseTokenBillingTransactionSubscription 側へ踏み込む削減は、初期実装の簡素化よりも後続の運用・調査・拡張コスト増のほうが大きくなりやすくなります。特に Subscription を欠く構成は、初回リリースの安定運用を成立させにくくなります。

16-7-6. 1テーブル削減案: StoreProductMapping のみを削減する場合

この案は、Plan は残し、StoreProductMapping だけを外部設定へ逃がす構成です。
つまり、商品対応表の役割そのものを消すのではなく、設定ファイル、外部 JSON、アプリケーション定数などで一時的に代替するだけです。商品マッピング責務は引き続き MUST であり、永続化手段を簡素化しているにすぎません。

成立条件は、provider + productId + basePlanId + offerId の解釈をアプリケーション内で 1 か所に集約できることです。
具体的には、ProductCatalogResolver のような resolver / service を 1 つ置き、ストア商品をまず内部 planCode に解決し、その後 Plan.code から planId を引く構成にしておくと扱いやすくなります。

この構成の主なメリットは次のとおりです。

  • DB テーブルを 1 枚減らせる
  • Plan を残すため、内部業務概念を維持できる
  • 将来 StoreProductMapping を DB へ戻すときも、resolver の実装差し替えで吸収しやすい

一方で、デメリットも明確です。

  • DB の unique / FK 制約で商品対応表を守れない
  • 設定更新ミスが誤投影へつながりうる
  • 参照口が複数箇所へ散ると、標準構成よりかえって壊れやすくなる

そのため、この案を採る場合でも、商品判定は必ず 1 か所の resolver に閉じ込めることが重要です。
ingest、webhook、batch、restore の各経路で個別に productId を見て分岐する実装は避けてください。

16-7-7. 2テーブル削減案: StoreProductMappingPlan を削減する場合

この案は、StoreProductMappingPlan の両方の物理テーブルを削減する構成です。
ただし、テーブルを削っても、内部プラン概念まで消してよいわけではありません。商品マッピング自体は要件上の MUST であり、ストア固有 ID をそのまま業務ロジックへ流さないための内部キーは引き続き必要です。

この案が成立しうるのは、少なくとも次の条件が揃う場合です。

  • 初回リリースで 1 プランのみ
  • 月額固定
  • 年額、買い切り、複数 tier、複数 offer の追加予定が当面薄い
  • 商品表示名や販売期間を DB の正規データとして早期に扱う必要が薄い

この条件下でも、SubscriptionEntitlement には planId の代わりに planCode のような安定した内部キーを持たせる設計にしてください。
productId をそのまま契約の意味づけに使うのではなく、内部語彙としての planCode を 1 段挟むことで、将来 Plan を戻す余地を確保できます。

また、billingPeriod が業務上必要なら、初回フェーズでは DB の列またはアプリケーション定数で持っておき、将来 Plan へ昇格できるようにしておくのが安全です。
画面表示名、プラン名、期間表記も、ストア ID から直接組み立てるのではなく、planCode 経由で解決する設計にしておくほうが、後の戻し工事が小さくなります。

この案のメリットは、初回リリース時のテーブル数と初期実装コストをさらに下げやすいことです。
一方で、デメリットはより重くなります。

  • 内部業務概念を DB に持たないため、設計上の戻し工事が前提になる
  • 2 本目のプラン追加時に migration とアプリケーション改修が必要になる
  • ストア識別子が業務ロジックへ漏れやすくなる

したがって、この案は「概念を消す案」ではなく、初回フェーズでは内部概念の永続化先をコード側へ寄せる案として理解するのが適切です。

16-7-8. 2テーブル削減を選ぶ場合の戻し工事前提の実装方針

2 テーブル削減案を選ぶ場合は、初回の楽さだけで実装しないことが重要です。
ここで必要なのは、将来 Plan / StoreProductMapping を戻すことを前提に、初回フェーズから内部プラン概念を崩さず持つことです。

まず、テーブルは削っても、コード上の内部プラン概念は残してください。
planCode を安定した内部キーとして定義し、必要なら billingPeriod も同様に内部語彙として扱います。初回フェーズでは 1 プランしかなくても、「将来複数プランになったときにそのまま昇格できる名前」で置いておくことが重要です。

次に、provider + productId + basePlanId + offerId から planCode を解決する resolver を 1 か所に閉じ込めます。
初回フェーズでは ConfigProductCatalogResolver のように設定ファイルベースで実装して構いませんが、将来 DbProductCatalogResolver に差し替えられるよう、ProductCatalogResolver のような interface を切っておくと移行しやすくなります。

SubscriptionEntitlement には、初回フェーズから planCode を保持してください。
将来 Plan テーブルを追加したとき、既存データを planCode から planId へ backfill できるようにしておくためです。初回から planCode を持っていれば、戻し工事は「新しい概念の導入」ではなく、「既に持っていた内部概念の永続化先を DB に移す」作業に近づきます。

画面表示や管理画面表示も、ストア ID を直接参照せず、planCode ベースの解決関数を経由させてください。
これにより、表示ロジックがストア識別子へ直接依存せず、後から Plan を戻したときも呼び出し側をほとんど変えずに済みます。

逆に、避けるべき実装は明確です。

  • ingest、webhook、batch、restore の各経路に if 文で商品判定を散らすこと
  • productId をそのまま契約の意味づけに使うこと
  • 画面表示名や期間表記を複数箇所へ直書きすること

2 テーブル削減案は、初回だけ見れば楽に見えることがあります。
しかし実際には、初回から discipline を守って内部キーと resolver 境界を整えておくことを強く要求する案です。ここを崩すと、将来の戻し工事は急激に重くなります。

16-7-9. 年額などの課金要素を追加するフェーズでの戻し工事

将来、月額以外に年額や複数 tier を追加するフェーズでは、戻し工事を段階的に進めると安全です。
ここでの本質は、新しい概念を突然導入することではなく、初回から保持していた内部プラン概念の永続化先を設定ファイルから DB に移すことです。

Phase 1. テーブル追加

  • Plan を作成する
  • StoreProductMapping を作成する
  • 月額、年額などの master data を seed する

Phase 2. 既存テーブルへ planId 追加

  • Subscription.planId を nullable で追加する
  • Entitlement.planId を nullable で追加する

Phase 3. backfill

  • 既存 planCode をもとに Plan.code から planId を埋める
  • 既存の月額データを壊さず移行する
  • NULL 残件や件数不一致を確認する

Phase 4. dual read / dual write

  • write 時は planCodeplanId の両方を書き込む
  • read 時は planId 優先、未設定時のみ planCode fallback にする

Phase 5. resolver 差し替え

  • 設定ファイル実装から DB 実装へ差し替える
  • 呼び出し側の契約は変えず、resolver の実装だけを置き換える

Phase 6. legacy 整理

  • 設定ファイル catalog を廃止する
  • 必要に応じて planCode の legacy 列を削除するか、検索用として残す
  • planIdNOT NULL 化する

この段取りにしておくと、戻し工事は「散らばった商品判定を拾い集めて作り直す作業」ではなく、初回から 1 か所に閉じ込めていた商品解決の永続化先を切り替える作業として進めやすくなります。

16-7-10. 初回リリース時の採用判断基準

初回リリース時にどこまで縮小するかは、テーブル数だけでなく、将来の戻しやすさと実装 discipline をどこまで守れるかで判断すると安定します。

判断対象向く条件補足
1 テーブル削減案月額 1 プランのみで、近いうちの複数プラン追加予定は薄いが、Plan は内部業務概念として残したい。将来戻しやすさも優先したい実務上もっとも現実的である。縮小しつつ、業務語彙と DB への戻しやすさをかなり維持できる
2 テーブル削減案初回は 1 プラン固定で、リリース期限と金銭コスト削減を最優先する。将来追加時の戻し工事を受け入れられ、初回から戻し工事前提の実装 discipline を守れる条件付きでは成立するが、planCode、resolver、dual read / dual write を見据えた実装が前提である
2 テーブル削減案を避けるべき条件近いうちに年額や複数 tier 追加の可能性が高い。商品表示名や期間を DB の正規データとして早めに持ちたい。複数開発者が分担し、商品判定ロジックの散逸リスクが高いこの場合は初回から PlanStoreProductMapping を持つほうが結果的に安定しやすい

したがって、判断としては次の整理が分かりやすくなります。

  • 標準推奨は、引き続き 8 テーブル構成
  • 実務上もっとも現実的な縮小案は、1 テーブル削減案
  • 2 テーブル削減案は、初回リリース優先時の条件付き案であり、戻し工事前提の実装 discipline を強く要求する

16-7-11. 推奨結論

設計としての結論は明確です。

  • 標準推奨は、引き続き 8 テーブル構成
  • さらに削減したい場合の第一候補は、StoreProductMapping の外部設定化
  • 次点として Plan の削減はありえるが、Plan は内部業務概念であり、StoreProductMapping より慎重に扱うべきである
  • EntitlementGooglePurchaseTokenBillingTransactionSubscription 側へ踏み込む削減は、初期コストを下げる代わりに、後続の運用・調査・拡張コストを大きく押し上げやすい

したがって、最も現実的な縮小案は StoreProductMapping のみを外部設定へ逃がし、Plan は残す案です。
一方で、StoreProductMappingPlan の両方を削る 2 テーブル削減案は、初回リリース優先時には成立しうるものの、概念まで削るのではなく、戻し工事前提で内部キーと resolver 境界を初回から整えておくことを強く要求します。

16-8. Plan

Plan は、自社サービス上の課金プランです。
ストアの商品 ID はストア都合で増減しますが、アプリの権限や価格帯の概念は自社都合で安定して参照したいことが多いため、自社が意味づけしたプラン概念を先に固定します。これにより、ストア識別子を業務ロジックへ直接漏らさずに済みます。

課金周期は汎用的な期間表現ではなく、販売する商品期間そのものを表す billingPeriod で持ちます。
この設計にすると、MONTHLYQUARTERLYSEMI_ANNUALYEARLY のように、実際に販売するプラン単位で表現できます。初回リリースで実際に使う値が MONTHLY のみでも、将来の年額や複数月プラン追加に備えた定義がしやすくなります。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
planIdMUSTuuidNOT NULLuuid()内部主キー
codeMUSTvarchar(64)NOT NULL-業務上の安定キー。例: basic_monthly商品マッピング
nameMUSTvarchar(128)NOT NULL-管理画面や社内で使う表示名管理・調査
billingPeriodMUSTBillingPeriodNOT NULL-販売する商品期間。例: MONTHLY, YEARLY商品マッピング、商品・価格
isActiveMUSTbooleanNOT NULLtrue新規販売・新規付与に使ってよいか商品マッピング、手動復旧

16-9. StoreProductMapping

StoreProductMapping は、Apple / Google の商品定義を Plan に結び付けるテーブルです。
Google では productId に加えて basePlanIdofferId があり、Apple と Google では識別子の構造が異なります。この差をアプリ全体に漏らさずに吸収する境界として置きます。商品追加や offer 追加のたびに条件分岐を各所へ散らさないためにも、初回から独立させておくのが安全です。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
storeProductMappingIdMUSTuuidNOT NULLuuid()内部主キー
providerMUSTBillingProviderNOT NULL-APPLE / GOOGLE商品マッピング
planIdMUSTuuidNOT NULL-対応する自社 Plan商品マッピング
productIdMUSTvarchar(128)NOT NULL-Apple の product ID、Google の subscription product ID商品マッピング
basePlanIdMUSTvarchar(128)NULL-Google の base plan。Apple では通常 NULL商品マッピング、初回特典
offerIdMUSTvarchar(128)NULL-Google の offer。Apple では通常 NULL商品マッピング、初回特典
basePlanIdNormalizedMUSTvarchar(128)NOT NULL''unique 制約を安定して成立させるための内部補助列。NULL は空文字へ揃える商品マッピング、冪等性
offerIdNormalizedMUSTvarchar(128)NOT NULL''unique 制約を安定して成立させるための内部補助列。NULL は空文字へ揃える商品マッピング、冪等性
isActiveMUSTbooleanNOT NULLtrueその対応関係を新規判定に使ってよいか商品マッピング、手動復旧

補足:

basePlanIdNormalized / offerIdNormalized は、nullable な basePlanId / offerId を含む一意制約を安定して成立させるための内部的な補助列です。検索条件や業務上の意味を持つ列ではなく、一意制約のためだけに保持します。

これらの列は、手入力・手動更新を前提としません。常に元値カラムと同期される運用とし、insert / update のたびにアプリケーション層で同期します。

同期ルールは次のとおりです。

  • basePlanIdNULL の場合、basePlanIdNormalized には '' を設定する
  • basePlanId に値がある場合、basePlanIdNormalized にはその値をそのまま設定する
  • offerIdNULL の場合、offerIdNormalized には '' を設定する
  • offerId に値がある場合、offerIdNormalized にはその値をそのまま設定する

したがって、アプリケーション実装では basePlanId / offerId を更新する際に、対応する正規化列も必ず同時に更新します。

16-10. BillingAccount

BillingAccount は、課金の主体です。
多くのサービスでは User と 1:1 で始めて問題ありませんが、課金の主体をアプリ上のユーザー概念から少しだけ分離しておくと、restore、移管、将来の外部決済追加、複数アカウント統合で設計が崩れにくくなります。初回は 1:1 でも、概念を切り出しておく価値があります。

また、BillingAccount は provider ごとに分ける概念ではありません。課金の帰属先そのものであり、provider は Subscription / BillingTransaction / StoreProductMapping 側で管理します。したがって、同一ユーザーが Apple / Google の両方で課金しても BillingAccount は 1 つでよく、accountToken も両 provider に共通利用できます。

さらに、BillingAccount購入時ではなく購入前に作成しておき、accountToken もその時点で生成しておきます。アプリは購入開始前までにこの accountToken を取得し、Apple の appAccountToken や Google の obfuscatedExternalAccountId 系へ埋め込みます。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
billingAccountIdMUSTuuidNOT NULLuuid()内部主キー
userIdMUSTuuidNOT NULL-課金帰属先に対応する永続識別子への参照。本ドキュメントでは User.id が不変である前提の簡略モデルとして userId と表記する紐付け、再紐付け
accountTokenMUSTuuidNOT NULLuuid()自社課金主体を表す安定トークン。BillingAccount 作成時に生成し、Apple appAccountToken や Google obfuscated ID に共通利用する紐付け、再紐付け、購入復元
hasStartedTrialAtLeastOnceSHOULDbooleanNOT NULLfalseトライアル利用歴あり / なしを高速に判定する補助フラグ利用履歴、初回特典
firstTrialStartedAtSHOULDtimestamptzNULL-最初に無料トライアルを開始した日時利用履歴、管理・調査
lastRecoveredAtSHOULDtimestamptzNULL-支払い問題から最後に回復した日時。CS や離脱分析に有用回復検知、管理・調査

実プロジェクトで退会・再入会により User.id が変わるなら、実装では列名も persistentUserId などへ寄せるほうが誤解が少なくなります。ここでは既存章との整合のため userId 表記を維持しています。

16-11. Subscription

Subscription は、現在の契約状態のスナップショットです。
Apple / Google から取得した状態を、自社で参照しやすい形へ投影した現在値として持ちます。BillingTransaction が履歴であるのに対し、Subscription は現在状態を表します。正常、トライアル中、猶予期間中、失効、返金後などを一箇所で参照できるようにするための中心テーブルです。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
subscriptionIdMUSTuuidNOT NULLuuid()内部主キー
providerMUSTBillingProviderNOT NULL-APPLE / GOOGLE契約状態、通知処理
billingAccountIdMUSTuuidNOT NULL-課金主体への参照紐付け、再紐付け
planIdMUSTuuidNOT NULL-現在紐付いている自社 Plan商品マッピング、利用可否提供
statusMUSTSubscriptionStatusNOT NULL-ACTIVE / TRIALING / IN_GRACE_PERIOD などの契約状態契約状態、決済異常検知、回復検知、猶予期間終了検知、返金・取消、状態確定
acknowledgementStatusMUSTAcknowledgementStatusNOT NULL-Google 向けの ack 状態。provider ごとに明示設定する。Apple では通常 NOT_REQUIRED、Google では PENDING / ACKNOWLEDGED を入れるGoogle 固有、再処理、状態確定
currentPeriodStartAt / currentPeriodEndAtMUSTtimestamptz / timestamptzNULL-現在契約期間契約状態、利用可否提供
gracePeriodEndsAtMUSTtimestamptzNULL-猶予期間の終了見込み契約状態、猶予期間終了検知
expiresAtMUSTtimestamptzNULL-利用停止の基準時刻として使う契約状態、権限反映、利用可否提供
appleOriginalTransactionIdMUSTvarchar(128)NULL-Apple 契約を一意に辿る主キー候補紐付け、再紐付け、購入復元、管理・調査
appleLatestTransactionIdMUSTvarchar(128)NULL-最新 transaction の把握や再照会起点に使う状態確定、管理・調査
currentGooglePurchaseTokenMUSTvarchar(512)NULL-現在有効な Google purchase token への参照用キャッシュ購入復元、再紐付け、Google 固有、管理・調査
canceledAtSHOULDtimestamptzNULL-解約確定日時や自動更新オフ検知時の記録に使う契約状態、権限反映
appleAppAccountTokenSHOULDuuidNULL-Apple 側へ渡した自社アカウント識別子の保持紐付け、再紐付け、購入復元
googleObfuscatedExternalAccountIdSHOULDvarchar(128)NULL-Google から返る難読化アカウント ID紐付け、再紐付け、購入復元
googleObfuscatedExternalProfileIdSHOULDvarchar(128)NULL-Google から返る難読化プロフィール ID紐付け、再紐付け
lineItemsLatestSuccessfulOrderIdSHOULDvarchar(128)NULL-Google の lineItems.latest_successful_order_id を保持する補助列Google 固有、管理・調査
statusReasonSHOULDvarchar(64)NULL-APPLE_BILLING_RETRY / GOOGLE_ACCOUNT_HOLD など、内部状態だけでは落ちる文脈を残す補助列決済異常検知、回復検知、管理・調査
lastVerifiedAtSHOULDtimestamptzNULL-最後にストア再照会した時刻。問い合わせ対応や補正ジョブで有用状態確定、欠落補正、管理・調査

acknowledgementStatus は provider 共通列ですが、デフォルトでは埋めずに projection 時に明示設定する前提にします。Apple は通常 NOT_REQUIRED、Google は purchase / restore / plan change など new purchase token を伴う purchase では PENDING または ACKNOWLEDGED を保持します。

Google の top-level latestOrderId は deprecated です。主要保持項目としては lineItems.latest_successful_order_id または BillingTransaction.orderId を使います。
特に Apple の billing retry と Google の account hold は entitlement がどちらも false になりえますが、意味は同じではありません。運用上の誤読を避けるには、status に加えて statusReason のような補助列を持つと安全です。

16-12. GooglePurchaseToken

GooglePurchaseToken は、Google の purchase token 履歴を保持するためのテーブルです。
これは 6 章の最小ドメインモデルに対して新しい業務概念を足しているというより、Subscription を Google 固有の系列管理の都合で DB 上で安全に分解したもの と考えるのが自然です。Google では plan change、re-signup、prepaid top-up などで purchase token が差し替わります。しかも linkedPurchaseToken で前 token へ辿れることがあるため、token は「現在値 1 本」ではなく履歴として扱うほうが安全です。Google を本番対象に含めるなら、currentGooglePurchaseToken の 1 列だけで済ませず、系列として保持する前提にしておくべきです。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
googlePurchaseTokenIdMUSTuuidNOT NULLuuid()内部主キー
subscriptionIdMUSTuuidNOT NULL-対応する SubscriptionGoogle 固有、再紐付け
purchaseTokenMUSTvarchar(512)NOT NULL-Google が返す token 本体。全体で一意購入証跡連携、購入復元、Google 固有、管理・調査
linkedFromPurchaseTokenMUSTvarchar(512)NULL-ひとつ前の token。linkedPurchaseToken を履歴として残す再紐付け、Google 固有、競合防止
isCurrentMUSTbooleanNOT NULLfalse現在有効な token かどうか状態確定、Google 固有
invalidatedAtMUSTtimestamptzNULL-現在 token でなくなった時刻権限反映、競合防止、管理・調査
lineItemsLatestSuccessfulOrderIdSHOULDvarchar(128)NULL-その token で見えた最新成功 order IDGoogle 固有、管理・調査

補足: 共通列の createdAt は、このテーブルでは token を最初に観測した時刻として扱います。

16-13. Google 専用テーブルが必要で、Apple 専用テーブルが不要な理由

Google では、purchaseToken がサーバ検証の主キーであり、しかも同一購読系列の中で差し替わることがあります。
purchases.subscriptionsv2.get は purchase token をキーに現在状態を返し、linkedPurchaseToken は旧 token との接続に使われます。つまり Google は、現在 token と過去 token の両方を系列として保持しないと、restore、re-signup、upgrade / downgrade、重複付与防止、障害調査が弱くなる構造です。

加えて、Google の一次情報でも、linkedPurchaseToken が返るときは旧 token 側の entitlement を適切に取り下げることが重要だと案内されています。
この運用は、currentGooglePurchaseToken の 1 列だけでは扱いにくく、履歴テーブルとしての GooglePurchaseToken があったほうが明確です。

一方、Apple では originalTransactionId が同一購読系列を通して安定したキーとして扱えます。
renewal や restore で transactionId 自体は増えていきますが、契約系列を表す基準は originalTransactionId です。そのため、Apple では Subscription.appleOriginalTransactionId を系列キーにし、必要に応じて appleLatestTransactionIdBillingTransaction.storeTransactionId / BillingTransaction.appleOriginalTransactionId を併用すれば、通常の restore・更新追跡・監査を十分に扱えます。

したがって、本ドキュメントでは次のように整理します。

  • Google: token 自体が系列の中心なので、GooglePurchaseToken を独立テーブルとして持つ
  • Apple: 契約系列は originalTransactionId で十分追えるため、Apple 専用の系列テーブルまでは作らない

Apple で別テーブルを検討するのは、appTransactionId を含む横断監査ビューを明確に分けたい、あるいは originalTransactionId 単位の独立した運用画面を持ちたい、といった追加要件がある場合で十分です。

16-14. Entitlement

Entitlement は、自社サービスでそのプランを使わせてよいかを表すテーブルです。
契約状態と利用可否を分離する目的で置きます。たとえば、猶予期間中は Subscription.status = IN_GRACE_PERIOD でも Entitlement.isActive = true にできます。逆に返金時は契約履歴が残っていても isActive = false にできます。ストア依存の細かな状態差分をアプリ側へ漏らさないための境界でもあります。

EntitlementbillingAccountId + planId で一意な現在の利用権として扱います。初回リリースの実データでは、1 本の Subscription から 1 本の Entitlement が導かれているように見えることがあります。
ただし、これは current scope での見え方にすぎず、概念上もスキーマ上も厳密な 1:1 を保証するものではありません

sourceSubscriptionId は、その利用権を導いた主な sourceを指す補助参照です。nullable であり、唯一の親子関係や厳密な 1:1 対応を保証するための列ではありません。Prisma スキーマでも Subscription.sourceEntitlements: Entitlement[] として表現しており、モデル上は 1:1 固定ではなく、将来の投影元拡張や説明責務の分離に耐える形にしています。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
entitlementIdMUSTuuidNOT NULLuuid()内部主キー
billingAccountIdMUSTuuidNOT NULL-どの課金主体に対する利用権か利用可否提供、紐付け
planIdMUSTuuidNOT NULL-どのプランの利用権か商品マッピング、利用可否提供
isActiveMUSTbooleanNOT NULL-現在使わせてよいか権限反映、利用可否提供
reasonMUSTEntitlementReasonNOT NULL-ACTIVE / IN_GRACE_PERIOD / EXPIRED など、利用可否の説明用理由権限反映、利用可否提供、管理・調査
startsAt / endsAtMUSTtimestamptz / timestamptzNULL-利用期間利用可否提供、管理・調査
sourceSubscriptionIdMUSTuuidNULL-この利用権を導いた主な Subscription を指す補助参照状態確定、監査保存、管理・調査

16-15. BillingTransaction

BillingTransaction は、課金イベント履歴です。
Subscription が現在状態、BillingTransaction が履歴という役割分担にします。ここには、購入、更新、回復、返金、取消、商品変更などを時系列で失わずに保存します。現在値だけでは説明できない事実を残すためのテーブルです。

また、運用時にストアコンソールを参照できるとしても、それだけで BillingTransaction の代替にはなりません。
ストアコンソール参照は外部状態の確認には有用ですが、自社システムが保存した時系列監査の代替にはならないためです。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
billingTransactionIdMUSTuuidNOT NULLuuid()内部主キー
subscriptionIdMUSTuuidNOT NULL-対応する Subscription履歴管理、管理・調査
providerMUSTBillingProviderNOT NULL-APPLE / GOOGLE履歴管理
environmentMUSTStoreEnvironmentNULL-PRODUCTION / SANDBOX。環境切り分けに使う環境分離、管理・調査
eventTypeMUSTBillingEventTypeNOT NULL-PURCHASED / RENEWED / REFUNDED など決済異常検知、回復検知、返金・取消、履歴管理
transactionKeyMUSTvarchar(512)NOT NULL-冪等記録用キー。重複保存を防ぐ冪等性、履歴管理
occurredAtMUSTtimestamptzNOT NULL-ストア上でそのイベントが起きた時刻順不同耐性、履歴管理、管理・調査
rawSnapshotMUSTjsonbNULL-ストア応答の原文スナップショット監査保存、手動復旧、管理・調査
storeTransactionIdSHOULDvarchar(128)NULL-Apple transactionId など、ストア側の主要識別子管理・調査
orderIdSHOULDvarchar(128)NULL-Google order IDGoogle 固有、管理・調査
purchaseTokenSHOULDvarchar(512)NULL-Google token のスナップショット購入証跡連携、再紐付け、管理・調査
appleOriginalTransactionIdSHOULDvarchar(128)NULL-Apple 契約系列を履歴側からも辿れるようにする再紐付け、購入復元、管理・調査
googleLinkedPurchaseTokenSHOULDvarchar(512)NULL-Google の旧 token への接続を履歴側にも残す再紐付け、Google 固有、競合防止
purchasedAtSHOULDtimestamptzNULL-購入成立時刻利用履歴、管理・調査
expiresAtSHOULDtimestamptzNULL-そのイベント時点で見えた期限契約状態、履歴管理
revokedAtSHOULDtimestamptzNULL-返金・取消などで失効した時刻返金・取消、権限反映、履歴管理

補足: 共通列の createdAt は、このテーブルではイベント発生時刻ではなく保存時刻として扱います。ストア上の発生時刻は occurredAt に分離します。

補足: transactionKey は、Apple / Google の外部識別子を材料に組み立てる冪等キーです。初回リリースでは可読な連結文字列をそのまま保持する方式で十分に運用できます。

カラム長を varchar(512) とする理由や、可読キー方式から固定長キーへ移行する余地は 21-6. transactionKey の決め方 で詳しく説明します。ここでは、BillingTransaction冪等記録用キーを必ず持たせることを押さえてください。

16-16. InboundWebhookEvent

InboundWebhookEvent は、Apple App Store Server Notifications と Google RTDN の受信監査テーブルです。
通知は欠落も再送もありえるため、受け取ったか、署名検証したか、処理したか、どこで失敗したかを保存する受け皿が必要です。課金状態そのものではなく、通知処理の事実と再処理起点を持つためのテーブルとして位置づけます。

これはストア通知の状態そのものを保存するためのテーブルではありません。
自社の受信・検証・再処理運用を成立させるための監査テーブルとして持ちます。ストアコンソールだけでは、自社 API が通知を受け取れたか、検証に成功したか、どの段階で処理失敗したかまでは代替できません。

カラムMUST/SHOULDNULL/NOT NULLデフォルト値役割主に影響する要件
inboundWebhookEventIdMUSTuuidNOT NULLuuid()内部主キー
providerMUSTBillingProviderNOT NULL-APPLE / GOOGLE通知処理
providerEventIdMUSTvarchar(128)NOT NULL-プロバイダ側イベント ID。冪等化の主キー冪等性、通知処理
providerBusinessKeyMUSTvarchar(512)NULL-Apple の originalTransactionId、Google の purchaseToken など、業務的な追跡キー欠落補正、再処理、管理・調査
environmentMUSTStoreEnvironmentNULL-PRODUCTION / SANDBOX環境分離、管理・調査
eventTypeMUSTvarchar(64)NOT NULL-通知種別通知処理、決済異常検知、回復検知、猶予期間終了検知
verifiedMUSTbooleanNOT NULLfalse署名検証に成功したか通知処理、監査保存
processResultMUSTWebhookProcessResultNOT NULLRECEIVEDRECEIVED / PROCESSED / FAILED などの処理結果再処理、監視、欠落補正
rawPayloadMUSTjsonbNOT NULL-生 payload監査保存、手動復旧、管理・調査
firstReceivedAtMUSTtimestamptzNOT NULLnow()最初に受信した時刻通知処理、監視、管理・調査
processedAtMUSTtimestamptzNULL-投影処理まで完了した時刻通知処理、監視、欠落補正
subscriptionIdSHOULDuuidNULL-既知なら紐付ける Subscription管理・調査、手動復旧
processingErrorSHOULDtextNULL-失敗理由の詳細再処理、監視、手動復旧

補足: eventTypeprovider から受け取った raw の通知種別を監査目的で保持する列として string のままにします。内部で正規化したイベント分類が必要な場合は、BillingTransaction.eventType のような別 enum へ投影して扱います。

16-17. 一意制約と業務キー

一意制約は、表示上の自然キーではなく、冪等更新に必要なキーから決めます。

対象推奨キー意図
StoreProductMapping(provider, productId, basePlanIdNormalized, offerIdNormalized)同一ストア商品を一意に識別する
Subscription (Apple)appleOriginalTransactionId の partial uniqueApple 契約を 1 行へ固定する
Subscription (Google 現在 token)currentGooglePurchaseToken の partial unique現在 token の二重所属を防ぐ
GooglePurchaseTokenpurchaseTokentoken 履歴の重複保存を防ぐ
GooglePurchaseTokensubscriptionId WHERE isCurrent = true の partial unique1 購読に current token を 1 本だけ持つ
Entitlement(billingAccountId, planId)同一プランの利用権を重複発行しない
BillingTransactiontransactionKey同じ課金イベントの二重記録を防ぐ
InboundWebhookEvent(provider, providerEventId)通知再送を冪等化する

16-18. 本番向け migration 補足

Prisma の @@unique だけでは、nullable 列を含む partial unique を十分に表現できない場合があります。
本番運用では、必要に応じて raw SQL migration を併用します。

CREATE UNIQUE INDEX uq_subscription_apple_original_tx
ON "Subscription" ("provider", "appleOriginalTransactionId")
WHERE "provider" = 'APPLE' AND "appleOriginalTransactionId" IS NOT NULL;

CREATE UNIQUE INDEX uq_subscription_google_current_token
ON "Subscription" ("provider", "currentGooglePurchaseToken")
WHERE "provider" = 'GOOGLE' AND "currentGooglePurchaseToken" IS NOT NULL;

CREATE UNIQUE INDEX uq_google_purchase_token_current
ON "GooglePurchaseToken" ("subscriptionId")
WHERE "isCurrent" = true;

16-19. 初回リリースでは後回しにできる運用補助テーブル

初回リリースでは、16-5 で示した 8 テーブルをコアとして実装すれば課金機能の成立に必要な土台は揃います。
そのうえで、運用開始後に重要度が上がりやすいものとして、次のような運用補助テーブルがあります。

  • BillingRecoveryTask

    • 手動再照合、再投影、欠落補正の実行要求を記録する
    • 障害復旧や CS 対応で有用
  • BillingAdminActionLog

    • 管理画面や運用 API から行った再照合、紐付け修正、手動停止などを記録する
    • 管理・調査や監査で有用
  • BillingMonitoringCheckpoint

    • 通知停止、反映遅延、未処理件数などの監視状態を記録する
    • 監視やアラート設計で有用
  • EntitlementHistory

    • 利用可否の変化を検索しやすい形で履歴化する
    • CS 調査や利用権変化の説明で有用

これらはあると運用性が上がりますが、初回リリースで課金の成立に必須というより、管理・調査、手動復旧、監視を強化するための補助要素です。

17. ENUM 定義

この章では、DB とアプリケーションで共有して使う 閉じた内部語彙 を定義します。
方針として、内部で正規化した分類値は enumprovider から受け取る raw 値は string で扱います。

17-1. 定義方針

  • Apple / Google の差分を吸収した内部状態内部イベント分類は enum として定義する
  • provider から受け取る raw notification type / subtype のような値は string のまま保持する
  • Plan の課金周期は、汎用期間ではなく販売する商品期間として BillingPeriod で表現する
  • StoreEnvironment は enum とするが、入力時点で不明なケースに備えて DB 列自体は NULL を許容してよい

17-2. BillingProvider

意味
APPLEApple App Store を表す
GOOGLEGoogle Play を表す

主な利用箇所: StoreProductMapping.provider, Subscription.provider, BillingTransaction.provider, InboundWebhookEvent.provider

17-3. BillingPeriod

Plan の販売する商品期間です。
汎用的な期間表現ではなく、実際に販売するプラン単位を enum として定義します。
値集合は初回リリースで追加するプランだけに厳密に閉じず、サブスク商品として実務上採用しやすい代表的な期間をあらかじめ定義しています。一方で、enum に値があることはその期間のプランを初回リリースで販売することを意味しません。初回リリースで実際に使う値が MONTHLY のみであっても問題なく、QUARTERLY / SEMI_ANNUAL / YEARLY は将来のプラン追加余地として先に定義しておく想定です。

意味
MONTHLY月額プラン
QUARTERLY3 か月プラン
SEMI_ANNUAL6 か月プラン
YEARLY年額プラン

主な利用箇所: Plan.billingPeriod

17-4. StoreEnvironment

意味
PRODUCTION本番環境
SANDBOXテスト環境

主な利用箇所: BillingTransaction.environment, InboundWebhookEvent.environment

17-5. SubscriptionStatus

Subscription現在状態を表す内部正規化 enum です。

意味
PENDING購入開始直後などで、まだ利用可否を確定しきれていない状態
ACTIVE通常の有効契約
TRIALING無料トライアル中
IN_GRACE_PERIOD猶予期間中
BILLING_RETRY決済再試行中
ON_HOLDGoogle の account hold などで一時停止相当
PAUSEDGoogle の pause 状態
CANCELED自動更新停止済みだが、期限までは有効でありうる状態
EXPIRED契約期限切れ
REVOKED返金・取消などにより失効した状態

主な利用箇所: Subscription.status

17-6. AcknowledgementStatus

意味
NOT_REQUIREDacknowledgement が不要
PENDINGacknowledgement 未完了
ACKNOWLEDGEDacknowledgement 完了

主な利用箇所: Subscription.acknowledgementStatus

17-7. BillingEventType

BillingTransaction に記録する内部正規化イベント分類です。InboundWebhookEvent.eventType の raw 値とは役割が異なります。

意味
PURCHASED初回購入
RENEWED契約更新
RECOVERED支払い問題等からの回復
CANCELED自動更新停止
RESTARTED停止していた自動更新の再開
PAUSEDpause 開始
RESUMEDpause 解除
ENTERED_GRACE_PERIOD猶予期間入り
ENTERED_BILLING_RETRY決済再試行状態入り
ENTERED_ON_HOLDaccount hold 入り
EXPIRED契約期限切れ
REVOKED返金・取消等による失効
REFUNDED返金
PRODUCT_CHANGEDプラン / 商品変更

主な利用箇所: BillingTransaction.eventType

17-8. EntitlementReason

Entitlement の利用可否理由です。状態そのものではなく、なぜ今その可否なのかを説明するために使います。

意味
ACTIVE有効契約に基づき利用可
IN_GRACE_PERIOD猶予期間中のため利用可
ACTIVE_UNTIL_EXPIRATION自動更新停止済みだが期限までは利用可
PENDING反映待ち等で確定前
EXPIRED期限切れにより利用不可
REVOKED返金・取消等により利用不可

主な利用箇所: Entitlement.reason

17-9. WebhookProcessResult

意味
RECEIVED受信済みだが未処理
PROCESSED投影処理まで完了
FAILED処理失敗
SKIPPED重複通知などで処理不要

主な利用箇所: InboundWebhookEvent.processResult

17-10. string のまま扱う分類値

InboundWebhookEvent.eventTypestring のままにします。
この列は Apple / Google から受け取った raw の通知種別を監査目的で保持するものであり、provider ごとの差分や将来追加される値をそのまま保存できることが重要だからです。

内部で共通化したイベント分類が必要な場合は、BillingTransaction.eventType のような別 enum へ投影して扱います。

17-11. Prisma enum 定義例

enum BillingProvider {
  APPLE
  GOOGLE
}

enum BillingPeriod {
  MONTHLY
  QUARTERLY
  SEMI_ANNUAL
  YEARLY
}

enum StoreEnvironment {
  PRODUCTION
  SANDBOX
}

enum SubscriptionStatus {
  PENDING
  ACTIVE
  TRIALING
  IN_GRACE_PERIOD
  BILLING_RETRY
  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_BILLING_RETRY
  ENTERED_ON_HOLD
  EXPIRED
  REVOKED
  REFUNDED
  PRODUCT_CHANGED
}

enum EntitlementReason {
  ACTIVE
  IN_GRACE_PERIOD
  ACTIVE_UNTIL_EXPIRATION
  PENDING
  EXPIRED
  REVOKED
}

enum WebhookProcessResult {
  RECEIVED
  PROCESSED
  FAILED
  SKIPPED
}

18. Prisma スキーマ例

この章では、16 章と 17 章の設計を Prisma へ落とし込んだ例を示します。
以下は GooglePurchaseToken を含むフル構成例 です。

acknowledgementStatus は provider 共通列ですが、Prisma 側でも default は置かず、projection / upsert 時に明示代入する前提にします。
そのままコピペするための最終版ではなく、設計意図と制約の置き方を示す叩き台として使ってください。

この章の model 例では、Prisma の scalar type と relation / default / index の置き方を読みやすくするため、@db.Uuid@db.VarChar(128) などの PostgreSQL 物理型 annotation は省略しています。
一方で 16 章のカラム表は PostgreSQL を前提にした物理型を記載しています。したがって、18 章の String / DateTime / Json は、16 章の uuid / varchar(...) / timestamptz / jsonb と対応づけて読んでください。
また、enum の完全な値一覧は 17 章を正とし、この章ではそれを Prisma でどう表現するかに集中します。

この例では、Prisma スキーマ上の主キーは planId / billingAccountId のような camelCase で表します。

18-1. model 例

以下の BillingAccount.userId は、User.id が不変である前提の簡略例です。退会・再入会でユーザー ID が変わる設計なら、実装では persistentUserId などへ読み替えてください。なお、accountTokenBillingAccount 作成時に生成される前提なので、例でも @default(uuid()) を付けています。

model BillingAccount {
  billingAccountId            String         @id @default(uuid())
  userId                      String         @unique
  accountToken                String         @unique @default(uuid())
  hasStartedTrialAtLeastOnce  Boolean        @default(false)
  firstTrialStartedAt         DateTime?
  lastRecoveredAt             DateTime?
  createdAt                   DateTime       @default(now())
  updatedAt                   DateTime       @updatedAt

  subscriptions               Subscription[]
  entitlements                Entitlement[]
}

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

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

model StoreProductMapping {
  storeProductMappingId  String          @id @default(uuid())
  provider               BillingProvider
  planId                 String
  productId              String
  basePlanId             String?
  offerId                String?
  basePlanIdNormalized   String          @default("")
  offerIdNormalized      String          @default("")
  isActive               Boolean         @default(true)
  createdAt              DateTime        @default(now())
  updatedAt              DateTime        @updatedAt

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

  @@unique([provider, productId, basePlanIdNormalized, offerIdNormalized], map: "uq_store_product_mapping_lookup")
  @@index([planId, provider])
}

model Subscription {
  subscriptionId                    String                 @id @default(uuid())
  provider                          BillingProvider
  billingAccountId                  String
  planId                            String
  status                            SubscriptionStatus
  statusReason                      String?
  acknowledgementStatus             AcknowledgementStatus
  currentPeriodStartAt              DateTime?
  currentPeriodEndAt                DateTime?
  gracePeriodEndsAt                 DateTime?
  canceledAt                        DateTime?
  expiresAt                         DateTime?
  appleOriginalTransactionId        String?
  appleLatestTransactionId          String?
  appleAppAccountToken              String?
  currentGooglePurchaseToken        String?
  googleObfuscatedExternalAccountId String?
  googleObfuscatedExternalProfileId String?
  lineItemsLatestSuccessfulOrderId  String?
  lastVerifiedAt                    DateTime?
  createdAt                         DateTime               @default(now())
  updatedAt                         DateTime               @updatedAt

  billingAccount                    BillingAccount         @relation(fields: [billingAccountId], references: [billingAccountId], onDelete: Cascade)
  plan                              Plan                   @relation(fields: [planId], references: [planId], onDelete: Restrict)
  googlePurchaseTokens              GooglePurchaseToken[]
  transactions                      BillingTransaction[]
  inboundEvents                     InboundWebhookEvent[]
  sourceEntitlements                Entitlement[]          @relation("EntitlementSource")

  @@index([billingAccountId, status])
  @@index([provider, planId, status])
  @@index([provider, appleOriginalTransactionId])
  @@index([provider, appleLatestTransactionId])
  @@index([provider, currentGooglePurchaseToken])
}

model GooglePurchaseToken {
  googlePurchaseTokenId            String          @id @default(uuid())
  subscriptionId                   String
  purchaseToken                    String          @unique
  linkedFromPurchaseToken          String?
  isCurrent                        Boolean         @default(false)
  lineItemsLatestSuccessfulOrderId String?
  createdAt                        DateTime        @default(now())
  invalidatedAt                    DateTime?

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

  @@index([subscriptionId, isCurrent])
  @@index([linkedFromPurchaseToken])
}

model Entitlement {
  entitlementId        String             @id @default(uuid())
  billingAccountId     String
  planId               String
  isActive             Boolean
  reason               EntitlementReason
  startsAt             DateTime?
  endsAt               DateTime?
  sourceSubscriptionId String?
  createdAt            DateTime           @default(now())
  updatedAt            DateTime           @updatedAt

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

  @@unique([billingAccountId, planId])
  @@index([billingAccountId, isActive])
  @@index([sourceSubscriptionId])
}

model BillingTransaction {
  billingTransactionId        String            @id @default(uuid())
  subscriptionId              String
  provider                    BillingProvider
  environment                 StoreEnvironment?
  eventType                   BillingEventType
  transactionKey              String            @unique
  storeTransactionId          String?
  orderId                     String?
  purchaseToken               String?
  appleOriginalTransactionId  String?
  googleLinkedPurchaseToken   String?
  occurredAt                  DateTime
  purchasedAt                 DateTime?
  expiresAt                   DateTime?
  revokedAt                   DateTime?
  rawSnapshot                 Json?
  createdAt                   DateTime          @default(now())

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

  @@index([subscriptionId, occurredAt])
  @@index([provider, storeTransactionId])
  @@index([provider, orderId])
  @@index([provider, purchaseToken])
  @@index([provider, appleOriginalTransactionId])
}

model InboundWebhookEvent {
  inboundWebhookEventId  String               @id @default(uuid())
  subscriptionId         String?
  provider               BillingProvider
  environment            StoreEnvironment?
  providerEventId        String
  providerBusinessKey    String?
  eventType              String
  verified               Boolean              @default(false)
  processResult          WebhookProcessResult @default(RECEIVED)
  processingError        String?
  rawPayload             Json
  firstReceivedAt        DateTime             @default(now())
  processedAt            DateTime?

  subscription           Subscription?        @relation(fields: [subscriptionId], references: [subscriptionId], onDelete: SetNull)

  @@unique([provider, providerEventId])
  @@index([provider, providerBusinessKey])
  @@index([provider, firstReceivedAt])
  @@index([subscriptionId])
}

18-2. スキーマの読み方

このスキーマ例では、次の意図を持たせています。

  • Subscription は現在状態
  • GooglePurchaseToken は Google token 履歴の真実源泉
  • Apple の契約系列は appleOriginalTransactionId で固定し、Apple 専用系列テーブルは置かない
  • Entitlement は利用可否の現在値であり、reason を持たせて説明可能にする
  • EntitlementbillingAccountId + planId で一意な read model / projection である
  • sourceSubscriptionId は entitlement の「唯一の親」ではなく、主な source を指す補助参照である
  • sourceSubscriptionId は nullable であり、SubscriptionEntitlement の厳密な 1:1 を保証しない
  • Subscription.sourceEntitlements を配列で表しているのは、モデル上 1:1 固定にしないためである
  • BillingTransaction は履歴であり、環境差分と系列キーも必要に応じて保持する
  • InboundWebhookEvent は受信イベントの監査と再処理の起点であり、providerBusinessKeyprocessResult を持たせる
  • Google の主要 order ID は lineItems.latest_successful_order_id ベースで扱う
  • current scope では見かけ上 1:1 に近く見えることがあっても、モデル上は strict な 1:1 を前提にしない
  • 表と Prisma スキーマ例では、主キーも planId / billingAccountId のような camelCase で表現する

18-3. Prisma だけでは表現しづらい制約

Prisma の @@unique は便利ですが、次のような制約は raw SQL migration を併用したほうが安全です。

  • Apple の appleOriginalTransactionId を provider 条件付きで一意にしたい
  • Google の currentGooglePurchaseToken を provider 条件付きで一意にしたい
  • GooglePurchaseTokenisCurrent = true を subscription ごとに 1 本へ制限したい
CREATE UNIQUE INDEX uq_subscription_apple_original_tx
ON "Subscription" ("provider", "appleOriginalTransactionId")
WHERE "provider" = 'APPLE' AND "appleOriginalTransactionId" IS NOT NULL;

CREATE UNIQUE INDEX uq_subscription_google_current_token
ON "Subscription" ("provider", "currentGooglePurchaseToken")
WHERE "provider" = 'GOOGLE' AND "currentGooglePurchaseToken" IS NOT NULL;

CREATE UNIQUE INDEX uq_google_purchase_token_current
ON "GooglePurchaseToken" ("subscriptionId")
WHERE "isCurrent" = true;

18-4. migration 時の注意

  • 既存データがある環境で unique index を追加する前に重複を洗い出す
  • rawSnapshotrawPayload は JSONB を想定し、インデックスを貼りすぎない
  • basePlanIdNormalized / offerIdNormalized は手動更新しない内部補助列とし、insert / update ごとにアプリケーション層で元値と同期する
  • Subscription.statusEntitlement.isActive を同一列で済ませない
  • Google の top-level latestOrderId は deprecated のため、新規実装では主要フィールドとして採用しない

19. NestJS / TypeScript 実装構成例

19-1. 推奨ディレクトリ構成

モノレポで運用する場合は、api 用の NestJS application と batch 用の NestJS application を分け、課金ロジックは library に寄せる 形が最も扱いやすくなります。

apps/
  api/
    src/
      main.ts
      app.module.ts
      modules/
        billing/
          controllers/
            apple-billing-ingest.controller.ts
            google-billing-ingest.controller.ts
            billing-admin-reconcile.controller.ts
          dto/
            apple-ingest.request.ts
            google-ingest.request.ts
            billing-reconcile.request.ts
        settings/
          controllers/
            my-settings.controller.ts
        webhooks/
          controllers/
            apple-billing-notifications.controller.ts
            google-billing-rtdn.controller.ts
  batch/
    src/
      main.ts
      app.module.ts
      jobs/
        failed-event-retry.job.ts
        notification-gap-reconcile.job.ts
        google-ack-recovery.job.ts
        expiring-subscription-reconcile.job.ts
      commands/
        billing-backfill.command.ts
        billing-reconcile.command.ts
libs/
  billing/
    domain/
      subscription-state.ts
      entitlement-state.ts
    application/
      services/
        billing-projection.service.ts
        entitlement-query.service.ts
        billing-reconcile.service.ts
        restore.service.ts
    infrastructure/
      prisma/
        prisma.service.ts
      providers/
        apple/
          apple-client.service.ts
          apple-notification-verifier.service.ts
          apple-projection.service.ts
        google/
          google-client.service.ts
          google-acknowledgement.service.ts
          google-projection.service.ts
      repositories/
        billing-account.repository.ts
        subscription.repository.ts
        webhook-event.repository.ts
  shared/
    locks/
    time/
    logging/

apibatch同じコードベースに統合してよい ですが、同じプロセスで動かす必要はありません
運用上は、api app と batch app を別エントリポイント・別プロセスとして起動し、共通ロジックだけを library で共有する形を推奨します。
現在利用可否取得責務を既存の画面取得 API に含める設計なら、entitlement-query.service.ts は library 側に置いたまま、settings などの画面系 module から呼ぶ形にすると自然です。

19-2. controller 命名と責務分割の原則

controller 名は、誰向けの API か何をする API か がファイル名と class 名で分かる形にすると保守しやすくなります。

API名Path推奨 controller class推奨ファイル名
Apple購入情報反映API/billing/apple/ingestAppleBillingIngestControllerapple-billing-ingest.controller.ts
Google購入情報反映API/billing/google/ingestGoogleBillingIngestControllergoogle-billing-ingest.controller.ts
Apple通知受信API/webhooks/apple/notificationsAppleBillingNotificationsControllerapple-billing-notifications.controller.ts
Google通知受信API/webhooks/google/rtdnGoogleBillingRtdnControllergoogle-billing-rtdn.controller.ts
利用可否取得API/me/entitlementMyEntitlementControllermy-entitlement.controller.ts
課金状態再照合API/admin/billing/reconcileBillingAdminReconcileControllerbilling-admin-reconcile.controller.ts
契約詳細取得API/me/billing/subscriptionMyBillingSubscriptionControllermy-billing-subscription.controller.ts

特に、現在利用可否取得責務を既存の画面 API に含める場合は、EntitlementController のような抽象的な名前を増やすより、MySettingsController のように画面 API 側の controller は画面責務の名前にし、内部で EntitlementQueryService を呼ぶほうが責務が明確です。

  • Controller: 入出力の整形だけを担当する
  • Service: ユースケースの流れを組み立てる
  • Provider service: Apple / Google 固有の API 呼び出しを担当する
  • Repository: Prisma を隠蔽する
  • Projection service: ストア状態を内部状態へ変換する
  • Job: 補正・再処理・救済を担当する

19-3. provider ごとの分離

Apple と Google は似ていますが、細部はかなり違います。
そのため、if provider === "APPLE" の分岐を 1 つの巨大 service に集めるより、provider ごとに service を分けるほうが保守しやすくなります。

19-4. TypeScript で押さえるとよい型

export type BillingProvider = "APPLE" | "GOOGLE";

export type ProjectionInput =
  | {
      provider: "APPLE";
      transactionId: string;
    }
  | {
      provider: "GOOGLE";
      purchaseToken: string;
      packageName?: string;
      productId?: string;
    };

export type ProjectionResult = {
  subscriptionId: string;
  billingAccountId: string;
  status: string;
  entitlementActive: boolean;
  requiresAcknowledgement: boolean;
};

20. API 実装サンプル

20-1. ingest DTO 例

import { IsIn, IsNotEmpty, IsOptional, IsString } from "class-validator";

export class AppleIngestRequest {
  @IsIn(["purchase", "restore"])
  action!: "purchase" | "restore";

  @IsString()
  @IsNotEmpty()
  transactionId!: string;
}

export class GoogleIngestRequest {
  @IsIn(["purchase", "restore"])
  action!: "purchase" | "restore";

  @IsString()
  @IsNotEmpty()
  purchaseToken!: string;

  @IsOptional()
  @IsString()
  @IsNotEmpty()
  packageName?: string;

  @IsOptional()
  @IsString()
  @IsNotEmpty()
  productId?: string;
}

ingest API では accountToken を必須入力にしません。現在ユーザーは認証済みコンテキストから特定し、Apple は transactionId、Google は purchaseToken を主入力として受けます。
Google ingest API の最小必須は purchaseToken です。packageName / productId は、追加検証や監査ログのために受け取る任意項目として扱うと、Part 1 の設計方針と整合します。

20-2. controller 例

以下は、専用の GET /me/entitlement を切り出す場合の例です。
controller 名も、API 名と path から責務が連想しやすい形に寄せています。

import { Body, Controller, Get, Post, Req } from "@nestjs/common";

@Controller("billing/apple")
export class AppleBillingIngestController {
  constructor(private readonly billingService: BillingProjectionService) {}

  @Post("ingest")
  async ingest(@Req() req: AuthenticatedRequest, @Body() request: AppleIngestRequest) {
    return this.billingService.ingestApplePurchase(req.user.id, request);
  }
}

@Controller("billing/google")
export class GoogleBillingIngestController {
  constructor(private readonly billingService: BillingProjectionService) {}

  @Post("ingest")
  async ingest(@Req() req: AuthenticatedRequest, @Body() request: GoogleIngestRequest) {
    return this.billingService.ingestGooglePurchase(req.user.id, request);
  }
}

@Controller("me")
export class MyEntitlementController {
  constructor(private readonly entitlementQueryService: EntitlementQueryService) {}

  @Get("entitlement")
  async getMyEntitlement(@Req() req: { user: { id: string } }) {
    return this.entitlementQueryService.getCurrentEntitlements(req.user.id);
  }
}

既存の画面 API に entitlement を含める場合は、次のように同じ query service を再利用できます。

import { Controller, Get, Req } from "@nestjs/common";

@Controller("me")
export class MySettingsController {
  constructor(
    private readonly settingsQueryService: SettingsQueryService,
    private readonly entitlementQueryService: EntitlementQueryService,
  ) {}

  @Get("settings")
  async getSettings(@Req() req: { user: { id: string } }) {
    const [settings, entitlement] = await Promise.all([
      this.settingsQueryService.getSettings(req.user.id),
      this.entitlementQueryService.getCurrentEntitlements(req.user.id),
    ]);

    return {
      ...settings,
      entitlement,
    };
  }
}

20-3. service の入口例

export class BillingProjectionService {
  constructor(
    private readonly appleProjectionService: AppleProjectionService,
    private readonly googleProjectionService: GoogleProjectionService,
  ) {}

  async ingestApplePurchase(currentUserId: string, request: AppleIngestRequest) {
    return this.appleProjectionService.ingest(currentUserId, request);
  }

  async ingestGooglePurchase(currentUserId: string, request: GoogleIngestRequest) {
    return this.googleProjectionService.ingest(currentUserId, request);
  }
}

20-4. webhook controller 例

import { Body, Controller, Headers, Post, Req } from "@nestjs/common";

@Controller("webhooks/apple")
export class AppleBillingNotificationsController {
  constructor(private readonly webhookDispatcherService: WebhookDispatcherService) {}

  @Post("notifications")
  async handle(
    @Body() body: { signedPayload: string },
    @Headers() headers: Record<string, string>,
  ) {
    await this.webhookDispatcherService.handleAppleNotification(body, headers);
    return { ok: true };
  }
}

@Controller("webhooks/google")
export class GoogleBillingRtdnController {
  constructor(private readonly webhookDispatcherService: WebhookDispatcherService) {}

  @Post("rtdn")
  async handle(@Body() body: unknown, @Req() req: { headers: Record<string, string> }) {
    await this.webhookDispatcherService.handleGoogleRtdn(body, req.headers);
    return { ok: true };
  }
}

20-5. POST /billing/apple/ingest の詳細

API 名: Apple購入情報反映API

20-5-1. 役割

Apple で発生した購入や復元の入口です。
クライアントが transactionId を持ってきたときに、その値をそのまま信用せず、サーバ側で Apple へ再照会して内部状態へ反映します。現在ユーザーは認証済みコンテキストから特定し、appAccountToken購入開始時に埋め込まれてストア再照会結果から返ってくる値として扱います。

20-5-2. いつ呼ばれるか

  • 購入完了直後
  • 復元導線を踏んだ直後
  • クライアントが再同期を要求したとき

20-5-3. 推奨リクエスト

{
  "action": "purchase",
  "transactionId": "2000001234567890"
}

または復元時:

{
  "action": "restore",
  "transactionId": "2000001234567890"
}

20-5-4. サーバ側の基本動作

  1. 認証済みの現在ユーザーに対応する BillingAccount を取得する
  2. actionpurchaserestore かを確認する
  3. 冪等性チェックを行う
  4. transactionId を使って App Store Server API を呼ぶ
  5. 必要に応じて Get All Subscription Statuses などで補足確認する
    • 主要 endpoint は、保存済みの transaction identifier を path parameter として利用できる
  6. ストア再照会で得た appAccountToken と、現在ユーザーの BillingAccount.accountToken、既存の originalTransactionId 系列を合わせて見て紐付け可否を判定する
  7. Subscription / Entitlement / 履歴を更新する
  8. クライアント向けの最終利用可否を返す

20-5-5. この endpoint に持たせるべき責務

  • やること
    • Apple への再照会
    • 内部状態への projection
    • 復元時の紐付け判定
    • appAccountToken と契約系列の両方を使った照合
  • やらないこと
    • クライアント申告だけで entitlement を付ける
    • accountToken を購入後入力の必須パラメータとして扱う
    • Apple 通知待ちだけで初回反映を完了させる
    • UI 向けの詳細表示情報を過剰に返す

20-5-6. 主な呼び出し場面

場面A. 購入完了直後の最短反映

StoreKit で購入成功後、クライアントは transactionId を取得してこの API を呼びます。
appAccountToken 自体はこの時点で送るのではなく、購入開始時にすでに埋め込まれています。サーバは Apple へ再照会し、返ってきた appAccountToken と現在ユーザーの BillingAccount を照合したうえで、購入直後に SubscriptionEntitlement を更新します。
この経路があることで、Apple 通知を待たずに有料機能を開放できます。

場面B. 復元ボタン押下後の再同期

ユーザーが別端末や再インストール後に「購入を復元」を実行したとき、クライアントは復元対象の transactionId を送ってこの API を呼びます。
サーバは Apple 側証跡、現在ログイン中のユーザーに紐づく BillingAccount.accountToken、既存の originalTransactionId 系列を照合し、紐付け可能であれば状態を反映します。

場面C. 購入直後の応答取りこぼしからの再試行

クライアントで購入自体は完了したものの、アプリの再起動や通信断で前回の反映結果を受け取れなかった場合、クライアントは再度この API を呼びます。
サーバ側は冪等に処理し、既に反映済みなら同じ最終状態を返します。

20-5-7. シーケンス図

sequenceDiagram
    autonumber
    actor User as User
    participant App as iOS App
    participant API as POST /billing/apple/ingest
    participant Auth as Auth Guard
    participant Idem as Idempotency Check
    participant Queue as Job Queue
    participant Worker as Billing Worker
    participant Apple as App Store Server API
    participant Projector as Projection Service
    participant DB as App DB

    User->>App: Complete purchase or tap restore
    App->>API: action, transactionId
    API->>Auth: Authenticate current user
    Auth-->>API: userId
    API->>DB: Load BillingAccount by current user
    DB-->>API: billingAccountId, accountToken
    API->>API: Validate request payload
    API->>Idem: Check duplicate by userId + action + transactionId
    alt Already completed recently
        Idem-->>API: existing result
        API-->>App: current entitlement / projection result
    else Needs processing
        Idem-->>API: continue
        API->>DB: Save ingest request audit log
        API->>Queue: Enqueue apple ingest job
        Queue-->>API: accepted
        API->>Worker: Start job
        Worker->>Apple: Get Transaction Info(transactionId)
        Apple-->>Worker: transaction data with appAccountToken
        opt Additional confirmation is needed
            Worker->>Apple: Get All Subscription Statuses(originalTransactionId or transactionId)
            Apple-->>Worker: latest subscription statuses
        end
        Worker->>Worker: Compare appAccountToken and contract lineage
        Worker->>Projector: Map store state to internal state
        Projector->>DB: Upsert Subscription
        Projector->>DB: Upsert Entitlement
        Projector->>DB: Insert BillingTransaction / audit history
        DB-->>Projector: committed
        Projector-->>Worker: final entitlement
        Worker-->>API: final result
        API-->>App: projection result + entitlement
    end
    App-->>User: Unlock premium features or show current state

20-5-8. 補足

復元専用の別 endpoint を切るより、action=restore を受ける形にして、検証と projection の本体を新規購入と共通化するほうが安全です。

20-6. POST /billing/google/ingest の詳細

API 名: Google購入情報反映API

20-6-1. 役割

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

20-6-2. いつ呼ばれるか

  • 購入完了直後
  • 復元導線を踏んだ直後
  • pending purchase が purchased へ進んだあとに再同期するとき

20-6-3. 推奨リクエスト

{
  "action": "purchase",
  "purchaseToken": "example_purchase_token"
}

または復元時:

{
  "action": "restore",
  "purchaseToken": "example_purchase_token"
}

20-6-4. 最小必須項目の考え方

Google の ingest API は、公開 API の最小構成では purchaseToken を必須にしておけば十分です。
ただし、バックエンドが purchases.subscriptionsv2.get を呼ぶ際には、purchaseToken に加えて packageName も必要です。packageName は、対象アプリのサーバ設定、認証済みクライアントの文脈、またはクライアントから任意で受け取った申告値から補完します。productId などの追加情報はストア応答から確認できます。

ただし、実運用では次の目的で packageNameproductId任意で受け取る 設計も有効です。

  • 想定外のアプリや商品に対する token を早期に弾く
  • 監査ログにクライアント申告値を残す
  • クライアント実装とサーバ実装の不整合を検知する

したがって、本ドキュメントでは purchaseToken を最小必須packageName / productId追加検証用の任意項目 として扱います。

20-6-5. サーバ側の基本動作

  1. 認証済みの現在ユーザーに対応する BillingAccount を取得する
  2. actionpurchaserestore かを確認する
  3. 冪等性チェックを行う
  4. purchaseToken と補完済みの packageName を使って purchases.subscriptionsv2.get を呼ぶ
  5. 状態、帰属先、line item を確認する
  6. ストア再照会で得た externalAccountIdentifiers.obfuscatedExternalAccountId と、現在ユーザーの BillingAccount.accountToken、既存の linkedPurchaseToken 系列を合わせて見て紐付け可否を判定する
  7. Subscription / Entitlement / 履歴を更新する
  8. entitlement 付与可能で、かつ new purchase token を伴う未 acknowledgement purchase なら acknowledgement を実行する
  9. クライアント向けの最終利用可否を返す

20-6-6. この endpoint に持たせるべき責務

  • やること
    • Google への再照会
    • pending / active / expired などの内部状態化
    • acknowledgement 要否の判定
    • obfuscatedExternalAccountId と契約系列の両方を使った照合
  • やらないこと
    • purchaseToken の文字列だけで有効判定する
    • クライアント申告だけで entitlement を付ける
    • accountToken を購入後入力の必須パラメータとして扱う
    • renewal ごとに毎回 acknowledgement する

20-6-7. 主な呼び出し場面

場面A. 購入完了直後の最短反映

Billing Library で購入成功後、クライアントは purchaseToken を取得してこの API を呼びます。
obfuscatedExternalAccountId 自体はこの時点で送るのではなく、購入開始時にすでに埋め込まれています。サーバは Google API へ再照会し、返ってきた obfuscatedExternalAccountId と現在ユーザーの BillingAccount を照合したうえで、必要なら acknowledgement まで行って SubscriptionEntitlement を反映します。

場面B. 復元導線からの再同期

ユーザーが機種変更後や再ログイン後に既存の契約を同期したい場合、クライアントは復元対象の purchaseToken を送ってこの API を呼びます。
サーバは Google 側状態、現在ログイン中のユーザーに紐づく BillingAccount.accountToken、既存の linkedPurchaseToken 系列を確認し、反映可能なものだけを内部状態へ反映します。

場面C. pending から purchased への遷移後の再反映

Google では pending purchase が後から purchased に進むことがあります。
このときクライアントが再同期を行い、同じ purchaseToken でこの API を再度呼ぶことで、サーバは entitlement 付与と acknowledgement を完了できます。

20-6-8. シーケンス図

sequenceDiagram
    autonumber
    actor User as User
    participant App as Android App
    participant API as POST /billing/google/ingest
    participant Auth as Auth Guard
    participant Idem as Idempotency Check
    participant Queue as Job Queue
    participant Worker as Billing Worker
    participant Google as Google Play Developer API
    participant Projector as Projection Service
    participant DB as App DB

    User->>App: Complete purchase / restore / retry after pending
    App->>API: action, purchaseToken, optional packageName/productId
    API->>Auth: Authenticate current user
    Auth-->>API: userId
    API->>DB: Load BillingAccount by current user
    DB-->>API: billingAccountId, accountToken
    API->>API: Validate request payload
    API->>Idem: Check duplicate by userId + action + purchaseToken
    alt Already completed recently
        Idem-->>API: existing result
        API-->>App: current entitlement / projection result
    else Needs processing
        Idem-->>API: continue
        API->>DB: Save ingest request audit log
        API->>Queue: Enqueue google ingest job
        Queue-->>API: accepted
        API->>Worker: Start job
        Worker->>Google: purchases.subscriptionsv2.get(purchaseToken)
        Google-->>Worker: subscription resource with obfuscatedExternalAccountId
        Worker->>Worker: Compare obfuscatedExternalAccountId and linkedPurchaseToken lineage
        Worker->>Worker: Determine latest line item and entitlement state
        alt New purchase token and acknowledgement required
            Worker->>Google: purchases.subscriptions.acknowledge
            Google-->>Worker: acknowledged
        else Renewal or already acknowledged
            Worker->>Worker: Skip acknowledgement
        end
        Worker->>Projector: Map store state to internal state
        Projector->>DB: Upsert Subscription
        Projector->>DB: Upsert Entitlement
        Projector->>DB: Insert BillingTransaction / audit history
        DB-->>Projector: committed
        Projector-->>Worker: final entitlement
        Worker-->>API: final result
        API-->>App: projection result + entitlement
    end
    App-->>User: Unlock premium features or show current state

20-6-9. 補足

renewal は通常この API の主経路ではありません。
自動更新、解約、支払い問題の追従は、後述する RTDN と再照合ジョブを主入口にします。

20-7. GET /me/entitlement の詳細

API 名: 利用可否取得API

20-7-1. 役割

GET /me/entitlement は、クライアントが最終的な利用可否を確認するための専用 read modelです。
アプリ側はストア API の生レスポンスを解釈せず、この endpoint の結果だけを見て有料機能の開閉を判断する ことができます。

ただし、専用 endpoint を別途用意すること自体は MUST ではありません。
既存の XXX画面取得API や BFF のレスポンスに entitlement を含める設計でも問題ありません。

20-7-2. この endpoint が向いている場合

  • 複数画面で同じ entitlement 情報を横断利用する
  • 購入直後や復元直後に entitlement だけを軽く再取得したい
  • クライアント実装として「利用可否はこの API を見ればよい」と単純化したい

このような要件がある場合は、専用の GET /me/entitlement を切り出す価値があります。

20-7-3. 既存画面 API に含める場合の考え方

既存の XXX画面取得API に entitlement を含める設計でも構いません。
その場合でも、次の 3 点は崩さないようにします。

  • entitlement 判定ロジックを画面ごとに分散させない
  • isActivereason などの意味を API ごとにぶらさない
  • ストア状態の最終判断は必ずサーバ側で行う

つまり、外部 API は分かれていても、内部では同じ EntitlementQueryService や read model を参照する 形が望ましいです。

20-7-4. 返す情報の最小構成

例:

{
  "planCode": "PRO_MONTHLY",
  "isActive": true,
  "reason": "ACTIVE",
  "effectiveUntil": "2026-03-31T12:00:00Z"
}

最低限、次を返せば十分です。

  • 現在有効か
  • どの理由で有効 / 無効か
  • いつまで有効か
  • 必要なら plan の識別子

20-7-5. この endpoint に持たせないほうがよいもの

  • ストア生レスポンスそのもの
  • 管理画面向けの監査情報
  • 復元候補一覧のような複雑な運用情報

20-7-6. 主な呼び出し場面

場面A. アプリ起動時や画面表示時の利用可否確認

クライアントは、ホーム画面や設定画面を開く前に現在の利用可否だけを知りたいことがあります。
その場合、この API を呼んで isActiveeffectiveUntil を取得し、UI の表示可否を決めます。

場面B. 購入直後・復元直後の反映確認

/billing/*/ingest の完了後に、クライアントが別途最新の read model を取り直したい場合があります。
この API を用意しておくと、購入反映と表示取得を役割分担しやすくなります。

場面C. 既存画面 API ではなく共通 read model が欲しい場合

複数画面から同じ利用可否を参照する構成では、画面 API ごとに entitlement を埋め込むより、この API を共通 read model として呼ぶほうが分かりやすいことがあります。

20-7-7. シーケンス図

sequenceDiagram
    autonumber
    actor User as User
    participant App as Mobile App
    participant API as GET /me/entitlement
    participant Auth as Auth Guard
    participant Query as EntitlementQueryService
    participant DB as App DB

    User->>App: Open premium-related screen
    App->>API: GET /me/entitlement
    API->>Auth: Authenticate current user
    Auth-->>API: userId
    API->>Query: Build entitlement view model for userId
    Query->>DB: Read current Entitlement
    DB-->>Query: entitlement row
    opt Additional plan summary is needed
        Query->>DB: Read Plan / StoreProductMapping
        DB-->>Query: plan metadata
    end
    Query->>Query: Normalize response fields(isActive, reason, effectiveUntil)
    Query-->>API: entitlement response
    API-->>App: 200 OK + entitlement
    App-->>User: Show or hide premium UI

20-7-8. 既存画面 API に含める場合のシーケンス図

sequenceDiagram
    autonumber
    actor User as User
    participant App as Mobile App
    participant ScreenAPI as GET /settings
    participant Auth as Auth Guard
    participant ScreenQuery as SettingsQueryService
    participant EntQuery as EntitlementQueryService
    participant DB as App DB

    User->>App: Open settings screen
    App->>ScreenAPI: GET /settings
    ScreenAPI->>Auth: Authenticate current user
    Auth-->>ScreenAPI: userId
    par Build screen data
        ScreenAPI->>ScreenQuery: Load settings view model
        ScreenQuery->>DB: Read settings-related data
        DB-->>ScreenQuery: settings data
    and Build entitlement summary
        ScreenAPI->>EntQuery: Load entitlement summary
        EntQuery->>DB: Read Entitlement
        DB-->>EntQuery: entitlement row
        opt Additional billing label is needed
            EntQuery->>DB: Read Plan metadata
            DB-->>EntQuery: plan label
        end
        EntQuery-->>ScreenAPI: entitlement summary
    end
    ScreenQuery-->>ScreenAPI: settings view model
    ScreenAPI->>ScreenAPI: Compose single response payload
    ScreenAPI-->>App: settings data + entitlement
    App-->>User: Render billing status in screen

20-8. GET /me/billing/subscription の詳細

API 名: 契約詳細取得API

設定画面や契約詳細画面で、既存の画面 API や GET /me/entitlement より多い情報を返したい場合に追加を検討します。

20-8-1. 役割

この API は、利用可否判定の中核 ではなく、表示や説明責務のための read model です。
そのため、初回リリースでは無理に追加せず、必要になったら分けて増やすほうが安全です。

20-8-2. 返却候補

  • planCode
  • store
  • status
  • willRenew
  • effectiveUntil
  • inTrial
  • inGracePeriod

20-8-3. レスポンス例

{
  "planCode": "premium_monthly",
  "store": "APPLE",
  "status": "ACTIVE",
  "willRenew": true,
  "effectiveUntil": "2026-03-31T12:00:00Z",
  "inTrial": false,
  "inGracePeriod": false
}

20-8-4. 主な呼び出し場面

  • 設定画面で「次回更新日」「自動更新の有無」「試用中かどうか」まで表示したいとき
  • 課金管理画面で、単なる isActive 以上の契約説明が必要なとき
  • CS 向けではなく、一般ユーザー向けの表示責務として契約詳細を返したいとき

20-8-5. シーケンス図

sequenceDiagram
    autonumber
    actor User as User
    participant App as Mobile App
    participant API as GET /me/billing/subscription
    participant Auth as Auth Guard
    participant Query as SubscriptionReadModelService
    participant DB as App DB

    User->>App: Open billing detail screen
    App->>API: GET /me/billing/subscription
    API->>Auth: Authenticate current user
    Auth-->>API: userId
    API->>Query: Build subscription detail view model
    Query->>DB: Read current Subscription
    DB-->>Query: subscription row
    Query->>DB: Read Entitlement and related Plan metadata
    DB-->>Query: entitlement + plan data
    Query->>Query: Derive status, willRenew, inTrial, inGracePeriod
    Query-->>API: detail view model
    API-->>App: planCode, status, willRenew, effectiveUntil...
    App-->>User: Render contract detail screen

20-9. POST /admin/billing/reconcile の詳細

API 名: 課金状態再照合API

20-9-1. 役割

運用者や管理者が、特定契約を手動で再照合するための endpoint です。
通知欠落、障害復旧、CS 問い合わせ対応、誤反映の再投影で使います。

20-9-2. なぜ SHOULD なのか

理想は初回から入れることですが、一般ユーザー向けの課金フローを成立させるうえでの最小要件ではありません。
ただし、本番運用まで見据えるなら、かなり早い段階で入れるべき管理系 endpoint です。

20-9-3. 入力の考え方

最初から何でも受けられる万能 API にせず、どのキーで再照合するかを明示 したほうが安全です。

例:

{
  "provider": "google",
  "purchaseToken": "example_purchase_token"
}

または:

{
  "provider": "apple",
  "transactionId": "2000001234567890"
}

20-9-4. サーバ側の基本動作

  1. 管理権限を確認する
  2. 入力キーに応じてストア再照会を行う
  3. 現在の内部状態との差分を確認する
  4. 必要なら projection を再実行する
  5. 監査ログを残す

20-9-5. この endpoint の注意点

  • 直接 DB を書き換える入口にしない
  • 手動付与 API と混同しない
  • 誰が何を再照合したかを必ず残す

20-9-6. 主な呼び出し場面

場面A. CS 問い合わせ時の状態再確認

ユーザーから「購入済みなのに使えない」「解約したのに使えてしまう」といった問い合わせが来た場合、運用者は対象の transactionIdpurchaseToken を指定してこの API を呼びます。
これにより、現在のストア状態と内部状態の差分をその場で再確認できます。

場面B. 通知欠落や障害復旧後の手動補正

Webhook や RTDN の受信障害があった場合、対象契約だけを再照合して投影し直したいことがあります。
そのようなときに、この API を使って局所的に再反映します。

場面C. 定期ジョブではなく運用者判断で即時再実行したい場合

補正ジョブを待たずに、特定契約だけいま反映し直したいことがあります。
その際の運用用入口がこの API です。

20-9-7. シーケンス図

sequenceDiagram
    autonumber
    actor Admin as Admin / CS
    participant API as POST /admin/billing/reconcile
    participant Auth as Admin Auth Guard
    participant Store as Store API
    participant Projector as Projection Service
    participant DB as App DB

    Admin->>API: provider + transactionId or purchaseToken
    API->>Auth: Verify admin permission
    Auth-->>API: authorized
    API->>DB: Save reconcile request audit log
    alt provider = apple
        API->>Store: Re-fetch transaction / subscription status from Apple
        Store-->>API: latest Apple state
    else provider = google
        API->>Store: Re-fetch subscription state from Google
        Store-->>API: latest Google state
    end
    API->>Projector: Re-run projection with fetched store state
    Projector->>DB: Upsert Subscription
    Projector->>DB: Upsert Entitlement
    Projector->>DB: Insert BillingTransaction / operator audit log
    DB-->>Projector: committed
    Projector-->>API: diff summary + final entitlement
    API-->>Admin: reconciled result + audit summary

21. Projection / 状態反映ロジックの実装例

21-1. 考え方

最も重要なのは、ストア API の再照会結果を入力として、内部状態を pure function に近い形で決めることです。
Controller や repository の中で状態分岐をばらまくと、テストしにくくなります。

21-2. 正規化レイヤーを 1 段挟む

実装では、Apple / Google の再照会結果や通知 payload をそのまま projection 関数へ渡さず、共通スナップショットへ正規化してから投影する形にすると保守しやすくなります。
これにより、入口が ingest・Webhook・RTDN・定期ジョブのどれであっても、最終的には同じ入力形式で Subscription / Entitlement を決められます。

export type StoreSubscriptionSnapshot = {
  provider: "APPLE" | "GOOGLE";
  environment: "SANDBOX" | "PRODUCTION";
  accountToken: string | null;
  eventType: string;
  storeState: "PENDING" | "ACTIVE" | "PAUSED" | "CANCELED" | "EXPIRED" | "REVOKED";
  identifiers: {
    providerBusinessKey: string;
    providerEventId: string;
    originalTransactionId?: string | null;
    latestTransactionId?: string | null;
    purchaseToken?: string | null;
    linkedPurchaseToken?: string | null;
  };
  product: {
    productId: string;
    basePlanId?: string | null;
    offerId?: string | null;
    planCode?: string | null;
  };
  occurredAt: Date;
  purchasedAt?: Date | null;
  currentPeriodStartAt?: Date | null;
  currentPeriodEndAt?: Date | null;
  expiresAt?: Date | null;
  inTrial: boolean;
  inGracePeriod: boolean;
  inBillingRetry: boolean;
  onHold: boolean;
  requiresAcknowledgement: boolean;
  rawSnapshot: unknown;
};

この型の要点は次のとおりです。

  • 識別子群: Apple の originalTransactionId / transactionId、Google の purchaseToken / linkedPurchaseToken などを 1 つの構造へ寄せる
  • 商品群: productIdbasePlanIdofferId など、Plan 解決に必要な情報をまとめる
  • 時刻群: occurredAtexpiresAt、現契約期間など、投影と監査に使う日時をまとめる
  • 状態フラグ: inTrialinGracePeriodinBillingRetryonHold のような provider 差分を明示的に持つ
  • 共通状態: projection 関数へ直接渡せるように、storeState を provider 差分から切り出して持つ
  • 生データ: rawSnapshot を残し、後から監査・再解析できるようにする

実装上は、Apple / Google ごとに normalizeAppleSnapshot(...)normalizeGoogleSnapshot(...) のような関数を用意し、最後にこの共通型へ寄せるイメージです。
例えば Apple では originalTransactionId を系列識別子の主軸にし、Google では purchaseTokenlinkedPurchaseToken を主軸にします。Google の lineItems のように複数候補がある場合は、最新の entitlement 判定に使う line item を 1 つ選び、その結果だけを snapshot へ落とす方針にしておくと downstream が単純になります。

そのうえで、実装では StoreSubscriptionSnapshot をそのまま DB へ書くのではなく、必要最小限の項目だけを StoreProjectionInput へ写して pure に近い判定関数へ渡す、という 2 段構成にしておくと責務が分かりやすくなります。

21-3. projection 関数の例

export type StoreProjectionInput = {
  provider: "APPLE" | "GOOGLE";
  currentStatus: string | null;
  storeState: "PENDING" | "ACTIVE" | "PAUSED" | "CANCELED" | "EXPIRED" | "REVOKED";
  inTrial: boolean;
  inGracePeriod: boolean;
  inBillingRetry: boolean;
  onHold: boolean;
};

export type ProjectSubscriptionStateOutput = {
  nextStatus:
    | "PENDING"
    | "ACTIVE"
    | "TRIALING"
    | "IN_GRACE_PERIOD"
    | "BILLING_RETRY"
    | "ON_HOLD"
    | "PAUSED"
    | "CANCELED"
    | "EXPIRED"
    | "REVOKED";
  entitlementActive: boolean;
};

export function mapGoogleSubscriptionStateToStoreState(input: {
  subscriptionState:
    | "SUBSCRIPTION_STATE_PENDING"
    | "SUBSCRIPTION_STATE_ACTIVE"
    | "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"
    | "SUBSCRIPTION_STATE_ON_HOLD"
    | "SUBSCRIPTION_STATE_PAUSED"
    | "SUBSCRIPTION_STATE_EXPIRED"
    | "SUBSCRIPTION_STATE_CANCELED";
}): StoreProjectionInput["storeState"] {
  switch (input.subscriptionState) {
    case "SUBSCRIPTION_STATE_PENDING":
      return "PENDING";
    case "SUBSCRIPTION_STATE_CANCELED":
      return "CANCELED";
    case "SUBSCRIPTION_STATE_EXPIRED":
      return "EXPIRED";
    case "SUBSCRIPTION_STATE_PAUSED":
      return "PAUSED";
    default:
      // ACTIVE / IN_GRACE_PERIOD / ON_HOLD are normalized here as ACTIVE.
      // The detailed handling is decided later by input.inGracePeriod / input.onHold and similar flags.
      return "ACTIVE";
  }
}

export function projectSubscriptionState(
  input: StoreProjectionInput,
): ProjectSubscriptionStateOutput {
  // REVOKED: refund or revocation — immediately revoke entitlement
  if (input.storeState === "REVOKED") {
    return { nextStatus: "REVOKED", entitlementActive: false };
  }

  // EXPIRED: subscription period ended — revoke entitlement
  if (input.storeState === "EXPIRED") {
    return { nextStatus: "EXPIRED", entitlementActive: false };
  }

  // PENDING: payment not yet confirmed — do not grant entitlement
  if (input.storeState === "PENDING") {
    return { nextStatus: "PENDING", entitlementActive: false };
  }

  // CANCELED: auto-renew turned off but still within paid period — keep entitlement
  if (input.storeState === "CANCELED") {
    return { nextStatus: "CANCELED", entitlementActive: true };
  }

  // PAUSED: Google subscription is paused — do not grant entitlement
  if (input.storeState === "PAUSED") {
    return { nextStatus: "PAUSED", entitlementActive: false };
  }

  // Beyond this point, storeState is ACTIVE

  if (input.provider === "APPLE" && input.inBillingRetry) {
    return { nextStatus: "BILLING_RETRY", entitlementActive: false };
  }

  if (input.provider === "GOOGLE" && input.onHold) {
    return { nextStatus: "ON_HOLD", entitlementActive: false };
  }

  if (input.inGracePeriod) {
    return { nextStatus: "IN_GRACE_PERIOD", entitlementActive: true };
  }

  if (input.inTrial) {
    return { nextStatus: "TRIALING", entitlementActive: true };
  }

  return { nextStatus: "ACTIVE", entitlementActive: true };
}

CANCELEDREVOKED の違い: CANCELED は自動更新がオフになった状態であり、有効期限まではサービスを利用できますREVOKED は返金・取り消しにより即時停止が必要な状態です。この 2 つを混同すると、自動更新をオフにしただけのユーザーから不正にサービスを剥奪する事故につながります。

REVOKED の決定方法: Google の subscriptionState には SUBSCRIPTION_STATE_REVOKED という値は存在しません。返金・取り消しは SUBSCRIPTION_STATE_EXPIRED として返されることがあり、canceledStateContext の理由や RTDN の SUBSCRIPTION_REVOKED 通知で識別します。さらに、返金・チャージバック監査を強める場合は VoidedPurchaseNotificationpurchases.voidedpurchases.list も監査ソースに含めると安全です。本番実装では、RTDN の notificationTypecanceledStateContext、必要に応じて voided purchase 系の情報を投影ロジックの入力に組み込んで REVOKED を判定してください。

21-4. DB 更新の流れ

  1. ストア API から現在状態を取得する
  2. provider ごとの normalizer で StoreSubscriptionSnapshot へ正規化する
  3. StoreSubscriptionSnapshot から StoreProjectionInput を組み立て、projectSubscriptionState(...) で内部状態を決める
  4. Subscription を upsert する
  5. Entitlement を更新する
  6. BillingTransaction を記録する
  7. 必要なら Google acknowledgement を実行する

21-5. application service の例

export class ApplyStoreProjectionService {
  constructor(private readonly prisma: PrismaService) {}

  async applyProjection(input: {
    subscriptionId: string;
    provider: "APPLE" | "GOOGLE";
    nextStatus: string;
    entitlementActive: boolean;
    transactionKey: string;
    eventType: string;
    occurredAt: Date;
    rawSnapshot: unknown;
  }) {
    await this.prisma.$transaction(async (tx) => {
      await tx.subscription.update({
        where: { subscriptionId: input.subscriptionId },
        data: { status: input.nextStatus },
      });

      const subscription = await tx.subscription.findUniqueOrThrow({
        where: { subscriptionId: input.subscriptionId },
        select: { billingAccountId: true, planId: true },
      });

      await tx.entitlement.upsert({
        where: {
          billingAccountId_planId: {
            billingAccountId: subscription.billingAccountId,
            planId: subscription.planId,
          },
        },
        update: {
          isActive: input.entitlementActive,
          sourceSubscriptionId: input.subscriptionId,
        },
        create: {
          billingAccountId: subscription.billingAccountId,
          planId: subscription.planId,
          isActive: input.entitlementActive,
          sourceSubscriptionId: input.subscriptionId,
        },
      });

      await tx.billingTransaction.upsert({
        where: { transactionKey: input.transactionKey },
        update: {},
        create: {
          subscriptionId: input.subscriptionId,
          provider: input.provider as never,
          eventType: input.eventType as never,
          transactionKey: input.transactionKey,
          occurredAt: input.occurredAt,
          rawSnapshot: input.rawSnapshot as never,
        },
      });
    });
  }
}

この例では sourceSubscriptionId に今回の投影元 Subscription を保存しているが、これは entitlement の主な出所の記録であって、SubscriptionEntitlement の厳密な 1:1 を意味しない。簡略化のため reasonstartsAt / endsAt の更新は省略しているが、実装では同じ transaction 内で整合的に更新する。

21-6. transactionKey の決め方

BillingTransaction.transactionKey は、同じ課金イベントを二重登録しないためのキーです。
Apple / Google の外部識別子を材料にした冪等キーであり、provider ごとに形式が異なって構いません。

初回リリースでは、可読な連結文字列をそのまま保持する方式で十分です。例えば、次のような構成が考えられます。

  • Apple 系の例: apple:<transactionId>:<eventType>
  • Apple 系の別例: apple:<originalTransactionId>:<transactionId>:<eventType>
  • Google 系の例: google:<purchaseToken>:<eventType>:<expiryTime>
  • Google 系の別例: google:<purchaseToken>:<eventType>:<occurredAt>

ここで注意すべきなのは、特に Google の purchaseToken可変長の外部文字列であり、公式仕様上も最大長を前提に強く縛りにくいことです。さらに、providereventType、時刻、系列識別子などを連結して可読なキーを作る場合、varchar(128) では余裕が小さくなります。

そのため、transactionKey のカラム長は varchar(512) を推奨します。varchar(512) であれば、可読な連結キーを保持する方式でも、現実的な運用では十分に安全側です。

ただし、これは「512 なら絶対に保証できる」という意味ではありません。あくまで、外部識別子の長さ不確実性を踏まえた保守的な設計です。将来的にキー構成をより厳密に固定したい場合は、連結元の文字列をハッシュ化して固定長キーへ移行することも可能です。とはいえ、初回リリース方針としては、まず varchar(512) の可読キー方式で十分に運用できます。

22. Webhook / RTDN 処理詳細

22-1. 受信時の基本原則

  • 入口は重い本処理を同期実行しない
  • 検証と本処理を分離する
  • 生 payload を保存してから再処理できるようにする
  • 受信順に依存しない
  • 同じイベントの再送を前提にする

ただし、署名検証や JWT 検証に失敗した通知まで無条件に 2xx で握りつぶす という意味ではありません。
認証・署名が不正な通知に対してどのステータスを返すかは、プロバイダの再送挙動と運用方針を踏まえて決めます。少なくとも本番では、検証失敗を監査ログ・アラートへ残すこと、そして 検証前に業務処理へ進めないこと を必須にしてください。

22-1-1. 共通ディスパッチャーの流れ

Webhook / RTDN の受信処理は、provider ごとの差分があっても、次の共通フローに寄せると整理しやすくなります。

  1. 受信 payload を検証する
  2. providerEventId または業務キーで冪等チェックする
  3. InboundWebhookEventRECEIVED で保存する
  4. 重い処理は queue / worker へ引き渡す
  5. worker 側でストア再照会と projection を実行する
  6. 結果に応じて PROCESSED / FAILED / SKIPPED へ更新する

ここでのポイントは、通知受信 endpoint を「最終状態を同期的に決める場所」にしないことです。
受信口では、署名や JWT の検証、監査保存、非同期実行の起点づくりまでに責務を絞ると、再送・一時障害・順不同到着に強くなります。

22-2. Apple 通知の基本手順

  1. signedPayload を受け取る
  2. JWS として verify and decode する
  3. notificationUUID を冪等キーとして保存する
  4. signedTransactionInfo / signedRenewalInfo があれば decode する
  5. 受信イベントを非同期ワーカーへ引き渡す
  6. ワーカー側で必要に応じて App Store Server API を再照会する

22-3. Google RTDN の基本手順

Google RTDN は、Pub/Sub の push / pull のどちらでも受信可能です。受信方式の考え方は 3-2. Google 側の準備 で述べたとおりで、本書では初回リリースで導入しやすい push subscription を前提に説明します。

22-3-1. push message のエンベロープ

push subscription を採る場合、受信 body は概ね次のような envelope です。

{
  "message": {
    "data": "<base64-encoded-json>",
    "messageId": "1234567890",
    "publishTime": "2026-03-30T00:00:00.000Z"
  },
  "subscription": "projects/example/subscriptions/example-sub"
}

message.data を Base64 デコードすると、subscriptionNotificationvoidedPurchaseNotificationtestNotification などを含む Google Play 側 payload が得られます。
そのため、HTTP 受信直後の実装では envelope の検証・保存message.data の復号 を分けて扱うと見通しがよくなります。

22-3-2. 通知種別は routing のために使い、最終状態の確定には使い切らない

subscriptionNotification.notificationType は、worker がどの処理へ流すかを決めるための routing 情報として有用です。例えば、SUBSCRIPTION_PURCHASEDSUBSCRIPTION_RENEWEDSUBSCRIPTION_IN_GRACE_PERIODSUBSCRIPTION_ON_HOLDSUBSCRIPTION_REVOKED などを識別できます。
ただし、これらは RTDN のイベント種別 であり、そのまま内部の Subscription.statusEntitlement.reason を表す値ではありません。RTDN の通知名だけで Entitlement を決め打ちせず、最終状態は必ず purchases.subscriptionsv2.get の再照会結果で確定します。

22-3-3. push subscription を使う場合の流れ

  1. Pub/Sub push を受け取る
  2. Authorization ヘッダの JWT を検証し、想定した audience / service account からの push であることを確認する
  3. messageId または自前の業務キーで冪等化する
  4. payload から purchaseToken を取り出す
  5. 非同期ワーカーで purchases.subscriptionsv2.get を呼ぶ
  6. 返ってきた状態を内部状態へ投影する

本番運用では、Pub/Sub の JWT 検証を基本としつつ、必要に応じて共有トークン付き endpoint を追加して多層防御にしても構いません。

22-3-4. pull subscription を使う場合の流れ

  1. 常駐 worker / subscriber が Pub/Sub からメッセージを取得する
  2. messageId または自前の業務キーで冪等化する
  3. payload から purchaseToken を取り出す
  4. purchases.subscriptionsv2.get を呼ぶ
  5. 返ってきた状態を内部状態へ投影する
  6. 正常処理後に Pub/Sub へ ack する

pull を採る場合も、5-2. 処理の原則 で述べたとおり、通知だけで状態を確定しません。違いは、受信口が HTTPS endpoint ではなく subscriber worker になることと、JWT 検証の代わりに Pub/Sub への接続権限と worker 運用 が重要になることです。

22-4. ローカル確認時のモック / バイパス方針

ローカル確認では、署名検証・認証検証投影ロジックの確認 を分けて考えると実装しやすくなります。

ここでいうバイパスやモックは、ローカル確認専用です。共有の開発 / ステージング環境では有効化せず、そこでの確認は実際の署名検証・JWT 検証を通す前提で行います。

Apple の場合

  • signedPayload は Apple の秘密鍵で署名された JWS である
  • そのため、自作 JWS を本番用の署名検証ロジックに通すことはできない
  • ローカルでは、次のどちらかを採る
    • ローカル確認限定で署名検証をバイパスする
    • verify 済み payload fixture を projection 関数へ直接渡す
  • 共有の開発 / ステージング環境や production では、署名検証バイパスを有効化しない

Google の場合

  • RTDN の message.data は Base64 エンコードされた JSON である
  • ローカルでは、Pub/Sub push envelope を模擬した JSON を組み立てて POST すると処理フローを確認しやすい
  • push JWT 認証は、本番では Authorization ヘッダの JWT を検証する
  • ローカルでは JWT 検証をバイパスまたはモックし、共有の開発 / ステージング環境では audience / service account まで含めて実検証する
  • 必要に応じて、共有トークン付き endpoint を併用して検証層を増やしてもよい

この方針を採ると、ストア署名 / Pub/Sub 認証の検証内部の冪等・投影ロジック を切り分けて確認できます。

22-5. event 保存モデル

InboundWebhookEvent に最低限入れておくとよい項目は次です。

  • provider
  • providerEventId
  • eventType
  • verified
  • rawPayload
  • processResult
  • processedAt
  • processingError

processResult は、RECEIVED / PROCESSED / FAILED / SKIPPED のように管理しておくと、通知の再処理や監視がしやすくなります。
特に、署名検証や JWT 検証には成功したが、現在は安全に処理できないため一旦スキップする というケースを SKIPPED として残せると、運用上の見通しがよくなります。

22-6. 冪等ルール

  • Apple は notificationUUID を起点にする
  • Google は Pub/Sub messageId 単体でなく、purchaseToken と組み合わせた業務キーも検討する
  • 本処理は、同じイベントを何回流しても最終状態が壊れないようにする

22-7. Apple通知受信API の詳細

API 名: Apple通知受信API

22-7-1. 役割

Apple Server Notifications V2 の受信口です。
初回購入の入口ではなく、状態変化の追従、通知ドリブン補正、取りこぼしの早期回収 のために置きます。

22-7-2. いつ必要か

  • 更新、解約、失効、返金などの変化を早く反映したいとき
  • クライアント起動に依存せず状態を追従したいとき
  • 補正ジョブの負荷を下げたいとき

22-7-3. 受信時の基本動作

  1. Apple 署名を検証する
  2. notificationUUID などで冪等性を担保する
  3. 受信 payload を監査用に保存する
  4. 通知本文をそのまま真実の源泉にせず、必要な識別子を抽出する
  5. App Store Server API を再照会し、最新状態へ projection する
  6. すぐ返答し、重い処理は非同期化する

22-7-4. この endpoint の注意点

  • 通知そのものを直接 entitlement 判定の唯一根拠にしない
  • 署名検証前に業務処理へ進めない
  • 再送や重複到着を前提に設計する

22-7-5. 主な呼び出し場面

場面A. 自動更新の追従

Apple 側でサブスクが自動更新されると、クライアントが何もしなくても Apple からこの API に通知が届きます。
サーバは通知を受けて最新状態を再照会し、Subscription.expiresAt などを延長します。

場面B. 解約・失効・返金の早期反映

ユーザーがサブスクを解約した、支払い問題で失効した、返金が発生した、といった変化はクライアント起点で届かないことがあります。
この API はそのような変化を早く取り込み、利用可否の取りこぼしを減らします。

場面C. クライアント申告の補完

購入直後にクライアントからの ingest が失敗した場合でも、Apple 通知経由で後から状態を補足できます。
そのため、この API は初回購入の主経路ではなくても、整合回復の重要な入口になります。

22-7-6. シーケンス図

sequenceDiagram
    autonumber
    participant Apple as App Store Notifications V2
    participant API as POST /webhooks/apple/notifications
    participant Verify as Signature Verifier
    participant Idem as Dedupe Check
    participant DB as App DB
    participant Queue as Job Queue
    participant Worker as Billing Worker
    participant AppleAPI as App Store Server API
    participant Projector as Projection Service

    Apple->>API: signedPayload
    API->>Verify: Verify JWS signature and bundle/environment
    Verify-->>API: verified payload
    API->>Idem: Check duplicate by notificationUUID
    alt Duplicate notification
        Idem-->>API: duplicate
        API-->>Apple: 200 OK
    else First arrival
        Idem-->>API: continue
        API->>DB: Save InboundWebhookEvent(raw payload, metadata)
        API->>Queue: Enqueue apple notification job
        Queue-->>API: accepted
        API-->>Apple: 200 OK
        Worker->>DB: Load inbound event
        Worker->>Worker: Extract transactionId / originalTransactionId / subtype
        Worker->>AppleAPI: Re-fetch latest transaction / subscription status
        AppleAPI-->>Worker: latest store state
        Worker->>Projector: Recompute Subscription / Entitlement
        Projector->>DB: Upsert Subscription
        Projector->>DB: Upsert Entitlement
        Projector->>DB: Insert BillingTransaction / mark event processed
        DB-->>Projector: committed
    end

22-7-7. 補足

この API は通知を受けた瞬間に同期的に全反映を終える場ではなく、署名検証・受信記録・非同期再照会の起点として設計するのが安全です。検証失敗時の応答方針だけは別途明示し、少なくとも 署名検証前に業務処理へ進めない ことを徹底してください。

22-8. Google通知受信API の詳細

API 名: Google通知受信API

22-8-1. 役割

Google Play の Real time developer notifications の受信口です。
Apple 通知と同様に、状態変化の追従と補正を早く行うための入口 です。

22-8-2. いつ必要か

  • 期限切れ、解約、支払い問題、renewal などを素早く反映したいとき
  • クライアント再訪を待たずに内部状態を更新したいとき
  • purchase token 単位の補正を自動で回したいとき

本書では Pub/Sub push subscription を採る場合の受信 endpoint として、POST /webhooks/google/rtdn を説明します。
pull subscription を採る場合は、この HTTP endpoint 自体は不要で、代わりに subscriber worker が同じ責務を担います。

22-8-3. 受信時の基本動作

  1. Pub/Sub push の JWT を検証する
  2. envelope と message.data を復号・解析する
  3. event id や publish 時刻などで冪等性を担保する
  4. 受信データを監査用に保存する
  5. 通知だけで確定せず、purchaseToken を使って Google API へ再照会する
  6. 最新状態へ projection する
  7. 署名検証や最小限の受信検証を終えたら、重い処理は非同期化する

22-8-4. この endpoint の注意点

  • JWT 検証を通していない push を信用しない
  • notificationType だけで entitlement を決めない
  • 検証失敗時の応答コードは、再送挙動と運用方針を踏まえて明示的に決める
  • Pub/Sub 再送や順不同到着を前提にする

22-8-5. 主な呼び出し場面

場面A. 自動更新の追従

Google Play でサブスクが自動更新されると、RTDN が Pub/Sub に publish され、push subscription を採っている場合はこの API に通知が届きます。
サーバは通知を受けて purchaseToken を再照会し、期限延長や状態更新を反映します。

場面B. 支払い問題・猶予期間・保留の追従

Google では grace period や account hold のような状態遷移が発生します。
この API は、その変化をクライアント再訪より先に捕捉し、利用可否の切り替えを早く行うために呼ばれます。

場面C. pull 方式を採らない構成での通知受信口

NestJS の Web API サーバを中心に構成する場合は、subscriber worker を別途常駐させる代わりに、この API を Pub/Sub push の受信口として置くと分かりやすくなります。
反対に pull 方式を採る場合は、この場面自体が worker 側処理に置き換わります。

22-8-6. シーケンス図

sequenceDiagram
    autonumber
    participant PubSub as Google Pub/Sub Push
    participant API as POST /webhooks/google/rtdn
    participant Verify as JWT Verifier
    participant Idem as Dedupe Check
    participant DB as App DB
    participant Queue as Job Queue
    participant Worker as Billing Worker
    participant Google as Google Play Developer API
    participant Projector as Projection Service

    PubSub->>API: Push message(envelope + JWT)
    API->>Verify: Verify Authorization JWT(audience / service account)
    Verify-->>API: verified
    API->>API: Decode message.data
    API->>Idem: Check duplicate by messageId / publishTime / event key
    alt Duplicate delivery
        Idem-->>API: duplicate
        API-->>PubSub: 200 OK
    else First arrival
        Idem-->>API: continue
        API->>DB: Save InboundWebhookEvent(raw payload, metadata)
        API->>Queue: Enqueue RTDN job
        Queue-->>API: accepted
        API-->>PubSub: 200 OK
        Worker->>DB: Load inbound event
        Worker->>Worker: Extract purchaseToken and notificationType
        Worker->>Google: purchases.subscriptionsv2.get(purchaseToken)
        Google-->>Worker: latest subscription resource
        Worker->>Projector: Recompute Subscription / Entitlement
        Projector->>DB: Upsert Subscription
        Projector->>DB: Upsert Entitlement
        Projector->>DB: Insert BillingTransaction / mark event processed
        DB-->>Projector: committed
    end

22-8-7. pull 方式を採る場合の読み替え

pull subscription を採る場合は、上図の POST /webhooks/google/rtdnsubscriber worker に読み替えます。
つまり、通知を受ける責務そのものは必要ですが、HTTP endpoint としての実装は不要になります。

23. Google acknowledgement 詳細

23-1. acknowledgement の位置づけ

Google では、PURCHASED で entitlement を付与した purchase が未 acknowledgement の場合に acknowledgement を実行します。
renewal は不要です。
一方で、初回購入・plan change・re-signup のように new purchase token を伴う purchase は acknowledgement 対象です。
prepaid を扱う場合は top-up も対象です。

23-2. いつ実行するか

ルールは次の 3 行で整理できます。

  • PENDING の間は execute しない
  • PURCHASED になってから実行する
  • Entitlement を付与した後に実行する

23-3. 実行条件の例

export function shouldAcknowledgeGooglePurchase(input: {
  acknowledgementState: "PENDING" | "ACKNOWLEDGED";
  purchaseState: "PENDING" | "PURCHASED";
  entitlementActive: boolean;
  hasNewPurchaseToken: boolean;
}): boolean {
  return (
    input.acknowledgementState === "PENDING" &&
    input.purchaseState === "PURCHASED" &&
    input.entitlementActive &&
    input.hasNewPurchaseToken
  );
}

23-4. application service の例

export class GoogleAcknowledgementService {
  constructor(private readonly googleClientService: GoogleClientService) {}

  async acknowledgeIfNeeded(input: {
    packageName: string;
    subscriptionId?: string;
    purchaseToken: string;
    acknowledgementState: "PENDING" | "ACKNOWLEDGED";
    purchaseState: "PENDING" | "PURCHASED";
    entitlementActive: boolean;
    hasNewPurchaseToken: boolean;
    externalAccountIds?: {
      obfuscatedAccountId?: string;
      obfuscatedProfileId?: string;
    };
  }) {
    if (
      !shouldAcknowledgeGooglePurchase({
        acknowledgementState: input.acknowledgementState,
        purchaseState: input.purchaseState,
        entitlementActive: input.entitlementActive,
        hasNewPurchaseToken: input.hasNewPurchaseToken,
      })
    ) {
      return;
    }

    await this.googleClientService.acknowledgeSubscription({
      packageName: input.packageName,
      // subscriptionId is optional since 2025-05-21.
      subscriptionId: input.subscriptionId,
      purchaseToken: input.purchaseToken,
      externalAccountIds: input.externalAccountIds,
    });
  }
}

purchases.subscriptions.acknowledgesubscriptionId は 2025-05-21 以降 optional です。
本ドキュメントの主対象である通常の自動更新サブスクでは渡しても構いませんが、add-ons を扱う場合は最新仕様に合わせて省略を検討します。
また、request body には externalAccountIds.obfuscatedAccountId / obfuscatedProfileId を含められます。これは resubscription purchase でのみ設定できる補助的な紐付け情報であり、通常の購入フローでは BillingFlow 側で設定した obfuscated identifiers を主経路として扱います。

23-5. 救済ジョブ

acknowledgement は、同期処理だけに頼らず救済ジョブを必ず持つのが安全です。
監視対象は少なくとも次です。

  • acknowledgementStatus = PENDING
  • purchaseState = PURCHASED
  • updatedAt が一定時間以上前
  • Entitlement は active

24. Restore / Account Linking 詳細

24-1. restore の基本原則

restore は「過去購入をもう一度作る」処理ではなく、既存のストア契約を現在のアプリユーザーへ正しく結び直す処理です。
そのため、通常購入と同じくストア再照会を行い、既存契約があればそれを投影し直します。

24-2. ingest / restore 時の既存 Subscription 特定順序

復元や再同期で最初にやるべきことは、このストア契約が既存のどの Subscription 系列に属するかを特定することです。
accountToken 系だけ、あるいは系列識別子だけに寄せず、系列一致を優先しつつ、購入時に埋め込んだ識別子で補強する形にすると安全です。

24-2-1. Apple

  1. originalTransactionId で既存 Subscription を探す
  2. 見つかった系列の中で transactionId / latestTransactionId を照合する
  3. 系列で見つからない場合に、appAccountToken と現在ユーザーの BillingAccount.accountToken の一致を確認する
  4. それでも見つからない場合に限り、新規 Subscription 作成可否を検討する

Apple では、契約系列の主軸は originalTransactionId です。
appAccountToken は強い補助手掛かりですが、復元時や運用時の補正では、まず既存系列へ収まるかを見るほうが事故を防ぎやすくなります。

24-2-2. Google

  1. purchaseToken で既存 Subscription を探す
  2. 見つからなければ linkedPurchaseToken の連鎖から既存系列を辿る
  3. 系列で見つからない場合に、obfuscatedExternalAccountId / obfuscatedExternalProfileId と現在ユーザーの BillingAccount.accountToken の一致を確認する
  4. 期限切れ後の out-of-app 再購読なら outOfAppPurchaseContext を補助手掛かりにする
  5. それでも見つからない場合に限り、新規 Subscription 作成可否を検討する

Google では、現在の purchaseToken だけでなく linkedPurchaseToken まで見て系列を解くのが重要です。
plan change や resubscribe を考えると、token 単体一致だけで判断すると取りこぼしや誤新規作成が起きやすくなります。

24-3. ユーザー文脈がない通知処理での紐付け

Webhook / RTDN では、ingest と違って「現在ログイン中ユーザー」がありません。
そのため、通知から作った snapshot を適用するときは、次の順で billingAccountId を解決すると安全です。

  1. snapshot の系列識別子(Apple の originalTransactionId、Google の purchaseToken / linkedPurchaseToken)で既存 Subscription を探す
  2. 見つかったら、その Subscription.billingAccountId を採用する
  3. 既存系列が見つからない場合に、snapshot に含まれる accountToken 系識別子から BillingAccount を探す
  4. それでも 1 件に決められない場合は、自動紐付けせず FAILED または SKIPPED として再処理・手動確認へ送る

通知系で重要なのは、ユーザー文脈がないからといって最初から新規作成へ進まないことです。
まず既存系列へ吸収できるかを見ることで、再送・順不同・別経路先着のケースでも同じ Subscription へ収束しやすくなります。

24-4. 自動紐付けしてよいケース

  • 1 つの BillingAccount にだけ安全に特定できる
  • 既存契約の所有者が同一ユーザーと判断できる
  • 既存の active entitlement と衝突しない
  • 系列識別子または購入時識別子のどちらかで十分な根拠がある

24-5. 止めるべきケース

  • BillingAccount に有効な購読がすでにある
  • 複数候補に一致する
  • store の識別子が不足していて安全に 1 件へ絞れない
  • 既存系列と現在ユーザーの accountToken 系識別子が矛盾する

24-6. 新規 Subscription 作成を検討してよい条件

既存系列へ到達できなかったからといって、常に新規作成してよいわけではありません。
新規作成を検討してよいのは、少なくとも次を満たす場合です。

  • ストア再照会結果が検証済みである
  • 既存 Subscription に安全に紐付けられないことが確認できている
  • accountToken 系識別子などから現在ユーザーへの帰属が十分に説明できる
  • 既存 active entitlement と競合しない

つまり、「系列が見つからない」だけでは不十分で、「このユーザーに新規として作っても安全」と言える根拠が必要です。

24-7. 手動確認へ送るルール

restore 失敗をすべて 404 にせず、次の 2 系統に分けると運用しやすくなります。

  • 本当に見つからない
  • 見つかったが自動移管できない

後者は CS や運用者が確認できるよう、監査ログと内部メモの起票導線を持つと安全です。
特に、別アカウントへの既存紐付け、複数候補一致、系列と accountToken の矛盾は、自動復元不可だが存在はしている ケースとして切り分けて残すのが重要です。