ドキュメント
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 と運用の両方が不安定になる |
GooglePurchaseToken | Google 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)
uuid、boolean、timestamptz、jsonb、各種 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 系列の履歴を保持する |
InboundWebhookEvent | Webhook / RTDN 受信時 | Apple 通知や Google RTDN を受信した都度追加し、通知監査と再処理起点を残す |
各テーブルの作成タイミングは、次のように見ると整理しやすくなります。
- 事前に用意しておくマスタ
PlanStoreProductMapping- これらは購入時に初めて作るものではない。商品を販売する前に、内部のプラン定義とストア商品対応表として登録しておく。
- 購入前に用意しておく主体
BillingAccountBillingAccountは初回購入時に初めて意味を持つ概念だが、設計上は購入開始前に作成しておく前提である。これにより、購入開始時にaccountTokenをストア SDK へ埋め込める。
- 購入や復元の反映時に作成される現在値 / 利用権
SubscriptionEntitlement- これらは、まだ対応する現在値が存在しなければ初回購入反映時や復元反映時に作成され、既存レコードがあれば update で最新状態へ保たれる。
- イベントのたびに追加作成される履歴
BillingTransactionGooglePurchaseToken(Google のみ)BillingTransactionは現在値ではなく、購入、更新、返金、回復などの都度追加されるイベント履歴である。一方、GooglePurchaseTokenは Google のすべての課金イベントで増えるわけではなく、初回購入や token 差し替えが起きたときに系列履歴として追加される。
- 通知受信時に作成される監査ログ
InboundWebhookEvent- これは Apple 通知や Google RTDN を受信した都度追加される監査ログであり、再送判定や処理失敗調査の起点になる。
このように、コアテーブル 8 種はすべてが「購入時にまとめて作られる」わけではありません。
マスタ、主体、現在値、履歴、通知監査 という性質ごとに作成タイミングが異なるため、その違いを意識して読むと、後続の個別テーブル説明を追いやすくなります。
16-7. コアテーブルからさらに削減したい場合
16-5 で示した 8 テーブル構成は、初回リリースで課金機能を安定して成立させるための標準推奨構成です。
本節は、その前提を崩さずに、金銭コストや初期実装簡素化を優先して要件を意図的に絞る場合の縮小案を補足するものです。通常構成の置き換えとして読むものではありません。
また、テーブル削減は単なる整理ではありません。
実際には、責務の統合、運用リスクの受容、障害調査性の低下、将来拡張性の放棄を伴う設計判断です。DB の金額差は限定的であることが多く、論点はむしろ運用性、障害調査性、将来変更容易性とのトレードオフにあります。
ここで特に重要なのは、商品マッピング要件そのものは消えないという点です。
Plan や StoreProductMapping の物理テーブルを削る案を書いても、ストア商品と内部プラン概念の対応付け責務までなくなるわけではありません。縮小案は、あくまでその責務の置き場所を一時的にコードや設定へ寄せる案として理解してください。
16-7-1. どこから削減検討すべきか
削減候補は、概ね次の順で検討すると判断しやすくなります。
| 優先度 | テーブル | 温度感 |
|---|---|---|
| まず検討しやすい | StoreProductMapping、Plan | 商品定義まわりの責務であり、他テーブルよりは縮小しやすい |
| 条件付きで検討可能 | BillingAccount、InboundWebhookEvent | 要件をかなり絞れば成立するが、復元・救済・運用監査の強度が落ちる |
| 非推奨だが理屈上は削減可能 | Entitlement、GooglePurchaseToken、BillingTransaction | 責務分離や監査性を崩すため、初期簡素化より後続コスト増の影響が大きい |
| 実質的に削減不可 | 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 が永続不変で、accountToken を User 側に直接持ってよく、未ログイン購入や将来のアカウント統合を考えないこと | User に accountToken と 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. Subscription に Entitlement を統合する案の追加リスク
Entitlement を削り、Subscription に isActive や reason を直接持たせる案は、初回段階では単純に見えることがあります。
ただし、これは単にテーブルを 1 枚減らす話ではなく、利用可否の正本をどこに置くかという設計そのものを変える判断です。
まず、Subscription は契約系列の現在状態を保持するテーブルであり、provider、契約状態、transaction 系識別子、purchase token 系列など、契約の追跡・再照合・系列管理をしやすい形で設計しています。
一方、Entitlement は**billingAccountId + planId 単位の現在利用権**を返す read model であり、クライアントや画面 API が「今このユーザーがこのプランを使えるか」を安定して参照しやすい形で設計しています。両者は似た情報を持つのではなく、検索軸、一意性、クエリ意図が異なるものとして分けています。
このとき isActive や reason を Subscription に統合すると、どの 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 利用権が単純対応しないケースへ広がると、契約系列を持つ構造と利用可否を返す構造を分けておいたほうが自然に拡張できます。初回は動いて見えても、Subscription に isActive / reason を寄せる設計は、こうした局面で戻し工事が大きくなりやすいです。
したがって、Entitlement を削る判断は「利用可否の正本設計そのものを変える」判断として扱うほうが適切です。
理屈上は成立しても、後で効いてくるのは責務混在そのものというより、参照の安定性、一意性、将来拡張性を先に削ってしまうことです。
16-7-4. 削減議論で誤解しやすいポイント
テーブル削減を検討するときは、単に「最新 1 件を見れば足りるか」「外部コンソールで確認できるか」だけで判断しないことが重要です。
ここで迷いやすいのは、現在状態の保持責務と履歴・監査の保持責務を、同じもので代替できるように見えてしまう点です。
まず、Subscription は現在の契約状態を正規化した最新像であり、BillingTransaction は課金イベントの履歴です。
BillingTransaction の最新 1 件は最新イベントではあっても、必ずしも現在状態そのものではありません。課金では、通知再送、順不同到着、クライアント通知の先着 / 後着、補正ジョブによる再投影がありえます。そのため、最新イベント = 現在状態とは限りません。
Subscription をなくし、BillingTransaction の最新から毎回現在状態を解釈する設計にすると、状態判定ロジックが read 側や運用側へ散りやすくなります。
その結果、API、管理画面、バッチ、障害調査のそれぞれで解釈がぶれやすくなり、同じ購読に対して異なる判定が生じやすくなります。これは単なるテーブル数の問題ではなく、現在状態の保持責務と履歴保持責務を混ぜないための分離として理解するほうが適切です。
また、ストアコンソールは有用な確認手段ですが、BillingTransaction や InboundWebhookEvent の代替にはなりません。
ストアコンソールで確認できるのは、基本的にストア側で見えている契約や課金の状態です。一方、自社運用で必要なのは、それに加えて自社が通知を受け取ったか、署名検証 / JWT 検証に成功したか、冪等処理でどう扱ったか、投影や DB 更新のどこで失敗したか、後で何を再処理すべきかを追えることです。
InboundWebhookEvent は、通知受信の監査と再処理起点を持つためのテーブルです。
これはストア通知の状態そのものを保存するためではなく、自社の受信・検証・再処理運用を成立させるための監査テーブルです。ストアコンソールだけでは、自社 API が受信した事実、検証結果、処理結果、失敗箇所までは置き換えられません。
同様に、BillingTransaction は自社システムが保存した課金イベント履歴であり、現在値だけでは説明できない時系列事実を残します。
ストアコンソール参照は外部状態の確認には役立ちますが、自社システム内で何を受信し、何を検証し、どこまで投影できたかという監査や説明責任の代替ではありません。
したがって、削減議論では「コンソールで見られるか」ではなく、自社で何を監査し、何を再処理し、何を説明責任として残す必要があるかで判断することが重要です。
この観点に立つと、Subscription、BillingTransaction、InboundWebhookEvent は似た情報を重複して持っているのではなく、それぞれ異なる責務を分担していることが分かりやすくなります。
16-7-5. 現実的な縮小案
机上では多くのテーブルを削れますが、現実的には次の 3 段階で考えると判断しやすくなります。
| 案 | 構成 | 主な前提条件 | 受け入れる主なリスク | コメント |
|---|---|---|---|---|
| 縮小案 A | 7 テーブル構成。StoreProductMapping を削減し、Plan は残す | 商品対応表を外部 JSON / 設定ファイルへ移し、その参照を 1 か所に集約できる | DB 制約で守れず、設定更新ミスが即時に誤投影へつながりうる | 最も現実的で、標準構成からの乖離も小さい |
| 縮小案 B | 6 テーブル構成。StoreProductMapping と Plan を削減する | 初回リリースで 1 プランのみ、月額固定、将来の複数プラン化予定が薄い。planCode のような安定キーをアプリケーション上で厳格に運用できる | ストア識別子が業務ロジックへ漏れやすくなり、複数プラン追加時に戻し工事が発生しやすい | 条件付きでは成立しうるが、戻し工事前提の実装 discipline を強く要求する |
| 縮小案 C | 4〜5 テーブル構成。さらに BillingAccount や InboundWebhookEvent まで削減する | 課金主体と認証主体を同一視し、通知監査や再処理起点を DB 以外で担保できる | 復元、救済、通知監査、署名検証の追跡、障害調査の強度が大きく落ちる | 初期簡素化の代わりに、運用・調査・復旧コストを前倒しで受け入れる案である |
縮小案 C より先、すなわち Entitlement、GooglePurchaseToken、BillingTransaction、Subscription 側へ踏み込む削減は、初期実装の簡素化よりも後続の運用・調査・拡張コスト増のほうが大きくなりやすくなります。特に 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テーブル削減案: StoreProductMapping と Plan を削減する場合
この案は、StoreProductMapping と Plan の両方の物理テーブルを削減する構成です。
ただし、テーブルを削っても、内部プラン概念まで消してよいわけではありません。商品マッピング自体は要件上の MUST であり、ストア固有 ID をそのまま業務ロジックへ流さないための内部キーは引き続き必要です。
この案が成立しうるのは、少なくとも次の条件が揃う場合です。
- 初回リリースで 1 プランのみ
- 月額固定
- 年額、買い切り、複数 tier、複数 offer の追加予定が当面薄い
- 商品表示名や販売期間を DB の正規データとして早期に扱う必要が薄い
この条件下でも、Subscription や Entitlement には 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 を切っておくと移行しやすくなります。
Subscription と Entitlement には、初回フェーズから 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 時は
planCodeとplanIdの両方を書き込む - read 時は
planId優先、未設定時のみplanCodefallback にする
Phase 5. resolver 差し替え
- 設定ファイル実装から DB 実装へ差し替える
- 呼び出し側の契約は変えず、resolver の実装だけを置き換える
Phase 6. legacy 整理
- 設定ファイル catalog を廃止する
- 必要に応じて
planCodeの legacy 列を削除するか、検索用として残す planIdをNOT NULL化する
この段取りにしておくと、戻し工事は「散らばった商品判定を拾い集めて作り直す作業」ではなく、初回から 1 か所に閉じ込めていた商品解決の永続化先を切り替える作業として進めやすくなります。
16-7-10. 初回リリース時の採用判断基準
初回リリース時にどこまで縮小するかは、テーブル数だけでなく、将来の戻しやすさと実装 discipline をどこまで守れるかで判断すると安定します。
| 判断対象 | 向く条件 | 補足 |
|---|---|---|
| 1 テーブル削減案 | 月額 1 プランのみで、近いうちの複数プラン追加予定は薄いが、Plan は内部業務概念として残したい。将来戻しやすさも優先したい | 実務上もっとも現実的である。縮小しつつ、業務語彙と DB への戻しやすさをかなり維持できる |
| 2 テーブル削減案 | 初回は 1 プラン固定で、リリース期限と金銭コスト削減を最優先する。将来追加時の戻し工事を受け入れられ、初回から戻し工事前提の実装 discipline を守れる | 条件付きでは成立するが、planCode、resolver、dual read / dual write を見据えた実装が前提である |
| 2 テーブル削減案を避けるべき条件 | 近いうちに年額や複数 tier 追加の可能性が高い。商品表示名や期間を DB の正規データとして早めに持ちたい。複数開発者が分担し、商品判定ロジックの散逸リスクが高い | この場合は初回から Plan と StoreProductMapping を持つほうが結果的に安定しやすい |
したがって、判断としては次の整理が分かりやすくなります。
- 標準推奨は、引き続き 8 テーブル構成
- 実務上もっとも現実的な縮小案は、1 テーブル削減案
- 2 テーブル削減案は、初回リリース優先時の条件付き案であり、戻し工事前提の実装 discipline を強く要求する
16-7-11. 推奨結論
設計としての結論は明確です。
- 標準推奨は、引き続き 8 テーブル構成
- さらに削減したい場合の第一候補は、
StoreProductMappingの外部設定化 - 次点として
Planの削減はありえるが、Planは内部業務概念であり、StoreProductMappingより慎重に扱うべきである Entitlement、GooglePurchaseToken、BillingTransaction、Subscription側へ踏み込む削減は、初期コストを下げる代わりに、後続の運用・調査・拡張コストを大きく押し上げやすい
したがって、最も現実的な縮小案は StoreProductMapping のみを外部設定へ逃がし、Plan は残す案です。
一方で、StoreProductMapping と Plan の両方を削る 2 テーブル削減案は、初回リリース優先時には成立しうるものの、概念まで削るのではなく、戻し工事前提で内部キーと resolver 境界を初回から整えておくことを強く要求します。
16-8. Plan
Plan は、自社サービス上の課金プランです。
ストアの商品 ID はストア都合で増減しますが、アプリの権限や価格帯の概念は自社都合で安定して参照したいことが多いため、自社が意味づけしたプラン概念を先に固定します。これにより、ストア識別子を業務ロジックへ直接漏らさずに済みます。
課金周期は汎用的な期間表現ではなく、販売する商品期間そのものを表す billingPeriod で持ちます。
この設計にすると、MONTHLY、QUARTERLY、SEMI_ANNUAL、YEARLY のように、実際に販売するプラン単位で表現できます。初回リリースで実際に使う値が MONTHLY のみでも、将来の年額や複数月プラン追加に備えた定義がしやすくなります。
| カラム | MUST/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
planId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
code | MUST | varchar(64) | NOT NULL | - | 業務上の安定キー。例: basic_monthly | 商品マッピング |
name | MUST | varchar(128) | NOT NULL | - | 管理画面や社内で使う表示名 | 管理・調査 |
billingPeriod | MUST | BillingPeriod | NOT NULL | - | 販売する商品期間。例: MONTHLY, YEARLY | 商品マッピング、商品・価格 |
isActive | MUST | boolean | NOT NULL | true | 新規販売・新規付与に使ってよいか | 商品マッピング、手動復旧 |
16-9. StoreProductMapping
StoreProductMapping は、Apple / Google の商品定義を Plan に結び付けるテーブルです。
Google では productId に加えて basePlanId や offerId があり、Apple と Google では識別子の構造が異なります。この差をアプリ全体に漏らさずに吸収する境界として置きます。商品追加や offer 追加のたびに条件分岐を各所へ散らさないためにも、初回から独立させておくのが安全です。
| カラム | MUST/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
storeProductMappingId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
provider | MUST | BillingProvider | NOT NULL | - | APPLE / GOOGLE | 商品マッピング |
planId | MUST | uuid | NOT NULL | - | 対応する自社 Plan | 商品マッピング |
productId | MUST | varchar(128) | NOT NULL | - | Apple の product ID、Google の subscription product ID | 商品マッピング |
basePlanId | MUST | varchar(128) | NULL | - | Google の base plan。Apple では通常 NULL | 商品マッピング、初回特典 |
offerId | MUST | varchar(128) | NULL | - | Google の offer。Apple では通常 NULL | 商品マッピング、初回特典 |
basePlanIdNormalized | MUST | varchar(128) | NOT NULL | '' | unique 制約を安定して成立させるための内部補助列。NULL は空文字へ揃える | 商品マッピング、冪等性 |
offerIdNormalized | MUST | varchar(128) | NOT NULL | '' | unique 制約を安定して成立させるための内部補助列。NULL は空文字へ揃える | 商品マッピング、冪等性 |
isActive | MUST | boolean | NOT NULL | true | その対応関係を新規判定に使ってよいか | 商品マッピング、手動復旧 |
補足:
basePlanIdNormalized / offerIdNormalized は、nullable な basePlanId / offerId を含む一意制約を安定して成立させるための内部的な補助列です。検索条件や業務上の意味を持つ列ではなく、一意制約のためだけに保持します。
これらの列は、手入力・手動更新を前提としません。常に元値カラムと同期される運用とし、insert / update のたびにアプリケーション層で同期します。
同期ルールは次のとおりです。
basePlanIdがNULLの場合、basePlanIdNormalizedには''を設定するbasePlanIdに値がある場合、basePlanIdNormalizedにはその値をそのまま設定するofferIdがNULLの場合、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/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
billingAccountId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
userId | MUST | uuid | NOT NULL | - | 課金帰属先に対応する永続識別子への参照。本ドキュメントでは User.id が不変である前提の簡略モデルとして userId と表記する | 紐付け、再紐付け |
accountToken | MUST | uuid | NOT NULL | uuid() | 自社課金主体を表す安定トークン。BillingAccount 作成時に生成し、Apple appAccountToken や Google obfuscated ID に共通利用する | 紐付け、再紐付け、購入復元 |
hasStartedTrialAtLeastOnce | SHOULD | boolean | NOT NULL | false | トライアル利用歴あり / なしを高速に判定する補助フラグ | 利用履歴、初回特典 |
firstTrialStartedAt | SHOULD | timestamptz | NULL | - | 最初に無料トライアルを開始した日時 | 利用履歴、管理・調査 |
lastRecoveredAt | SHOULD | timestamptz | NULL | - | 支払い問題から最後に回復した日時。CS や離脱分析に有用 | 回復検知、管理・調査 |
実プロジェクトで退会・再入会により
User.idが変わるなら、実装では列名もpersistentUserIdなどへ寄せるほうが誤解が少なくなります。ここでは既存章との整合のためuserId表記を維持しています。
16-11. Subscription
Subscription は、現在の契約状態のスナップショットです。
Apple / Google から取得した状態を、自社で参照しやすい形へ投影した現在値として持ちます。BillingTransaction が履歴であるのに対し、Subscription は現在状態を表します。正常、トライアル中、猶予期間中、失効、返金後などを一箇所で参照できるようにするための中心テーブルです。
| カラム | MUST/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
subscriptionId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
provider | MUST | BillingProvider | NOT NULL | - | APPLE / GOOGLE | 契約状態、通知処理 |
billingAccountId | MUST | uuid | NOT NULL | - | 課金主体への参照 | 紐付け、再紐付け |
planId | MUST | uuid | NOT NULL | - | 現在紐付いている自社 Plan | 商品マッピング、利用可否提供 |
status | MUST | SubscriptionStatus | NOT NULL | - | ACTIVE / TRIALING / IN_GRACE_PERIOD などの契約状態 | 契約状態、決済異常検知、回復検知、猶予期間終了検知、返金・取消、状態確定 |
acknowledgementStatus | MUST | AcknowledgementStatus | NOT NULL | - | Google 向けの ack 状態。provider ごとに明示設定する。Apple では通常 NOT_REQUIRED、Google では PENDING / ACKNOWLEDGED を入れる | Google 固有、再処理、状態確定 |
currentPeriodStartAt / currentPeriodEndAt | MUST | timestamptz / timestamptz | NULL | - | 現在契約期間 | 契約状態、利用可否提供 |
gracePeriodEndsAt | MUST | timestamptz | NULL | - | 猶予期間の終了見込み | 契約状態、猶予期間終了検知 |
expiresAt | MUST | timestamptz | NULL | - | 利用停止の基準時刻として使う | 契約状態、権限反映、利用可否提供 |
appleOriginalTransactionId | MUST | varchar(128) | NULL | - | Apple 契約を一意に辿る主キー候補 | 紐付け、再紐付け、購入復元、管理・調査 |
appleLatestTransactionId | MUST | varchar(128) | NULL | - | 最新 transaction の把握や再照会起点に使う | 状態確定、管理・調査 |
currentGooglePurchaseToken | MUST | varchar(512) | NULL | - | 現在有効な Google purchase token への参照用キャッシュ | 購入復元、再紐付け、Google 固有、管理・調査 |
canceledAt | SHOULD | timestamptz | NULL | - | 解約確定日時や自動更新オフ検知時の記録に使う | 契約状態、権限反映 |
appleAppAccountToken | SHOULD | uuid | NULL | - | Apple 側へ渡した自社アカウント識別子の保持 | 紐付け、再紐付け、購入復元 |
googleObfuscatedExternalAccountId | SHOULD | varchar(128) | NULL | - | Google から返る難読化アカウント ID | 紐付け、再紐付け、購入復元 |
googleObfuscatedExternalProfileId | SHOULD | varchar(128) | NULL | - | Google から返る難読化プロフィール ID | 紐付け、再紐付け |
lineItemsLatestSuccessfulOrderId | SHOULD | varchar(128) | NULL | - | Google の lineItems.latest_successful_order_id を保持する補助列 | Google 固有、管理・調査 |
statusReason | SHOULD | varchar(64) | NULL | - | APPLE_BILLING_RETRY / GOOGLE_ACCOUNT_HOLD など、内部状態だけでは落ちる文脈を残す補助列 | 決済異常検知、回復検知、管理・調査 |
lastVerifiedAt | SHOULD | timestamptz | NULL | - | 最後にストア再照会した時刻。問い合わせ対応や補正ジョブで有用 | 状態確定、欠落補正、管理・調査 |
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/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
googlePurchaseTokenId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
subscriptionId | MUST | uuid | NOT NULL | - | 対応する Subscription | Google 固有、再紐付け |
purchaseToken | MUST | varchar(512) | NOT NULL | - | Google が返す token 本体。全体で一意 | 購入証跡連携、購入復元、Google 固有、管理・調査 |
linkedFromPurchaseToken | MUST | varchar(512) | NULL | - | ひとつ前の token。linkedPurchaseToken を履歴として残す | 再紐付け、Google 固有、競合防止 |
isCurrent | MUST | boolean | NOT NULL | false | 現在有効な token かどうか | 状態確定、Google 固有 |
invalidatedAt | MUST | timestamptz | NULL | - | 現在 token でなくなった時刻 | 権限反映、競合防止、管理・調査 |
lineItemsLatestSuccessfulOrderId | SHOULD | varchar(128) | NULL | - | その token で見えた最新成功 order ID | Google 固有、管理・調査 |
補足: 共通列の 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 を系列キーにし、必要に応じて appleLatestTransactionId と BillingTransaction.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 にできます。ストア依存の細かな状態差分をアプリ側へ漏らさないための境界でもあります。
Entitlement は billingAccountId + planId で一意な現在の利用権として扱います。初回リリースの実データでは、1 本の Subscription から 1 本の Entitlement が導かれているように見えることがあります。
ただし、これは current scope での見え方にすぎず、概念上もスキーマ上も厳密な 1:1 を保証するものではありません。
sourceSubscriptionId は、その利用権を導いた主な sourceを指す補助参照です。nullable であり、唯一の親子関係や厳密な 1:1 対応を保証するための列ではありません。Prisma スキーマでも Subscription.sourceEntitlements: Entitlement[] として表現しており、モデル上は 1:1 固定ではなく、将来の投影元拡張や説明責務の分離に耐える形にしています。
| カラム | MUST/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
entitlementId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
billingAccountId | MUST | uuid | NOT NULL | - | どの課金主体に対する利用権か | 利用可否提供、紐付け |
planId | MUST | uuid | NOT NULL | - | どのプランの利用権か | 商品マッピング、利用可否提供 |
isActive | MUST | boolean | NOT NULL | - | 現在使わせてよいか | 権限反映、利用可否提供 |
reason | MUST | EntitlementReason | NOT NULL | - | ACTIVE / IN_GRACE_PERIOD / EXPIRED など、利用可否の説明用理由 | 権限反映、利用可否提供、管理・調査 |
startsAt / endsAt | MUST | timestamptz / timestamptz | NULL | - | 利用期間 | 利用可否提供、管理・調査 |
sourceSubscriptionId | MUST | uuid | NULL | - | この利用権を導いた主な Subscription を指す補助参照 | 状態確定、監査保存、管理・調査 |
16-15. BillingTransaction
BillingTransaction は、課金イベント履歴です。
Subscription が現在状態、BillingTransaction が履歴という役割分担にします。ここには、購入、更新、回復、返金、取消、商品変更などを時系列で失わずに保存します。現在値だけでは説明できない事実を残すためのテーブルです。
また、運用時にストアコンソールを参照できるとしても、それだけで BillingTransaction の代替にはなりません。
ストアコンソール参照は外部状態の確認には有用ですが、自社システムが保存した時系列監査の代替にはならないためです。
| カラム | MUST/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
billingTransactionId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
subscriptionId | MUST | uuid | NOT NULL | - | 対応する Subscription | 履歴管理、管理・調査 |
provider | MUST | BillingProvider | NOT NULL | - | APPLE / GOOGLE | 履歴管理 |
environment | MUST | StoreEnvironment | NULL | - | PRODUCTION / SANDBOX。環境切り分けに使う | 環境分離、管理・調査 |
eventType | MUST | BillingEventType | NOT NULL | - | PURCHASED / RENEWED / REFUNDED など | 決済異常検知、回復検知、返金・取消、履歴管理 |
transactionKey | MUST | varchar(512) | NOT NULL | - | 冪等記録用キー。重複保存を防ぐ | 冪等性、履歴管理 |
occurredAt | MUST | timestamptz | NOT NULL | - | ストア上でそのイベントが起きた時刻 | 順不同耐性、履歴管理、管理・調査 |
rawSnapshot | MUST | jsonb | NULL | - | ストア応答の原文スナップショット | 監査保存、手動復旧、管理・調査 |
storeTransactionId | SHOULD | varchar(128) | NULL | - | Apple transactionId など、ストア側の主要識別子 | 管理・調査 |
orderId | SHOULD | varchar(128) | NULL | - | Google order ID | Google 固有、管理・調査 |
purchaseToken | SHOULD | varchar(512) | NULL | - | Google token のスナップショット | 購入証跡連携、再紐付け、管理・調査 |
appleOriginalTransactionId | SHOULD | varchar(128) | NULL | - | Apple 契約系列を履歴側からも辿れるようにする | 再紐付け、購入復元、管理・調査 |
googleLinkedPurchaseToken | SHOULD | varchar(512) | NULL | - | Google の旧 token への接続を履歴側にも残す | 再紐付け、Google 固有、競合防止 |
purchasedAt | SHOULD | timestamptz | NULL | - | 購入成立時刻 | 利用履歴、管理・調査 |
expiresAt | SHOULD | timestamptz | NULL | - | そのイベント時点で見えた期限 | 契約状態、履歴管理 |
revokedAt | SHOULD | timestamptz | NULL | - | 返金・取消などで失効した時刻 | 返金・取消、権限反映、履歴管理 |
補足: 共通列の createdAt は、このテーブルではイベント発生時刻ではなく保存時刻として扱います。ストア上の発生時刻は occurredAt に分離します。
補足: transactionKey は、Apple / Google の外部識別子を材料に組み立てる冪等キーです。初回リリースでは可読な連結文字列をそのまま保持する方式で十分に運用できます。
カラム長を varchar(512) とする理由や、可読キー方式から固定長キーへ移行する余地は 21-6. transactionKey の決め方 で詳しく説明します。ここでは、BillingTransaction に冪等記録用キーを必ず持たせることを押さえてください。
16-16. InboundWebhookEvent
InboundWebhookEvent は、Apple App Store Server Notifications と Google RTDN の受信監査テーブルです。
通知は欠落も再送もありえるため、受け取ったか、署名検証したか、処理したか、どこで失敗したかを保存する受け皿が必要です。課金状態そのものではなく、通知処理の事実と再処理起点を持つためのテーブルとして位置づけます。
これはストア通知の状態そのものを保存するためのテーブルではありません。
自社の受信・検証・再処理運用を成立させるための監査テーブルとして持ちます。ストアコンソールだけでは、自社 API が通知を受け取れたか、検証に成功したか、どの段階で処理失敗したかまでは代替できません。
| カラム | MUST/SHOULD | 型 | NULL/NOT NULL | デフォルト値 | 役割 | 主に影響する要件 |
|---|---|---|---|---|---|---|
inboundWebhookEventId | MUST | uuid | NOT NULL | uuid() | 内部主キー | — |
provider | MUST | BillingProvider | NOT NULL | - | APPLE / GOOGLE | 通知処理 |
providerEventId | MUST | varchar(128) | NOT NULL | - | プロバイダ側イベント ID。冪等化の主キー | 冪等性、通知処理 |
providerBusinessKey | MUST | varchar(512) | NULL | - | Apple の originalTransactionId、Google の purchaseToken など、業務的な追跡キー | 欠落補正、再処理、管理・調査 |
environment | MUST | StoreEnvironment | NULL | - | PRODUCTION / SANDBOX | 環境分離、管理・調査 |
eventType | MUST | varchar(64) | NOT NULL | - | 通知種別 | 通知処理、決済異常検知、回復検知、猶予期間終了検知 |
verified | MUST | boolean | NOT NULL | false | 署名検証に成功したか | 通知処理、監査保存 |
processResult | MUST | WebhookProcessResult | NOT NULL | RECEIVED | RECEIVED / PROCESSED / FAILED などの処理結果 | 再処理、監視、欠落補正 |
rawPayload | MUST | jsonb | NOT NULL | - | 生 payload | 監査保存、手動復旧、管理・調査 |
firstReceivedAt | MUST | timestamptz | NOT NULL | now() | 最初に受信した時刻 | 通知処理、監視、管理・調査 |
processedAt | MUST | timestamptz | NULL | - | 投影処理まで完了した時刻 | 通知処理、監視、欠落補正 |
subscriptionId | SHOULD | uuid | NULL | - | 既知なら紐付ける Subscription | 管理・調査、手動復旧 |
processingError | SHOULD | text | NULL | - | 失敗理由の詳細 | 再処理、監視、手動復旧 |
補足: eventType は provider から受け取った raw の通知種別を監査目的で保持する列として string のままにします。内部で正規化したイベント分類が必要な場合は、BillingTransaction.eventType のような別 enum へ投影して扱います。
16-17. 一意制約と業務キー
一意制約は、表示上の自然キーではなく、冪等更新に必要なキーから決めます。
| 対象 | 推奨キー | 意図 |
|---|---|---|
StoreProductMapping | (provider, productId, basePlanIdNormalized, offerIdNormalized) | 同一ストア商品を一意に識別する |
Subscription (Apple) | appleOriginalTransactionId の partial unique | Apple 契約を 1 行へ固定する |
Subscription (Google 現在 token) | currentGooglePurchaseToken の partial unique | 現在 token の二重所属を防ぐ |
GooglePurchaseToken | purchaseToken | token 履歴の重複保存を防ぐ |
GooglePurchaseToken | subscriptionId WHERE isCurrent = true の partial unique | 1 購読に current token を 1 本だけ持つ |
Entitlement | (billingAccountId, planId) | 同一プランの利用権を重複発行しない |
BillingTransaction | transactionKey | 同じ課金イベントの二重記録を防ぐ |
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 とアプリケーションで共有して使う 閉じた内部語彙 を定義します。
方針として、内部で正規化した分類値は enum、provider から受け取る raw 値は string で扱います。
17-1. 定義方針
- Apple / Google の差分を吸収した内部状態や内部イベント分類は enum として定義する
- provider から受け取る raw notification type / subtype のような値は string のまま保持する
Planの課金周期は、汎用期間ではなく販売する商品期間としてBillingPeriodで表現するStoreEnvironmentは enum とするが、入力時点で不明なケースに備えて DB 列自体はNULLを許容してよい
17-2. BillingProvider
| 値 | 意味 |
|---|---|
APPLE | Apple App Store を表す |
GOOGLE | Google Play を表す |
主な利用箇所: StoreProductMapping.provider, Subscription.provider, BillingTransaction.provider, InboundWebhookEvent.provider
17-3. BillingPeriod
Plan の販売する商品期間です。
汎用的な期間表現ではなく、実際に販売するプラン単位を enum として定義します。
値集合は初回リリースで追加するプランだけに厳密に閉じず、サブスク商品として実務上採用しやすい代表的な期間をあらかじめ定義しています。一方で、enum に値があることはその期間のプランを初回リリースで販売することを意味しません。初回リリースで実際に使う値が MONTHLY のみであっても問題なく、QUARTERLY / SEMI_ANNUAL / YEARLY は将来のプラン追加余地として先に定義しておく想定です。
| 値 | 意味 |
|---|---|
MONTHLY | 月額プラン |
QUARTERLY | 3 か月プラン |
SEMI_ANNUAL | 6 か月プラン |
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_HOLD | Google の account hold などで一時停止相当 |
PAUSED | Google の pause 状態 |
CANCELED | 自動更新停止済みだが、期限までは有効でありうる状態 |
EXPIRED | 契約期限切れ |
REVOKED | 返金・取消などにより失効した状態 |
主な利用箇所: Subscription.status
17-6. AcknowledgementStatus
| 値 | 意味 |
|---|---|
NOT_REQUIRED | acknowledgement が不要 |
PENDING | acknowledgement 未完了 |
ACKNOWLEDGED | acknowledgement 完了 |
主な利用箇所: Subscription.acknowledgementStatus
17-7. BillingEventType
BillingTransaction に記録する内部正規化イベント分類です。InboundWebhookEvent.eventType の raw 値とは役割が異なります。
| 値 | 意味 |
|---|---|
PURCHASED | 初回購入 |
RENEWED | 契約更新 |
RECOVERED | 支払い問題等からの回復 |
CANCELED | 自動更新停止 |
RESTARTED | 停止していた自動更新の再開 |
PAUSED | pause 開始 |
RESUMED | pause 解除 |
ENTERED_GRACE_PERIOD | 猶予期間入り |
ENTERED_BILLING_RETRY | 決済再試行状態入り |
ENTERED_ON_HOLD | account 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.eventType は string のままにします。
この列は 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 などへ読み替えてください。なお、accountToken は BillingAccount 作成時に生成される前提なので、例でも @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を持たせて説明可能にするEntitlementはbillingAccountId + planIdで一意な read model / projection であるsourceSubscriptionIdは entitlement の「唯一の親」ではなく、主な source を指す補助参照であるsourceSubscriptionIdは nullable であり、SubscriptionとEntitlementの厳密な 1:1 を保証しないSubscription.sourceEntitlementsを配列で表しているのは、モデル上 1:1 固定にしないためであるBillingTransactionは履歴であり、環境差分と系列キーも必要に応じて保持するInboundWebhookEventは受信イベントの監査と再処理の起点であり、providerBusinessKeyとprocessResultを持たせる- 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 条件付きで一意にしたい GooglePurchaseTokenでisCurrent = 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 を追加する前に重複を洗い出す
rawSnapshotやrawPayloadは JSONB を想定し、インデックスを貼りすぎないbasePlanIdNormalized/offerIdNormalizedは手動更新しない内部補助列とし、insert/updateごとにアプリケーション層で元値と同期するSubscription.statusとEntitlement.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/
apiとbatchは 同じコードベースに統合してよい ですが、同じプロセスで動かす必要はありません。
運用上は、apiapp とbatchapp を別エントリポイント・別プロセスとして起動し、共通ロジックだけを library で共有する形を推奨します。
現在利用可否取得責務を既存の画面取得 API に含める設計なら、entitlement-query.service.tsは library 側に置いたまま、settingsなどの画面系 module から呼ぶ形にすると自然です。
19-2. controller 命名と責務分割の原則
controller 名は、誰向けの API か と 何をする API か がファイル名と class 名で分かる形にすると保守しやすくなります。
| API名 | Path | 推奨 controller class | 推奨ファイル名 |
|---|---|---|---|
| Apple購入情報反映API | /billing/apple/ingest | AppleBillingIngestController | apple-billing-ingest.controller.ts |
| Google購入情報反映API | /billing/google/ingest | GoogleBillingIngestController | google-billing-ingest.controller.ts |
| Apple通知受信API | /webhooks/apple/notifications | AppleBillingNotificationsController | apple-billing-notifications.controller.ts |
| Google通知受信API | /webhooks/google/rtdn | GoogleBillingRtdnController | google-billing-rtdn.controller.ts |
| 利用可否取得API | /me/entitlement | MyEntitlementController | my-entitlement.controller.ts |
| 課金状態再照合API | /admin/billing/reconcile | BillingAdminReconcileController | billing-admin-reconcile.controller.ts |
| 契約詳細取得API | /me/billing/subscription | MyBillingSubscriptionController | my-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. サーバ側の基本動作
- 認証済みの現在ユーザーに対応する
BillingAccountを取得する actionがpurchaseかrestoreかを確認する- 冪等性チェックを行う
transactionIdを使って App Store Server API を呼ぶ- 必要に応じて
Get All Subscription Statusesなどで補足確認する- 主要 endpoint は、保存済みの transaction identifier を path parameter として利用できる
- ストア再照会で得た
appAccountTokenと、現在ユーザーのBillingAccount.accountToken、既存のoriginalTransactionId系列を合わせて見て紐付け可否を判定する Subscription/Entitlement/ 履歴を更新する- クライアント向けの最終利用可否を返す
20-5-5. この endpoint に持たせるべき責務
- やること
- Apple への再照会
- 内部状態への projection
- 復元時の紐付け判定
appAccountTokenと契約系列の両方を使った照合
- やらないこと
- クライアント申告だけで entitlement を付ける
accountTokenを購入後入力の必須パラメータとして扱う- Apple 通知待ちだけで初回反映を完了させる
- UI 向けの詳細表示情報を過剰に返す
20-5-6. 主な呼び出し場面
場面A. 購入完了直後の最短反映
StoreKit で購入成功後、クライアントは transactionId を取得してこの API を呼びます。
appAccountToken 自体はこの時点で送るのではなく、購入開始時にすでに埋め込まれています。サーバは Apple へ再照会し、返ってきた appAccountToken と現在ユーザーの BillingAccount を照合したうえで、購入直後に Subscription と Entitlement を更新します。
この経路があることで、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 state20-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 などの追加情報はストア応答から確認できます。
ただし、実運用では次の目的で packageName や productId を 任意で受け取る 設計も有効です。
- 想定外のアプリや商品に対する token を早期に弾く
- 監査ログにクライアント申告値を残す
- クライアント実装とサーバ実装の不整合を検知する
したがって、本ドキュメントでは purchaseToken を最小必須、packageName / productId は 追加検証用の任意項目 として扱います。
20-6-5. サーバ側の基本動作
- 認証済みの現在ユーザーに対応する
BillingAccountを取得する actionがpurchaseかrestoreかを確認する- 冪等性チェックを行う
purchaseTokenと補完済みのpackageNameを使ってpurchases.subscriptionsv2.getを呼ぶ- 状態、帰属先、line item を確認する
- ストア再照会で得た
externalAccountIdentifiers.obfuscatedExternalAccountIdと、現在ユーザーのBillingAccount.accountToken、既存のlinkedPurchaseToken系列を合わせて見て紐付け可否を判定する Subscription/Entitlement/ 履歴を更新する- entitlement 付与可能で、かつ new purchase token を伴う未 acknowledgement purchase なら acknowledgement を実行する
- クライアント向けの最終利用可否を返す
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 まで行って Subscription と Entitlement を反映します。
場面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 state20-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 判定ロジックを画面ごとに分散させない
isActiveやreasonなどの意味を 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 を呼んで isActive や effectiveUntil を取得し、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 UI20-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 screen20-8. GET /me/billing/subscription の詳細
API 名: 契約詳細取得API
設定画面や契約詳細画面で、既存の画面 API や GET /me/entitlement より多い情報を返したい場合に追加を検討します。
20-8-1. 役割
この API は、利用可否判定の中核 ではなく、表示や説明責務のための read model です。
そのため、初回リリースでは無理に追加せず、必要になったら分けて増やすほうが安全です。
20-8-2. 返却候補
planCodestorestatuswillReneweffectiveUntilinTrialinGracePeriod
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 screen20-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. サーバ側の基本動作
- 管理権限を確認する
- 入力キーに応じてストア再照会を行う
- 現在の内部状態との差分を確認する
- 必要なら projection を再実行する
- 監査ログを残す
20-9-5. この endpoint の注意点
- 直接 DB を書き換える入口にしない
- 手動付与 API と混同しない
- 誰が何を再照合したかを必ず残す
20-9-6. 主な呼び出し場面
場面A. CS 問い合わせ時の状態再確認
ユーザーから「購入済みなのに使えない」「解約したのに使えてしまう」といった問い合わせが来た場合、運用者は対象の transactionId や purchaseToken を指定してこの 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 summary21. 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 つの構造へ寄せる - 商品群:
productId、basePlanId、offerIdなど、Plan 解決に必要な情報をまとめる - 時刻群:
occurredAt、expiresAt、現契約期間など、投影と監査に使う日時をまとめる - 状態フラグ:
inTrial、inGracePeriod、inBillingRetry、onHoldのような provider 差分を明示的に持つ - 共通状態: projection 関数へ直接渡せるように、
storeStateを provider 差分から切り出して持つ - 生データ:
rawSnapshotを残し、後から監査・再解析できるようにする
実装上は、Apple / Google ごとに normalizeAppleSnapshot(...)、normalizeGoogleSnapshot(...) のような関数を用意し、最後にこの共通型へ寄せるイメージです。
例えば Apple では originalTransactionId を系列識別子の主軸にし、Google では purchaseToken と linkedPurchaseToken を主軸にします。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 };
}
CANCELEDとREVOKEDの違い:CANCELEDは自動更新がオフになった状態であり、有効期限まではサービスを利用できます。REVOKEDは返金・取り消しにより即時停止が必要な状態です。この 2 つを混同すると、自動更新をオフにしただけのユーザーから不正にサービスを剥奪する事故につながります。
REVOKEDの決定方法: Google のsubscriptionStateにはSUBSCRIPTION_STATE_REVOKEDという値は存在しません。返金・取り消しはSUBSCRIPTION_STATE_EXPIREDとして返されることがあり、canceledStateContextの理由や RTDN のSUBSCRIPTION_REVOKED通知で識別します。さらに、返金・チャージバック監査を強める場合はVoidedPurchaseNotificationやpurchases.voidedpurchases.listも監査ソースに含めると安全です。本番実装では、RTDN のnotificationType、canceledStateContext、必要に応じて voided purchase 系の情報を投影ロジックの入力に組み込んでREVOKEDを判定してください。
21-4. DB 更新の流れ
- ストア API から現在状態を取得する
- provider ごとの normalizer で
StoreSubscriptionSnapshotへ正規化する StoreSubscriptionSnapshotからStoreProjectionInputを組み立て、projectSubscriptionState(...)で内部状態を決めるSubscriptionを upsert するEntitlementを更新するBillingTransactionを記録する- 必要なら 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 の主な出所の記録であって、SubscriptionとEntitlementの厳密な 1:1 を意味しない。簡略化のためreasonやstartsAt/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 が可変長の外部文字列であり、公式仕様上も最大長を前提に強く縛りにくいことです。さらに、provider、eventType、時刻、系列識別子などを連結して可読なキーを作る場合、varchar(128) では余裕が小さくなります。
そのため、transactionKey のカラム長は varchar(512) を推奨します。varchar(512) であれば、可読な連結キーを保持する方式でも、現実的な運用では十分に安全側です。
ただし、これは「512 なら絶対に保証できる」という意味ではありません。あくまで、外部識別子の長さ不確実性を踏まえた保守的な設計です。将来的にキー構成をより厳密に固定したい場合は、連結元の文字列をハッシュ化して固定長キーへ移行することも可能です。とはいえ、初回リリース方針としては、まず varchar(512) の可読キー方式で十分に運用できます。
22. Webhook / RTDN 処理詳細
22-1. 受信時の基本原則
- 入口は重い本処理を同期実行しない
- 検証と本処理を分離する
- 生 payload を保存してから再処理できるようにする
- 受信順に依存しない
- 同じイベントの再送を前提にする
ただし、署名検証や JWT 検証に失敗した通知まで無条件に 2xx で握りつぶす という意味ではありません。
認証・署名が不正な通知に対してどのステータスを返すかは、プロバイダの再送挙動と運用方針を踏まえて決めます。少なくとも本番では、検証失敗を監査ログ・アラートへ残すこと、そして 検証前に業務処理へ進めないこと を必須にしてください。
22-1-1. 共通ディスパッチャーの流れ
Webhook / RTDN の受信処理は、provider ごとの差分があっても、次の共通フローに寄せると整理しやすくなります。
- 受信 payload を検証する
providerEventIdまたは業務キーで冪等チェックするInboundWebhookEventをRECEIVEDで保存する- 重い処理は queue / worker へ引き渡す
- worker 側でストア再照会と projection を実行する
- 結果に応じて
PROCESSED/FAILED/SKIPPEDへ更新する
ここでのポイントは、通知受信 endpoint を「最終状態を同期的に決める場所」にしないことです。
受信口では、署名や JWT の検証、監査保存、非同期実行の起点づくりまでに責務を絞ると、再送・一時障害・順不同到着に強くなります。
22-2. Apple 通知の基本手順
signedPayloadを受け取る- JWS として verify and decode する
notificationUUIDを冪等キーとして保存するsignedTransactionInfo/signedRenewalInfoがあれば decode する- 受信イベントを非同期ワーカーへ引き渡す
- ワーカー側で必要に応じて 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 デコードすると、subscriptionNotification、voidedPurchaseNotification、testNotification などを含む Google Play 側 payload が得られます。
そのため、HTTP 受信直後の実装では envelope の検証・保存 と message.data の復号 を分けて扱うと見通しがよくなります。
22-3-2. 通知種別は routing のために使い、最終状態の確定には使い切らない
subscriptionNotification.notificationType は、worker がどの処理へ流すかを決めるための routing 情報として有用です。例えば、SUBSCRIPTION_PURCHASED、SUBSCRIPTION_RENEWED、SUBSCRIPTION_IN_GRACE_PERIOD、SUBSCRIPTION_ON_HOLD、SUBSCRIPTION_REVOKED などを識別できます。
ただし、これらは RTDN のイベント種別 であり、そのまま内部の Subscription.status や Entitlement.reason を表す値ではありません。RTDN の通知名だけで Entitlement を決め打ちせず、最終状態は必ず purchases.subscriptionsv2.get の再照会結果で確定します。
22-3-3. push subscription を使う場合の流れ
- Pub/Sub push を受け取る
Authorizationヘッダの JWT を検証し、想定した audience / service account からの push であることを確認するmessageIdまたは自前の業務キーで冪等化する- payload から
purchaseTokenを取り出す - 非同期ワーカーで
purchases.subscriptionsv2.getを呼ぶ - 返ってきた状態を内部状態へ投影する
本番運用では、Pub/Sub の JWT 検証を基本としつつ、必要に応じて共有トークン付き endpoint を追加して多層防御にしても構いません。
22-3-4. pull subscription を使う場合の流れ
- 常駐 worker / subscriber が Pub/Sub からメッセージを取得する
messageIdまたは自前の業務キーで冪等化する- payload から
purchaseTokenを取り出す purchases.subscriptionsv2.getを呼ぶ- 返ってきた状態を内部状態へ投影する
- 正常処理後に 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 に最低限入れておくとよい項目は次です。
providerproviderEventIdeventTypeverifiedrawPayloadprocessResultprocessedAtprocessingError
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. 受信時の基本動作
- Apple 署名を検証する
notificationUUIDなどで冪等性を担保する- 受信 payload を監査用に保存する
- 通知本文をそのまま真実の源泉にせず、必要な識別子を抽出する
- App Store Server API を再照会し、最新状態へ projection する
- すぐ返答し、重い処理は非同期化する
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
end22-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. 受信時の基本動作
- Pub/Sub push の JWT を検証する
- envelope と
message.dataを復号・解析する - event id や publish 時刻などで冪等性を担保する
- 受信データを監査用に保存する
- 通知だけで確定せず、
purchaseTokenを使って Google API へ再照会する - 最新状態へ projection する
- 署名検証や最小限の受信検証を終えたら、重い処理は非同期化する
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
end22-8-7. pull 方式を採る場合の読み替え
pull subscription を採る場合は、上図の POST /webhooks/google/rtdn を subscriber 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.acknowledgeのsubscriptionIdは 2025-05-21 以降 optional です。
本ドキュメントの主対象である通常の自動更新サブスクでは渡しても構いませんが、add-ons を扱う場合は最新仕様に合わせて省略を検討します。
また、request body にはexternalAccountIds.obfuscatedAccountId/obfuscatedProfileIdを含められます。これは resubscription purchase でのみ設定できる補助的な紐付け情報であり、通常の購入フローでは BillingFlow 側で設定した obfuscated identifiers を主経路として扱います。
23-5. 救済ジョブ
acknowledgement は、同期処理だけに頼らず救済ジョブを必ず持つのが安全です。
監視対象は少なくとも次です。
acknowledgementStatus = PENDINGpurchaseState = PURCHASEDupdatedAtが一定時間以上前Entitlementは active
24. Restore / Account Linking 詳細
24-1. restore の基本原則
restore は「過去購入をもう一度作る」処理ではなく、既存のストア契約を現在のアプリユーザーへ正しく結び直す処理です。
そのため、通常購入と同じくストア再照会を行い、既存契約があればそれを投影し直します。
24-2. ingest / restore 時の既存 Subscription 特定順序
復元や再同期で最初にやるべきことは、このストア契約が既存のどの Subscription 系列に属するかを特定することです。
accountToken 系だけ、あるいは系列識別子だけに寄せず、系列一致を優先しつつ、購入時に埋め込んだ識別子で補強する形にすると安全です。
24-2-1. Apple
originalTransactionIdで既存Subscriptionを探す- 見つかった系列の中で
transactionId/latestTransactionIdを照合する - 系列で見つからない場合に、
appAccountTokenと現在ユーザーのBillingAccount.accountTokenの一致を確認する - それでも見つからない場合に限り、新規
Subscription作成可否を検討する
Apple では、契約系列の主軸は originalTransactionId です。
appAccountToken は強い補助手掛かりですが、復元時や運用時の補正では、まず既存系列へ収まるかを見るほうが事故を防ぎやすくなります。
24-2-2. Google
purchaseTokenで既存Subscriptionを探す- 見つからなければ
linkedPurchaseTokenの連鎖から既存系列を辿る - 系列で見つからない場合に、
obfuscatedExternalAccountId/obfuscatedExternalProfileIdと現在ユーザーのBillingAccount.accountTokenの一致を確認する - 期限切れ後の out-of-app 再購読なら
outOfAppPurchaseContextを補助手掛かりにする - それでも見つからない場合に限り、新規
Subscription作成可否を検討する
Google では、現在の purchaseToken だけでなく linkedPurchaseToken まで見て系列を解くのが重要です。
plan change や resubscribe を考えると、token 単体一致だけで判断すると取りこぼしや誤新規作成が起きやすくなります。
24-3. ユーザー文脈がない通知処理での紐付け
Webhook / RTDN では、ingest と違って「現在ログイン中ユーザー」がありません。
そのため、通知から作った snapshot を適用するときは、次の順で billingAccountId を解決すると安全です。
- snapshot の系列識別子(Apple の
originalTransactionId、Google のpurchaseToken/linkedPurchaseToken)で既存Subscriptionを探す - 見つかったら、その
Subscription.billingAccountIdを採用する - 既存系列が見つからない場合に、snapshot に含まれる
accountToken系識別子からBillingAccountを探す - それでも 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 の矛盾は、自動復元不可だが存在はしている ケースとして切り分けて残すのが重要です。