ドキュメント
Part 2. 実装詳細編
モバイルアプリのアプリ内課金 実装・運用設計ドキュメント の「Part 2. 実装詳細編」をまとめたページです。
Part 2. 実装詳細編
16. DB 設計詳細
初回実装では概念を増やしすぎず、後から 再購読、補正ジョブ、監査、調査、価格変更 を足せる構成にします。
この章では、まず どのテーブルを採用すべきか を MUST / SHOULD で分け、その後に各テーブルで主に持つカラムを整理します。
16-1. 設計原則
DB 設計では、次の原則を守ると破綻しにくくなります。
- ストア状態は投影結果として保持する
- 契約状態と利用可否を分離する
- 履歴を捨てない
- 業務キーと表示用情報を分離する
- Apple と Google の外部識別子を無理に同一列へ押し込まない
- 冪等更新に必要な一意制約を先に決める
- Google の purchase token は current 値だけでなく履歴も意識する
16-2. この章での MUST / SHOULD の意味
この章で使う MUST / SHOULD は、そのテーブルを初回リリースで採用すべきかどうか を表します。
カラムの NOT NULL / NULL、アプリケーション入力での必須・任意とは意味が異なります。
そのため、各テーブルの説明では次の 2 つを分けて記載します。
- 採用優先度: テーブルとして初回リリースで入れるべきか
- まず持つカラム / 補助カラム: そのテーブルを採用した場合に、初回から主に使う列と、後から足してよい列の整理
ここでいう「まず持つカラム」も、そのまま NOT NULL を意味するものではありません。
ストア差分や状態差分により、実装上は NULL を許容したほうがよい列もあります。
また、この章では便宜上、各テーブル内の 「まず持つカラム」= 初回リリースで MUST 寄りに持つカラム、「補助カラムとして持つとよいもの」= SHOULD 寄りに追加するとよいカラム と読みます。
ただし、これは DB 制約そのものではなく、設計上の優先度 を示すものです。
16-3. 推奨 ER 図
以下は、本ドキュメント全体として推奨するフル構成です。
後述の MUST / SHOULD に応じて、初回リリースでは一部を段階導入して構いません。
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 id PK
text omittedFields
}
BillingAccount {
uuid id PK
uuid userId UK
uuid accountToken UK
boolean hasStartedTrialAtLeastOnce
timestamp firstTrialStartedAt
timestamp lastRecoveredAt
timestamp createdAt
timestamp updatedAt
}
Plan {
uuid id PK
text code UK
text name
text intervalUnit
boolean isActive
timestamp createdAt
timestamp updatedAt
}
StoreProductMapping {
uuid id PK
text provider
uuid planId FK
text productId
text basePlanId
text offerId
text basePlanIdNormalized
text offerIdNormalized
boolean isActive
timestamp createdAt
timestamp updatedAt
}
Subscription {
uuid id PK
text provider
uuid billingAccountId FK
uuid planId FK
text status
text acknowledgementStatus
timestamp currentPeriodStartAt
timestamp currentPeriodEndAt
timestamp gracePeriodEndsAt
timestamp canceledAt
timestamp expiresAt
text appleOriginalTransactionId
text appleLatestTransactionId
text appleAppAccountToken
text currentGooglePurchaseToken
text googleObfuscatedExternalAccountId
text googleObfuscatedExternalProfileId
text lineItemsLatestSuccessfulOrderId
timestamp lastVerifiedAt
timestamp createdAt
timestamp updatedAt
}
GooglePurchaseToken {
uuid id PK
uuid subscriptionId FK
text purchaseToken UK
text linkedFromPurchaseToken
boolean isCurrent
text lineItemsLatestSuccessfulOrderId
timestamp createdAt
timestamp invalidatedAt
}
Entitlement {
uuid id PK
uuid billingAccountId FK
uuid planId FK
boolean isActive
text reason
timestamp startsAt
timestamp endsAt
uuid sourceSubscriptionId FK
timestamp createdAt
timestamp updatedAt
}
BillingTransaction {
uuid id PK
uuid subscriptionId FK
text provider
text environment
text eventType
text transactionKey UK
text storeTransactionId
text orderId
text purchaseToken
text appleOriginalTransactionId
text googleLinkedPurchaseToken
timestamp occurredAt
timestamp purchasedAt
timestamp expiresAt
timestamp revokedAt
json rawSnapshot
timestamp createdAt
}
InboundWebhookEvent {
uuid id PK
uuid subscriptionId FK
text provider
text environment
text providerEventId
text providerBusinessKey
text eventType
boolean verified
text processResult
text processingError
json rawPayload
timestamp firstReceivedAt
timestamp processedAt
}16-4. User の扱い
User の属性はプロジェクト依存であり、本ドキュメントでは課金に必要な関連だけを扱います。
そのため、ER 図では User の非課金属性を omittedFields として省略表現し、メールアドレスや認証方式を前提にしません。
16-5. テーブル採用優先度一覧
| テーブル | 採用優先度 | 主な役割 | 守っていること | 削った場合の主なリスク |
|---|---|---|---|---|
Plan | MUST | 自社の課金プラン定義 | ストア商品 ID を業務ロジックへ直接漏らさない | ストア識別子が業務ロジックへ流出し、商品差し替えや複数ストア対応で条件分岐が崩れやすくなる |
StoreProductMapping | MUST | ストア商品と Plan の対応付け | Apple / Google の商品構造差分を境界で吸収する | product / base plan / offer の解釈が各所へ散らばり、誤った Plan へ投影しやすくなる |
BillingAccount | MUST | 課金の主体 | User と決済主体を分け、restore やアカウント連携の基点を持つ | 認証主体と課金主体が混ざり、復元・救済・トライアル履歴管理が不安定になる |
Subscription | MUST | 現在の契約状態 | ストア状態の現在値を一箇所へ集約する | 正常 / 猶予中 / 失効などの判定を毎回再計算することになり、API と運用の両方が不安定になる |
GooglePurchaseToken | MUST | Google purchase token の履歴管理 | purchaseToken / linkedPurchaseToken 系列を安全に扱う | restore、re-signup、token 差し替え、重複付与防止、障害調査が弱くなる |
Entitlement | MUST | 自社サービスの利用可否 | 契約状態と利用可否を分離し、アプリへ安定した利用判定を返す | ストア状態の差分がアプリ側へ漏れ、猶予期間中の継続利用などを一貫して表現しづらくなる |
BillingTransaction | MUST | 課金イベント履歴 | 監査、問い合わせ、再投影の根拠を残す | 現在値しか残らず、なぜその状態になったか説明できなくなる |
InboundWebhookEvent | MUST | 通知受信の監査と再処理起点 | webhook / RTDN の欠落、再送、処理失敗を区別できるようにする | 通知の冪等処理と障害復旧が弱くなり、受信済みか未処理かを判別しにくくなる |
16-6. Plan
Plan は、自社サービス上の課金プランです。
ストアの商品 ID はストア都合で増減しますが、アプリの権限や価格帯の概念は自社都合で安定して参照したいことが多いです。そのため、自社が意味づけしたプラン概念をまず Plan として固定します。
採用優先度
MUST
このテーブルの責務
- 自社サービスにおける「何を売っているか」を、ストア商品 ID と切り離して定義する
- entitlement、権限制御、将来の価格改定や商品差し替えの基準点を提供する
- Apple / Google の両方で同等な商品を、同じ内部プランとして扱えるようにする
削った場合の主なリスク
monthly_basic_ios や premium_yearly_google のようなストア識別子がそのまま業務ルールへ漏れます。すると、ストアごとの命名差分、商品差し替え、base plan 追加のたびにアプリ全体の条件分岐を書き換えることになります。さらに、同じ月額プランでもストアごとに別物として扱われやすくなり、権限付与や分析軸が不安定になります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
code | 業務上の安定キー。例: basic_monthly |
name | 管理画面や社内で使う表示名 |
intervalUnit | MONTH / YEAR などの課金周期の概念 |
isActive | 新規販売・新規付与に使ってよいか |
createdAt / updatedAt | 監査と変更追跡 |
16-7. StoreProductMapping
StoreProductMapping は、Apple / Google の商品定義を Plan に結び付けるテーブルです。
Google では productId に加えて basePlanId や offerId があり、Apple と Google では識別子の構造が異なります。この差をアプリ全体に漏らさずに吸収するための境界がこのテーブルです。
採用優先度
MUST
このテーブルの責務
- ストア商品を自社
Planへ正しく解決する境界を提供する - Google の
productId/basePlanId/offerIdと Apple の product ID の差分を吸収する - projection や API 実装から商品解決ロジックを切り離し、商品追加時の変更点を局所化する
削った場合の主なリスク
API 層や projection 層で「if productId === ...」の分岐が散らばり、商品追加や offer 追加のたびに複数箇所を修正する必要があります。価格変更やプロモーションを後から足すときにも、ここがない構成は崩れやすいです。特に Google では offer の解釈を誤って、無料トライアル付き購入や通常購入を誤った Plan へ投影する危険があります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
provider | APPLE / GOOGLE |
planId | 対応する自社 Plan |
productId | Apple の product ID、Google の subscription product ID |
basePlanId | Google の base plan。Apple では通常 NULL |
offerId | Google の offer。Apple では通常 NULL |
basePlanIdNormalized | unique 制約用の正規化列。NULL は空文字へ揃える |
offerIdNormalized | unique 制約用の正規化列。NULL は空文字へ揃える |
isActive | その対応関係を新規判定に使ってよいか |
createdAt / updatedAt | 監査と変更追跡 |
16-8. BillingAccount
BillingAccount は、課金の主体です。
多くのサービスでは User と 1:1 で始めて問題ありませんが、課金の主体をアプリ上のユーザー概念から少しだけ分離しておくと、restore、移管、将来の外部決済追加、複数アカウント統合で設計が崩れにくくなります。
採用優先度
MUST
このテーブルの責務
- 認証主体である
Userと、決済主体である課金アカウントを分離する - restore、アカウント連携、誤紐付け救済の基点を提供する
- トライアル利用歴や支払い問題からの回復履歴など、課金主体に紐づく補助情報の置き場を持つ
削った場合の主なリスク
User テーブルに直接 Apple / Google の課金識別子や restore 補助情報が入りやすくなり、認証・プロフィール・課金の責務が混ざりやすくなります。初回は 1:1 でも、概念として分けておく価値があります。これがないと、購入復元時に「誰の契約か」を安全に判断しづらくなり、トライアル利用歴や回復履歴の保管先も曖昧になります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
userId | 自社ユーザーへの参照 |
accountToken | 自社課金主体を表す安定トークン。Apple appAccountToken や Google obfuscated ID の元にする |
createdAt / updatedAt | 監査と変更追跡 |
補助カラムとして持つとよいもの
| カラム | 役割 |
|---|---|
hasStartedTrialAtLeastOnce | トライアル利用歴あり / なしを高速に判定する補助フラグ |
firstTrialStartedAt | 最初に無料トライアルを開始した日時 |
lastRecoveredAt | 支払い問題から最後に回復した日時。CS や churn 分析に有用 |
16-9. Subscription
Subscription は、現在の契約状態のスナップショットです。
Apple / Google から取得した状態を、自社で参照しやすい形へ投影した現在値として持ちます。BillingTransaction が履歴であるのに対し、Subscription は現在の契約状態です。
採用優先度
MUST
このテーブルの責務
- ストアから取得した契約状態の現在値を、自社で参照しやすい形へ投影する
- API、バッチ、管理画面が同じ現在状態を参照できるようにする
- 正常、トライアル中、猶予期間中、失効、返金後などの状態判定の土台を提供する
削った場合の主なリスク
「いま active なのか」「いま grace period なのか」を毎回履歴から再計算する必要があり、API 応答もバッチも複雑になります。また、運用時に「この購読はいまどう見えているか」を一目で確認できません。要件として求められている正常、決済失敗だが猶予期間中、猶予期間終了後の判定も不安定になりやすくなります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
provider | APPLE / GOOGLE |
billingAccountId | 課金主体への参照 |
planId | 現在紐付いている自社 Plan |
status | ACTIVE / TRIALING / IN_GRACE_PERIOD などの契約状態 |
acknowledgementStatus | Google 向けの ack 状態。Apple では通常 NOT_REQUIRED |
currentPeriodStartAt / currentPeriodEndAt | 現在契約期間 |
gracePeriodEndsAt | 猶予期間の終了見込み |
expiresAt | 利用停止の基準時刻として使う |
appleOriginalTransactionId | Apple 契約を一意に辿る主キー候補 |
appleLatestTransactionId | 最新 transaction の把握や再照会起点に使う |
currentGooglePurchaseToken | 現在有効な Google purchase token への参照用キャッシュ |
createdAt / updatedAt | 監査と変更追跡 |
補助カラムとして持つとよいもの
| カラム | 役割 |
|---|---|
canceledAt | 解約確定日時や自動更新オフ検知時の記録に使う |
appleAppAccountToken | Apple 側へ渡した自社アカウント識別子の保持 |
googleObfuscatedExternalAccountId | Google から返る難読化アカウント ID |
googleObfuscatedExternalProfileId | Google から返る難読化プロフィール ID |
lineItemsLatestSuccessfulOrderId | Google の lineItems.latest_successful_order_id を保持する補助列 |
lastVerifiedAt | 最後にストア再照会した時刻。問い合わせ対応や補正ジョブで有用 |
Google の top-level
latestOrderIdは deprecated です。主要保持項目としてはlineItems.latest_successful_order_idまたはBillingTransaction.orderIdを使います。
16-10. GooglePurchaseToken
GooglePurchaseToken は、Google の purchase token 履歴を保持するためのテーブルです。
Google では plan change、re-signup、prepaid top-up などで purchase token が差し替わります。しかも linkedPurchaseToken で前 token へ辿れることがあるため、token は「現在値 1 本」ではなく履歴として扱うほうが安全です。
採用優先度
MUST
このテーブルの責務
- Google の purchase token を現在値ではなく系列履歴として保持する
linkedPurchaseTokenを使って旧 token と新 token の関係を辿れるようにする- restore、re-signup、plan change、token 差し替え時にも同一購読系列を安全に追跡する
削った場合の主なリスク
Subscription.currentGooglePurchaseToken だけでは、過去 token を入力されたときの restore、token 差し替え後の追跡、linkedPurchaseToken を使った系列統合、障害調査が弱くなります。Google を本番対象に含めるなら、現在値キャッシュだけで済ませず token 履歴を別テーブルで持つ前提にしておくほうが安全です。これがないと、旧 token と新 token の関係が見えず、重複 entitlement や旧契約の取り下げ漏れが起きやすくなります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
subscriptionId | 対応する Subscription |
purchaseToken | Google が返す token 本体。全体で一意 |
linkedFromPurchaseToken | ひとつ前の token。linkedPurchaseToken を履歴として残す |
isCurrent | 現在有効な token かどうか |
createdAt | 初回観測時刻 |
invalidatedAt | 現在 token でなくなった時刻 |
補助カラムとして持つとよいもの
| カラム | 役割 |
|---|---|
lineItemsLatestSuccessfulOrderId | その token で見えた最新成功 order ID |
16-11. 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-12. Entitlement
Entitlement は、自社サービスでそのプランを使わせてよいかを表すテーブルです。
契約状態と利用可否を分離する目的で置きます。たとえば、猶予期間中は Subscription.status = IN_GRACE_PERIOD でも Entitlement.isActive = true にできます。逆に返金時は契約履歴が残っていても isActive = false にできます。
採用優先度
MUST
このテーブルの責務
- 契約状態とは別に、自社サービスとしての利用可否を確定する
- クライアントへ返す read model として、安定した利用判定を提供する
- 猶予期間中は利用継続可、返金時は即停止、といった業務判断を
Subscriptionから切り離して表現する
削った場合の主なリスク
すべての利用判定を Subscription.status の条件分岐に押し込むことになり、Apple と Google の微妙な差分をアプリ全体へばらまくことになります。特に、決済失敗だが猶予期間中は利用継続 OK といった判定を一貫して表現しにくくなり、アプリ側にストア依存ロジックが漏れやすくなります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
billingAccountId | どの課金主体に対する利用権か |
planId | どのプランの利用権か |
isActive | 現在使わせてよいか |
reason | ACTIVE / IN_GRACE_PERIOD / EXPIRED など、利用可否の説明用理由 |
startsAt / endsAt | 利用期間 |
sourceSubscriptionId | どの Subscription から導いたか |
createdAt / updatedAt | 監査と変更追跡 |
16-13. BillingTransaction
BillingTransaction は、課金イベント履歴です。
Subscription が現在状態、BillingTransaction が履歴という役割分担にします。ここには、購入、更新、回復、返金、取消、商品変更などを時系列で失わずに保存します。
採用優先度
MUST
このテーブルの責務
- 購入、更新、回復、返金、取消、商品変更などの課金イベントを時系列で保存する
SubscriptionやEntitlementの現在値が、どのイベントを根拠に導かれたかを説明できるようにする- 再投影、調査、CS 対応、監査のための事実ログを残す
削った場合の主なリスク
「なぜ active から inactive になったのか」「二重反映したのか」「どの orderId / transactionId が根拠だったのか」を後から説明できません。運用事故や問い合わせ調査では、現在値より履歴のほうが重要になることが多いです。現在値だけ残る構成になるため、決済失敗、回復、返金、トライアル開始の根拠を遡って確認しづらくなります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
subscriptionId | 対応する Subscription |
provider | APPLE / GOOGLE |
environment | PRODUCTION / SANDBOX など。環境切り分けに使う |
eventType | PURCHASED / RENEWED / REFUNDED など |
transactionKey | 冪等記録用キー。重複保存を防ぐ |
occurredAt | ストア上でそのイベントが起きた時刻 |
rawSnapshot | ストア応答の原文スナップショット |
createdAt | 保存時刻 |
補助カラムとして持つとよいもの
| カラム | 役割 |
|---|---|
storeTransactionId | Apple transactionId など、ストア側の主要識別子 |
orderId | Google order ID |
purchaseToken | Google token のスナップショット |
appleOriginalTransactionId | Apple 契約系列を履歴側からも辿れるようにする |
googleLinkedPurchaseToken | Google の旧 token への接続を履歴側にも残す |
purchasedAt | 購入成立時刻 |
expiresAt | そのイベント時点で見えた期限 |
revokedAt | 返金・取消などで失効した時刻 |
16-14. InboundWebhookEvent
InboundWebhookEvent は、Apple App Store Server Notifications と Google RTDN の受信監査テーブルです。
通知は欠落も再送もありえます。そのため、受け取ったか、署名検証したか、処理したか、どこで失敗したかを保存する受け皿が必要です。
採用優先度
MUST
このテーブルの責務
- Apple App Store Server Notifications と Google RTDN の受信事実を監査可能な形で残す
- 通知の署名検証、冪等処理、再処理、失敗記録の基点を提供する
- webhook / RTDN が現在状態へ反映されたかどうかを追跡できるようにする
削った場合の主なリスク
通知の再送と二重処理を区別できず、障害時に「受け取っていない」のか「受け取ったが処理失敗した」のかが分かりません。通知欠落時の復旧でも、このテーブルが起点になります。決済エラー、回復、猶予期間終了のような重要イベントを通知ベースで追跡する運用も不安定になります。
まず持つカラム
| カラム | 役割 |
|---|---|
id | 内部主キー |
provider | APPLE / GOOGLE |
providerEventId | プロバイダ側イベント ID。冪等化の主キー |
providerBusinessKey | Apple の originalTransactionId、Google の purchaseToken など、業務的な追跡キー |
environment | PRODUCTION / SANDBOX など |
eventType | 通知種別 |
verified | 署名検証に成功したか |
processResult | RECEIVED / PROCESSED / FAILED などの処理結果 |
rawPayload | 生 payload |
firstReceivedAt | 最初に受信した時刻 |
processedAt | 投影処理まで完了した時刻 |
補助カラムとして持つとよいもの
| カラム | 役割 |
|---|---|
subscriptionId | 既知なら紐付ける Subscription |
processingError | 失敗理由の詳細 |
16-15. 一意制約と業務キー
一意制約は、表示上の自然キーではなく、冪等更新に必要なキーから決めます。
| 対象 | 推奨キー | 意図 |
|---|---|---|
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-16. 本番向け 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-17. 現実的な初回リリース構成
初回リリースでまず入れるべきなのは、次の MUST 8 テーブルです。
PlanStoreProductMappingBillingAccountSubscriptionGooglePurchaseTokenEntitlementBillingTransactionInboundWebhookEvent
本ドキュメントは Apple / Google の両方を初回から対象にする前提なので、Google を含むなら GooglePurchaseToken も初回から入れる整理にします。
一方で Apple については、専用の系列テーブルまでは作らず、Subscription.appleOriginalTransactionId を系列キーとして扱い、必要な履歴は BillingTransaction で補う構成を推奨します。
この 8 テーブルは、単なる正規化のためではなく、「何を売っているか」「誰の課金か」「今どういう契約か」「今使わせてよいか」「何が起きたか」「どの通知を受けたか」 をそれぞれ独立して答えられるようにするための最小構成です。
どれか 1 つを削ると、その問いのどれかに安定して答えられなくなり、課金実装ではそのまま障害点になりやすいです。
17. Prisma スキーマ例
この章では、16 章の設計を Prisma へ落とし込んだ例を示します。
以下は GooglePurchaseToken を含むフル構成例 です。
そのままコピペするための最終版ではなく、設計意図と制約の置き方を示す叩き台として使ってください。
17-1. enum 定義例
enum BillingProvider {
APPLE
GOOGLE
}
enum SubscriptionStatus {
PENDING
ACTIVE
TRIALING
IN_GRACE_PERIOD
ON_HOLD
PAUSED
CANCELED
EXPIRED
REVOKED
}
enum AcknowledgementStatus {
NOT_REQUIRED
PENDING
ACKNOWLEDGED
}
enum BillingEventType {
PURCHASED
RENEWED
RECOVERED
CANCELED
RESTARTED
PAUSED
RESUMED
ENTERED_GRACE_PERIOD
ENTERED_ON_HOLD
EXPIRED
REVOKED
REFUNDED
PRODUCT_CHANGED
}
enum EntitlementReason {
ACTIVE
IN_GRACE_PERIOD
ACTIVE_UNTIL_EXPIRATION
PENDING
EXPIRED
REVOKED
}
enum WebhookProcessResult {
RECEIVED
PROCESSED
FAILED
SKIPPED
}
17-2. model 例
model BillingAccount {
id String @id @default(uuid())
userId String @unique
accountToken String @unique
hasStartedTrialAtLeastOnce Boolean @default(false)
firstTrialStartedAt DateTime?
lastRecoveredAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
subscriptions Subscription[]
entitlements Entitlement[]
}
model Plan {
id String @id @default(uuid())
code String @unique
name String
intervalUnit String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
storeMappings StoreProductMapping[]
subscriptions Subscription[]
entitlements Entitlement[]
}
model StoreProductMapping {
id String @id @default(uuid())
provider BillingProvider
planId String
productId String
basePlanId String?
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: [id], onDelete: Restrict)
@@unique([provider, productId, basePlanIdNormalized, offerIdNormalized], map: "uq_store_product_mapping_lookup")
@@index([planId, provider])
}
model Subscription {
id String @id @default(uuid())
provider BillingProvider
billingAccountId String
planId String
status SubscriptionStatus
acknowledgementStatus AcknowledgementStatus @default(NOT_REQUIRED)
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: [id], onDelete: Cascade)
plan Plan @relation(fields: [planId], references: [id], 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 {
id 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: [id], onDelete: Cascade)
@@index([subscriptionId, isCurrent])
@@index([linkedFromPurchaseToken])
}
model Entitlement {
id 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: [id], onDelete: Cascade)
plan Plan @relation(fields: [planId], references: [id], onDelete: Restrict)
sourceSubscription Subscription? @relation("EntitlementSource", fields: [sourceSubscriptionId], references: [id], onDelete: SetNull)
@@unique([billingAccountId, planId])
@@index([billingAccountId, isActive])
@@index([sourceSubscriptionId])
}
model BillingTransaction {
id String @id @default(uuid())
subscriptionId String
provider BillingProvider
environment String?
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: [id], onDelete: Cascade)
@@index([subscriptionId, occurredAt])
@@index([provider, storeTransactionId])
@@index([provider, orderId])
@@index([provider, purchaseToken])
@@index([provider, appleOriginalTransactionId])
}
model InboundWebhookEvent {
id String @id @default(uuid())
subscriptionId String?
provider BillingProvider
environment String?
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: [id], onDelete: SetNull)
@@unique([provider, providerEventId])
@@index([provider, providerBusinessKey])
@@index([provider, firstReceivedAt])
@@index([subscriptionId])
}
17-3. スキーマの読み方
このスキーマ例では、次の意図を持たせています。
Subscriptionは現在状態GooglePurchaseTokenは Google token 履歴の真実源泉- Apple の契約系列は
appleOriginalTransactionIdで固定し、Apple 専用系列テーブルは置かない Entitlementは利用可否であり、reasonを持たせて説明可能にするBillingTransactionは履歴であり、環境差分と系列キーも必要に応じて保持するInboundWebhookEventは受信イベントの監査と再処理の起点であり、providerBusinessKeyとprocessResultを持たせる- Google の主要 order ID は
lineItems.latest_successful_order_idベースで扱う
17-4. 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;
17-5. migration 時の注意
- 既存データがある環境で unique index を追加する前に重複を洗い出す
rawSnapshotやrawPayloadは JSONB を想定し、インデックスを貼りすぎないbasePlanIdNormalized/offerIdNormalizedは application service か DB trigger でNULLと同期するSubscription.statusとEntitlement.isActiveを同一列で済ませない- Google の top-level
latestOrderIdは deprecated のため、新規実装では主要フィールドとして採用しない
18. NestJS / TypeScript 実装構成例
18-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 から呼ぶ形にすると自然です。
18-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: 補正・再処理・救済を担当する
18-3. provider ごとの分離
Apple と Google は似ていますが、細部はかなり違います。
そのため、if provider === "APPLE" の分岐を 1 つの巨大 service に集めるより、provider ごとに service を分けるほうが保守しやすくなります。
18-4. TypeScript で押さえるとよい型
export type BillingProvider = "APPLE" | "GOOGLE";
export type ProjectionInput =
| {
provider: "APPLE";
transactionId: string;
appAccountToken?: string;
}
| {
provider: "GOOGLE";
purchaseToken: string;
packageName?: string;
productId?: string;
};
export type ProjectionResult = {
subscriptionId: string;
billingAccountId: string;
status: string;
entitlementActive: boolean;
requiresAcknowledgement: boolean;
};
19. API 実装サンプル
19-1. ingest DTO 例
import { IsIn, IsNotEmpty, IsOptional, IsString, IsUUID } from "class-validator";
export class AppleIngestRequest {
@IsIn(["purchase", "restore"])
action!: "purchase" | "restore";
@IsString()
@IsNotEmpty()
transactionId!: string;
@IsOptional()
@IsUUID()
appAccountToken?: string;
}
export class GoogleIngestRequest {
@IsIn(["purchase", "restore"])
action!: "purchase" | "restore";
@IsString()
@IsNotEmpty()
purchaseToken!: string;
@IsOptional()
@IsString()
@IsNotEmpty()
packageName?: string;
@IsOptional()
@IsString()
@IsNotEmpty()
productId?: string;
}
Google ingest API の最小必須は
purchaseTokenです。
packageName/productIdは、追加検証や監査ログのために受け取る任意項目として扱うと、Part 1 の設計方針と整合します。
19-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(@Body() request: AppleIngestRequest) {
return this.billingService.ingestApplePurchase(request);
}
}
@Controller("billing/google")
export class GoogleBillingIngestController {
constructor(private readonly billingService: BillingProjectionService) {}
@Post("ingest")
async ingest(@Body() request: GoogleIngestRequest) {
return this.billingService.ingestGooglePurchase(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,
};
}
}
19-3. service の入口例
export class BillingProjectionService {
constructor(
private readonly appleProjectionService: AppleProjectionService,
private readonly googleProjectionService: GoogleProjectionService,
) {}
async ingestApplePurchase(request: AppleIngestRequest) {
return this.appleProjectionService.ingest(request);
}
async ingestGooglePurchase(request: GoogleIngestRequest) {
return this.googleProjectionService.ingest(request);
}
}
19-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. Projection / 状態反映ロジックの実装例
20-1. 考え方
最も重要なのは、ストア API の再照会結果を入力として、内部状態を pure function に近い形で決めることです。
Controller や repository の中で状態分岐をばらまくと、テストしにくくなります。
20-2. projection 関数の例
export type StoreProjectionInput = {
provider: "APPLE" | "GOOGLE";
currentStatus: string | null;
storeState: "PENDING" | "ACTIVE" | "CANCELED" | "EXPIRED" | "REVOKED";
inTrial: boolean;
inGracePeriod: boolean;
onHold: boolean;
};
export type ProjectSubscriptionStateOutput = {
nextStatus:
| "PENDING"
| "ACTIVE"
| "TRIALING"
| "IN_GRACE_PERIOD"
| "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";
default:
// ACTIVE / IN_GRACE_PERIOD / ON_HOLD / PAUSED 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 };
}
// Beyond this point, storeState is ACTIVE
if (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を判定してください。
20-3. DB 更新の流れ
- ストア API から現在状態を取得する
projectSubscriptionState(...)で内部状態を決めるSubscriptionを upsert するEntitlementを更新するBillingTransactionを記録する- 必要なら Google acknowledgement を実行する
20-4. 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: { id: input.subscriptionId },
data: { status: input.nextStatus },
});
const subscription = await tx.subscription.findUniqueOrThrow({
where: { id: input.subscriptionId },
select: { billingAccountId: true, planId: true },
});
await tx.entitlement.upsert({
where: {
billingAccountId_planId: {
billingAccountId: subscription.billingAccountId,
planId: subscription.planId,
},
},
update: { isActive: input.entitlementActive },
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,
},
});
});
}
}
20-5. transactionKey の決め方
BillingTransaction.transactionKey は、同じ課金イベントを二重登録しないためのキーです。
Apple と Google で形式は異なって構いません。
例:
- Apple:
apple:<transactionId>:<eventType> - Google:
google:<purchaseToken>:<eventType>:<expiryTime>
21. Webhook / RTDN 処理詳細
21-1. 受信時の基本原則
- 入口はすぐに 2xx を返せるようにする
- 検証と本処理を分離する
- 生 payload を保存してから再処理できるようにする
- 受信順に依存しない
- 同じイベントの再送を前提にする
21-2. Apple 通知の基本手順
signedPayloadを受け取る- JWS として verify and decode する
notificationUUIDを冪等キーとして保存するsignedTransactionInfo/signedRenewalInfoがあれば decode する- 受信イベントを非同期ワーカーへ引き渡す
- ワーカー側で必要に応じて App Store Server API を再照会する
21-3. Google RTDN の基本手順
Google RTDN は、Pub/Sub の push / pull のどちらでも受信可能です。
本書では、初回リリースで導入しやすい push subscription を前提に説明します。
21-3-1. push subscription を使う場合の流れ
- Pub/Sub push を受け取る
Authorizationヘッダの JWT を検証し、想定した audience / service account からの push であることを確認するmessageIdまたは自前の業務キーで冪等化する- payload から
purchaseTokenを取り出す - 非同期ワーカーで
purchases.subscriptionsv2.getを呼ぶ - 返ってきた状態を内部状態へ投影する
本番運用では、Pub/Sub の JWT 検証を基本としつつ、必要に応じて共有トークン付き endpoint を追加して多層防御にしても構いません。
21-3-2. pull subscription を使う場合の流れ
- 常駐 worker / subscriber が Pub/Sub からメッセージを取得する
messageIdまたは自前の業務キーで冪等化する- payload から
purchaseTokenを取り出す purchases.subscriptionsv2.getを呼ぶ- 返ってきた状態を内部状態へ投影する
- 正常処理後に Pub/Sub へ ack する
pull を採る場合も、通知だけで状態を確定しない 点は同じです。
違いは、受信口が HTTPS endpoint ではなく subscriber worker になることと、JWT 検証の代わりに Pub/Sub への接続権限と worker 運用 が重要になることです。
21-4. ローカル確認時のモック / バイパス方針
ローカル確認では、署名検証・認証検証 と 投影ロジックの確認 を分けて考えると実装しやすくなります。
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 認証の検証 と 内部の冪等・投影ロジック を切り分けて確認できます。
21-5. event 保存モデル
InboundWebhookEvent に最低限入れておくとよい項目は次です。
providerproviderEventIdeventTypeverifiedrawPayloadprocessedAtprocessingError
21-6. 冪等ルール
- Apple は
notificationUUIDを起点にする - Google は Pub/Sub
messageId単体でなく、purchaseTokenと組み合わせた業務キーも検討する - 本処理は、同じイベントを何回流しても最終状態が壊れないようにする
22. Google acknowledgement 詳細
22-1. acknowledgement の位置づけ
Google では、PURCHASED で entitlement を付与した purchase が未 acknowledgement の場合に acknowledgement を実行します。
renewal は不要です。
一方で、初回購入・plan change・re-signup のように new purchase token を伴う purchase は acknowledgement 対象です。
prepaid を扱う場合は top-up も対象です。
22-2. いつ実行するか
ルールは次の 3 行で整理できます。
PENDINGの間は execute しないPURCHASEDになってから実行するEntitlementを付与した後に実行する
22-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
);
}
22-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 を主経路として扱います。
22-5. 救済ジョブ
acknowledgement は、同期処理だけに頼らず救済ジョブを必ず持つのが安全です。
監視対象は少なくとも次です。
acknowledgementStatus = PENDINGpurchaseState = PURCHASEDupdatedAtが一定時間以上前Entitlementは active
23. Restore / Account Linking 詳細
23-1. restore の基本原則
restore は「過去購入をもう一度作る」処理ではなく、既存のストア契約を現在のアプリユーザーへ正しく結び直す処理です。
そのため、通常購入と同じくストア再照会を行い、既存契約があればそれを投影し直します。
23-2. 紐付け優先順位
Apple
appAccountTokenが一致するoriginalTransactionIdが既存Subscriptionと一致する- 既存契約が見つからなければ、23-3 の条件をすべて満たし、23-4 の停止条件に触れない場合に限って、検証済みのストア契約を内部
Subscription/Entitlementの projection として新規作成することを検討する
obfuscatedExternalAccountId/obfuscatedExternalProfileIdが一致するpurchaseTokenが既存Subscriptionと一致するlinkedPurchaseTokenの連鎖から辿れる- 期限切れ後の out-of-app 再購読なら
outOfAppPurchaseContextを補助手掛かりにする
23-3. 自動紐付けしてよいケース
- 1 つの
BillingAccountにだけ安全に特定できる - 既存契約の所有者が同一ユーザーと判断できる
- 既存の active entitlement と衝突しない
23-4. 止めるべきケース
- 別
BillingAccountに active な購読がすでにある - 複数候補に一致する
- store の識別子が不足していて安全に 1 件へ絞れない
23-5. 手動確認へ送るルール
restore 失敗をすべて 404 にせず、次の 2 系統に分けると運用しやすくなります。
- 本当に見つからない
- 見つかったが自動移管できない
後者は CS や運用者が確認できるよう、監査ログと内部メモの起票導線を持つと安全です。