ドキュメント
メニューを開く

テーマ

このページの内容

ドキュメント

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. テーブル採用優先度一覧

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

16-6. Plan

Plan は、自社サービス上の課金プランです。
ストアの商品 ID はストア都合で増減しますが、アプリの権限や価格帯の概念は自社都合で安定して参照したいことが多いです。そのため、自社が意味づけしたプラン概念をまず Plan として固定します。

採用優先度

MUST

このテーブルの責務

  • 自社サービスにおける「何を売っているか」を、ストア商品 ID と切り離して定義する
  • entitlement、権限制御、将来の価格改定や商品差し替えの基準点を提供する
  • Apple / Google の両方で同等な商品を、同じ内部プランとして扱えるようにする

削った場合の主なリスク

monthly_basic_iospremium_yearly_google のようなストア識別子がそのまま業務ルールへ漏れます。すると、ストアごとの命名差分、商品差し替え、base plan 追加のたびにアプリ全体の条件分岐を書き換えることになります。さらに、同じ月額プランでもストアごとに別物として扱われやすくなり、権限付与や分析軸が不安定になります。

まず持つカラム

カラム役割
id内部主キー
code業務上の安定キー。例: basic_monthly
name管理画面や社内で使う表示名
intervalUnitMONTH / YEAR などの課金周期の概念
isActive新規販売・新規付与に使ってよいか
createdAt / updatedAt監査と変更追跡

16-7. StoreProductMapping

StoreProductMapping は、Apple / Google の商品定義を Plan に結び付けるテーブルです。
Google では productId に加えて basePlanIdofferId があり、Apple と Google では識別子の構造が異なります。この差をアプリ全体に漏らさずに吸収するための境界がこのテーブルです。

採用優先度

MUST

このテーブルの責務

  • ストア商品を自社 Plan へ正しく解決する境界を提供する
  • Google の productId / basePlanId / offerId と Apple の product ID の差分を吸収する
  • projection や API 実装から商品解決ロジックを切り離し、商品追加時の変更点を局所化する

削った場合の主なリスク

API 層や projection 層で「if productId === ...」の分岐が散らばり、商品追加や offer 追加のたびに複数箇所を修正する必要があります。価格変更やプロモーションを後から足すときにも、ここがない構成は崩れやすいです。特に Google では offer の解釈を誤って、無料トライアル付き購入や通常購入を誤った Plan へ投影する危険があります。

まず持つカラム

カラム役割
id内部主キー
providerAPPLE / GOOGLE
planId対応する自社 Plan
productIdApple の product ID、Google の subscription product ID
basePlanIdGoogle の base plan。Apple では通常 NULL
offerIdGoogle の offer。Apple では通常 NULL
basePlanIdNormalizedunique 制約用の正規化列。NULL は空文字へ揃える
offerIdNormalizedunique 制約用の正規化列。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内部主キー
providerAPPLE / GOOGLE
billingAccountId課金主体への参照
planId現在紐付いている自社 Plan
statusACTIVE / TRIALING / IN_GRACE_PERIOD などの契約状態
acknowledgementStatusGoogle 向けの ack 状態。Apple では通常 NOT_REQUIRED
currentPeriodStartAt / currentPeriodEndAt現在契約期間
gracePeriodEndsAt猶予期間の終了見込み
expiresAt利用停止の基準時刻として使う
appleOriginalTransactionIdApple 契約を一意に辿る主キー候補
appleLatestTransactionId最新 transaction の把握や再照会起点に使う
currentGooglePurchaseToken現在有効な Google purchase token への参照用キャッシュ
createdAt / updatedAt監査と変更追跡

補助カラムとして持つとよいもの

カラム役割
canceledAt解約確定日時や自動更新オフ検知時の記録に使う
appleAppAccountTokenApple 側へ渡した自社アカウント識別子の保持
googleObfuscatedExternalAccountIdGoogle から返る難読化アカウント ID
googleObfuscatedExternalProfileIdGoogle から返る難読化プロフィール ID
lineItemsLatestSuccessfulOrderIdGoogle の 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
purchaseTokenGoogle が返す 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 を系列キーにし、必要に応じて appleLatestTransactionIdBillingTransaction.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現在使わせてよいか
reasonACTIVE / IN_GRACE_PERIOD / EXPIRED など、利用可否の説明用理由
startsAt / endsAt利用期間
sourceSubscriptionIdどの Subscription から導いたか
createdAt / updatedAt監査と変更追跡

16-13. BillingTransaction

BillingTransaction は、課金イベント履歴です。
Subscription が現在状態、BillingTransaction が履歴という役割分担にします。ここには、購入、更新、回復、返金、取消、商品変更などを時系列で失わずに保存します。

採用優先度

MUST

このテーブルの責務

  • 購入、更新、回復、返金、取消、商品変更などの課金イベントを時系列で保存する
  • SubscriptionEntitlement の現在値が、どのイベントを根拠に導かれたかを説明できるようにする
  • 再投影、調査、CS 対応、監査のための事実ログを残す

削った場合の主なリスク

「なぜ active から inactive になったのか」「二重反映したのか」「どの orderId / transactionId が根拠だったのか」を後から説明できません。運用事故や問い合わせ調査では、現在値より履歴のほうが重要になることが多いです。現在値だけ残る構成になるため、決済失敗、回復、返金、トライアル開始の根拠を遡って確認しづらくなります。

まず持つカラム

カラム役割
id内部主キー
subscriptionId対応する Subscription
providerAPPLE / GOOGLE
environmentPRODUCTION / SANDBOX など。環境切り分けに使う
eventTypePURCHASED / RENEWED / REFUNDED など
transactionKey冪等記録用キー。重複保存を防ぐ
occurredAtストア上でそのイベントが起きた時刻
rawSnapshotストア応答の原文スナップショット
createdAt保存時刻

補助カラムとして持つとよいもの

カラム役割
storeTransactionIdApple transactionId など、ストア側の主要識別子
orderIdGoogle order ID
purchaseTokenGoogle token のスナップショット
appleOriginalTransactionIdApple 契約系列を履歴側からも辿れるようにする
googleLinkedPurchaseTokenGoogle の旧 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内部主キー
providerAPPLE / GOOGLE
providerEventIdプロバイダ側イベント ID。冪等化の主キー
providerBusinessKeyApple の originalTransactionId、Google の purchaseToken など、業務的な追跡キー
environmentPRODUCTION / SANDBOX など
eventType通知種別
verified署名検証に成功したか
processResultRECEIVED / PROCESSED / FAILED などの処理結果
rawPayload生 payload
firstReceivedAt最初に受信した時刻
processedAt投影処理まで完了した時刻

補助カラムとして持つとよいもの

カラム役割
subscriptionId既知なら紐付ける Subscription
processingError失敗理由の詳細

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

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

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

16-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 テーブルです。

  1. Plan
  2. StoreProductMapping
  3. BillingAccount
  4. Subscription
  5. GooglePurchaseToken
  6. Entitlement
  7. BillingTransaction
  8. InboundWebhookEvent

本ドキュメントは 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 は受信イベントの監査と再処理の起点であり、providerBusinessKeyprocessResult を持たせる
  • Google の主要 order ID は lineItems.latest_successful_order_id ベースで扱う

17-4. Prisma だけでは表現しづらい制約

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

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

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

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

17-5. migration 時の注意

  • 既存データがある環境で unique index を追加する前に重複を洗い出す
  • rawSnapshotrawPayload は JSONB を想定し、インデックスを貼りすぎない
  • basePlanIdNormalized / offerIdNormalized は application service か DB trigger で NULL と同期する
  • Subscription.statusEntitlement.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/

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

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

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

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

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

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

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 };
}

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

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

20-3. DB 更新の流れ

  1. ストア API から現在状態を取得する
  2. projectSubscriptionState(...) で内部状態を決める
  3. Subscription を upsert する
  4. Entitlement を更新する
  5. BillingTransaction を記録する
  6. 必要なら 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 通知の基本手順

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

21-3. Google RTDN の基本手順

Google RTDN は、Pub/Sub の push / pull のどちらでも受信可能です。
本書では、初回リリースで導入しやすい push subscription を前提に説明します。

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

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

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

21-3-2. pull subscription を使う場合の流れ

  1. 常駐 worker / subscriber が Pub/Sub からメッセージを取得する
  2. messageId または自前の業務キーで冪等化する
  3. payload から purchaseToken を取り出す
  4. purchases.subscriptionsv2.get を呼ぶ
  5. 返ってきた状態を内部状態へ投影する
  6. 正常処理後に 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 に最低限入れておくとよい項目は次です。

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

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

22-5. 救済ジョブ

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

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

23. Restore / Account Linking 詳細

23-1. restore の基本原則

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

23-2. 紐付け優先順位

Apple

  1. appAccountToken が一致する
  2. originalTransactionId が既存 Subscription と一致する
  3. 既存契約が見つからなければ、23-3 の条件をすべて満たし、23-4 の停止条件に触れない場合に限って、検証済みのストア契約を内部 Subscription / Entitlement の projection として新規作成することを検討する

Google

  1. obfuscatedExternalAccountId / obfuscatedExternalProfileId が一致する
  2. purchaseToken が既存 Subscription と一致する
  3. linkedPurchaseToken の連鎖から辿れる
  4. 期限切れ後の out-of-app 再購読なら outOfAppPurchaseContext を補助手掛かりにする

23-3. 自動紐付けしてよいケース

  • 1 つの BillingAccount にだけ安全に特定できる
  • 既存契約の所有者が同一ユーザーと判断できる
  • 既存の active entitlement と衝突しない

23-4. 止めるべきケース

  • BillingAccount に active な購読がすでにある
  • 複数候補に一致する
  • store の識別子が不足していて安全に 1 件へ絞れない

23-5. 手動確認へ送るルール

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

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

後者は CS や運用者が確認できるよう、監査ログと内部メモの起票導線を持つと安全です。