ある朝、アプリのサポート窓口に「年額プランを買ったのに、有料機能のロックが外れません」という連絡が届きました。レシートのスクリーンショットを見る限り、購入は確かに成立しています。App Store Connect の売上にも計上されています。それでも、その方の端末ではアプリが「未購入」と判断していました。
StoreKit 2 のサンプルコードはどれも美しく、try await product.purchase() の数行で課金が終わるように見えます。けれど本番でほんとうに難しいのは購入の瞬間ではなく、購入した後に「この人は今、権利を持っているのか」を端末とサーバの両方で食い違いなく答え続けること でした。ここでは、その食い違い——権利状態のドリフト——をどう減らしたかを、実際に踏んだ落とし穴の順に記録します。
「購入」と「権利」を別物として扱う
最初のつまずきは、設計の言葉づかいにありました。purchasedProductIDs のような集合を持ち、購入時にそこへ製品 ID を足す——多くの入門実装がこの形です。けれど購入は一度きりのイベントで、権利(entitlement)は時間とともに変わる状態 です。自動更新サブスクは毎月静かに更新され、失効し、返金され、Billing Retry で宙づりになります。購入イベントだけを集めても、今この瞬間の権利は表せません。
そこで、アプリ内部の真実源を「購入履歴」ではなく「現在の権利」に置き直しました。型としても分けています。
// 「今、何を使えるか」だけを表す。購入の履歴ではない
struct Entitlement : Equatable {
enum State : Equatable {
case active (until: Date ? ) // 有効(until=nil は買い切り)
case inGracePeriod (until: Date) // 支払い再試行中。猶予で使わせる
case expired
}
let productID: String
let state: State
}
この inGracePeriod を最初から型に持たせたのが、後で効きました。返金や決済失敗を「即座に無効」とだけ扱うと、Apple 側がまだ猶予を与えている利用者を、アプリが先回りして締め出してしまうからです。
currentEntitlements を「唯一の真実」にしない
StoreKit 2 で現在の権利を引くと言えば Transaction.currentEntitlements です。私も最初はこれだけを信じていました。けれどサポート事例を追っていくと、currentEntitlements がそのデバイスのローカルキャッシュを反映するに過ぎない 場面が見えてきました。別の端末で更新された、ネットワークが不調だった、署名検証で弾いて握りつぶした——理由はさまざまですが、結果として「Apple のサーバは有効と知っているのに、この端末の currentEntitlements には出てこない」状態が起こります。
そこで三つの情報源を、信頼度の順に重ねることにしました。
情報源 答えるもの 弱点
Transaction.currentEntitlements この端末が把握する有効トランザクション ローカルキャッシュ依存・取りこぼしあり
Product.SubscriptionInfo.Status 更新状態・猶予・失効理由 製品が手元に load 済みである前提
App Store Server Notifications V2 サーバ側の確定事実(返金・失効・更新) 自前のエンドポイントが要る
個人開発でサーバを持ちたくない気持ちは痛いほど分かります。RevenueCat のような外部サービスに in-app purchase の状態管理ごと預ける選択肢もありますし、課金率や継続率の計測まで含めて任せたい場合はお勧めします。それでも私は、依存を増やしたくなかったので最小構成を選びました。「課金したのに使えない」の再発を本当に止められたのは、サーバ側の確定情報を一つ持ってからでした 。Apple からの通知を受けて最後の確定状態を記録しておくだけで、端末の言い分とサーバの事実を突き合わせられます。この一手間は、LTV を毀損しないためにも入れておくことを推奨します。
起動のたびに状態を整流する(reconcile)
購入の瞬間にだけ権利を更新する設計は、更新が起きる「アプリを開いていない時間」を取りこぼします。私は購入時の更新をやめたわけではありませんが、それとは別に、アプリのフォアグラウンド復帰ごとに権利を一から組み直す整流処理 を入れました。
@MainActor
final class EntitlementStore : ObservableObject {
@Published private ( set ) var entitlements: [ String : Entitlement] = [ : ]
private var updates: Task< Void , Never > ?
func start () {
// 起動時とフォアグラウンド復帰時に必ず呼ぶ
Task { await reconcile () }
updates = listenForUpdates ()
}
// 現在の権利を端末情報から「組み直す」。差分更新ではなく作り直す
func reconcile () async {
var rebuilt: [ String : Entitlement] = [ : ]
for await result in Transaction.currentEntitlements {
guard case . verified ( let txn) = result else { continue } // 未検証は採用しない
// 失効済み(返金・アップグレードで置換)は飛ばす
if let revoked = txn.revocationDate, revoked <= Date () { continue }
rebuilt[txn.productID] = Entitlement (
productID : txn.productID,
state : await resolveState ( for : txn)
)
}
entitlements = rebuilt // まるごと置換。古い権利が残らない
}
// 更新・失効の詳細は subscription.status から補う
private func resolveState ( for txn: Transaction) async -> Entitlement.State {
guard txn.productType == .autoRenewable,
let product = try? await Product. products ( for : [txn.productID]). first ,
let status = try? await product.subscription ? .status. first ,
case . verified ( let renewal) = status.renewalInfo else {
return . active ( until : txn.expirationDate)
}
switch status.state {
case .subscribed, .inBillingRetryPeriod :
// 支払い再試行中でも、まだ猶予内なら使わせる
if let untilStr = renewal.gracePeriodExpirationDate {
return . inGracePeriod ( until : untilStr)
}
return . active ( until : txn.expirationDate)
case .inGracePeriod :
return . inGracePeriod ( until : txn.expirationDate ?? Date ())
default:
return .expired
}
}
}
要点は二つあります。差分で足し引きせず、毎回 rebuilt をまるごと置換 すること。そして、subscription.status を見て支払い再試行中の利用者を即座に締め出さない こと。この二つを入れてから、「更新したはずなのに無料に戻った」という問い合わせがほぼ消えました。
Transaction.updates を取りこぼす三つの経路
リアルタイム監視の Transaction.updates は、外部要因(家族共有の購入、別端末での更新、返金)を受け取る生命線です。ところがこれを静かに取りこぼす 経路が三つありました。
ひとつ目は、リスナーの寿命です。Task.detached でリスナーを起こしておきながら、それを保持する所有者が先に解放されると、ストリームごと消えます。私自身、一度ビュー単位でリスナーを起こしてしまい、画面を閉じた利用者だけ更新が届かない、という再現しにくいバグを作りました。リスナーはアプリの寿命と同じ場所——App 直下の単一オブジェクトに置くべきでした。
ふたつ目は、アプリが起動していない間の更新です。Transaction.updates は起動中のイベントを流すもので、閉じている間に起きた更新は、次回起動時に流れ直すとは限りません。前章の reconcile() を起動ごとに必ず走らせるのは、この穴を塞ぐためです。
みっつ目が、finish() のし忘れです。検証して権利に反映したトランザクションは await transaction.finish() で完了させないと、StoreKit は「まだ処理されていない」と判断し、起動のたびに同じトランザクションを updates へ再投入します。finish を条件分岐の奥に置いてしまい、ある経路だけ呼ばれない——これも一度やりました。検証に成功したら、権利反映と finish はワンセットで、早期 return より前に書く のが安全です。
private func listenForUpdates () -> Task< Void , Never > {
Task. detached { [ weak self ] in
for await update in Transaction.updates {
guard case . verified ( let txn) = update else { continue }
await self ? . reconcile () // まず状態を組み直し
await txn. finish () // 必ず finish(早期 return の前に)
}
}
}
復元ボタンと AppStore.sync() の距離感
「購入を復元」ボタンに AppStore.sync() を直結している実装をよく見かけます(私も昔そうでした)。けれど AppStore.sync() は App Store アカウントとの強制再同期 で、サインインダイアログを伴うことがあります。多くの場合、利用者が本当に求めているのは再ログインではなく「今ある権利をもう一度確認して」です。
そこで復元の導線を二段にしました。ボタンを押したらまず reconcile() を走らせ、それでも権利が見つからないときにだけ AppStore.sync() を提案します。ダイアログを出す回数が減り、「復元を押したのに何も起きない/勝手にログインを求められた」という不満の両方が和らぎました。
func restore () async -> Bool {
await reconcile ()
if ! entitlements. isEmpty { return true } // ローカルで足りた
try? await AppStore. sync () // ここで初めて強制同期
await reconcile ()
return ! entitlements. isEmpty
}
返金・失効を「即・無料化」にしない
revocationDate が入ったトランザクションは権利から外す——これは正しいのですが、外し方には配慮が要ります。返金が確定した瞬間にアプリ内の保存物(ダウンロード済みコンテンツなど)まで即時に消すと、家族共有の取り消しや誤操作の返金で、悪意のない利用者に強い不快感を与えます。本番運用では、この種の事故をどう回避するかが評価に直結します。私は「権利フラグは即座に下げるが、ローカルの成果物は次の自然な区切りまで残す」という緩衝を入れました。締め出すにしても、ドアを静かに閉める方を選んでいます。
Billing Retry(支払い再試行)も同じ思想です。inBillingRetryPeriod は「失効」ではなく「Apple がまだ回収を試みている」状態で、猶予期間が設定されていれば利用を続けさせるよう Apple も推奨します。前述の inGracePeriod 型は、このためにあります。
サンドボックスと本番の挙動差で消耗しないために
最後に、検証段階で一番時間を溶かしたのがサンドボックスでした。サンドボックスのサブスクは更新周期が極端に短く(月額が数分で更新・失効する)、本番では起こらない速さで updates が飛んできます。これ自体は仕様ですが、サンドボックス特有の速さを本番運用のバグと取り違えない ことが大切でした。誤検知を回避するため、私は環境を明示してログに出すようにし、切り分けを楽にしました。
// Transaction の environment で実行環境を判別する(推測しない)
extension Transaction {
var isSandbox: Bool { environment == .sandbox }
}
Antigravity に整流ロジックの叩き台を書かせるときも、私は「サンドボックスでの高速更新を前提にテストケースを足してほしい」と最初に伝えるようにしています。生成されたコードをそのまま信じるのではなく、自分が踏んだ穴をテストとして固定する ところまでをひと続きの作業にすると、同じ問い合わせが二度と来なくなります。
権利状態のドリフトは、派手なクラッシュではなく「静かなすれ違い」として現れます。だからこそ、購入の一点だけを正しく書いても足りず、起動のたびに状態を組み直し、サーバの確定事実と突き合わせ、猶予を尊重する——この地味な往復が効きました。同じように「課金したのに使えない」の問い合わせに悩んでいる方の、切り分けの手がかりになれば幸いです。