axiom-in-app-purchases について
iOS 開発者向けに、StoreKit 2 によるアプリ内課金の実装とテストを案内するリファレンススキルです。.storekit の設定と購入フローの検証を本番コードより先に行う手順を重視します。
主な機能
- 01StoreKit 2 の購入パターン
- 02テスト優先の .storekit ワークフロー
- 03消耗型・非消耗型・サブスクリプション
- 04トランザクション検証と権利管理
- 05購入復元と同期のガイド
活用シーン
- 01消耗型やプレミアム機能の購入フロー構築
- 02自動更新サブスクリプションの実装と追跡
- 03購入や復元のシナリオをローカルでテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-in-app-purchasesインストール後、Claude Code で/axiom-in-app-purchasesを入力して読み込まれたか確認します。
詳細
StoreKit 2 アプリ内課金の実装
目的:堅牢でテスト可能なアプリ内課金の実装をガイドします StoreKit のバージョン:StoreKit 2 iOS のバージョン:iOS 15 以降(最新機能には iOS 18.4 以降) Xcode:Xcode 13 以降(Xcode 16 以降を推奨) コンテキスト:WWDC 2025-241、2025-249、2023-10013、2021-10114
このスキルを使う場面
✅ 次の場合にこのスキルを使ってください:
- アプリ内課金機能を新規実装または既存の機能に追加する
- 消費型商品(コイン、ヒント、ブースト)を追加する
- 非消費型商品(プレミアム機能、レベルパック)を追加する
- 自動更新サブスクリプション(月額/年額プラン)を追加する
- 購入の失敗、取引の欠落、復元に関する問題をデバッグする
- StoreKit テスト構成を設定する
- サブスクリプションのステータス追跡を実装する
- プロモーションオファーや初回オファーを追加する
- サーバー側でレシートを検証する
- ファミリー共有をサポートする
❌ 次の場合はこのスキルを使わないでください:
- StoreKit 1(旧 API)—このスキルは StoreKit 2 に重点を置いています
- App Store Connect の商品設定(別のドキュメントを参照)
- 価格戦略やビジネスモデルの決定
⚠️ .storekit 構成を作成する前にコードを書いてしまいましたか?
.storekit 構成を作成する前に購入コードを書いた場合、次の 3 つの選択肢があります。
選択肢 A:削除して最初からやり直す(強く推奨)
IAP のコードをすべて削除し、以下のテストファーストのワークフローに従ってください。正しい習慣が身につき、.storekit を先に作成する開発のメリットを最大限に得られます。
これが最善である理由:
- ワークフローを理解しているか確認できます
- 見落としていた可能性のある商品 ID の問題を検出できます
- 今後の IAP 実装に向けた手順が身につきます
- 経験豊富な開発者であれば、わずか 15~30 分で完了します
選択肢 B:今すぐ .storekit 構成を作成する(注意すれば許容可能)
既存の商品 ID を使って、今すぐ .storekit ファイルを作成してください。すべてがローカルで動作することをテストします。まずサンドボックスでテストしたことを PR に記載してください。
トレードオフ:
- ✅ 動作するコードを維持できます
- ✅ ローカルでのテスト機能を追加できます
- ❌ 商品 ID を検証できるメリットを逃します
- ❌ テストを後回しにするパターンを強化してしまいます
- ❌ コードレビューでより慎重な確認が必要になります
この方法を選ぶ場合:ただちに .storekit を作成し、ローカルで検証してから、その方法を説明するメモをコミットしてください。
選択肢 C:.storekit を完全に省略する(非推奨)
.storekit 構成なしでコミットし、サンドボックスでのみテストします。
問題がある理由:
- チームメイトがローカルで購入をテストできません
- 実行時まで商品 ID を検証できません
- App Store Connect が必要なため、反復作業が難しくなります
- 商品構成のドキュメントが不足します
結論:可能であれば選択肢 A、現実的な方法を取るなら選択肢 B を選んでください。選択肢 C は絶対に選ばないでください。
基本方針:テストファーストのワークフロー
ベストプラクティス:本番用の購入コードを書く前に、StoreKit 構成を作成してテストしてください。
.storekit を先に作成することが重要な理由
推奨されるワークフローでは、購入コードを書く前に .storekit 構成を作成します。これは恣意的な手順ではなく、具体的なメリットがあります。
商品 ID をすぐに検証できます:
- 実行時ではなく、Xcode でタイプミスを検出できます
- 商品構成をプロジェクト内で確認できます
- テスト時に App Store Connect に依存する必要がありません
反復作業を高速化できます:
- シミュレーターですぐに購入をテストできます
- 開発中にネットワークリクエストが発生しません
- テスト用にサブスクリプションの更新を高速化できます
チームにとってのメリット:
- 誰でもローカルで購入フローをテストできます
- 商品カタログをコード内に記録できます
- コードレビューで購入テストも確認できます
よくある反論への回答:
❓ 「すでにサンドボックスでテストしました」 — サンドボックスでのテストは有用ですが、その段階は後になります。.storekit を使ったローカルテストはより高速で、真の TDD を実現できます。
❓ 「コードは動作します」 — コードが動作するのは素晴らしいことです。.storekit を追加すれば、チームメイトが検証し、保守しやすくなります。
❓ 「以前にもやったことがあります」 — 経験は貴重です。.storekit を先に作成するワークフローで、経験豊富な開発者はさらに生産性を高められます。
❓ 「時間に余裕がありません」 — .storekit の作成には 10~15 分かかります。反復作業で節約できる時間ですぐに元が取れます。
推奨されるワークフロー
StoreKit Config → Local Testing → Production Code → Unit Tests → Sandbox Testing
↓ ↓ ↓ ↓ ↓
.storekit Test purchases StoreManager Mock store Integration test
この順序が役立つ理由:
- StoreKit Config を最初に作成:App Store Connect に依存せずに製品を定義できます
- ローカルでテスト:製品 ID と購入フローをすぐに検証できます
- 本番コードを作成:検証済みの製品構成に基づいて実装できます
- ユニットテスト:モックしたストアの応答でビジネスロジックを検証できます
- サンドボックスでテスト:App Store 環境で最終検証を行います
このワークフローに従うメリット:
- コードを書く前に製品 ID を検証できます
- 開発の反復を速められます
- チームでの共同作業が容易になります
- テストカバレッジが向上します
必須チェックリスト
IAP の実装を完了としてマークする前に、すべての項目を確認する必要があります。
フェーズ 1:テスト基盤
- すべての製品を含む
.storekit構成ファイルを作成した - 各製品タイプが StoreKit プレビューで正しく表示されることを確認した
- Xcode で各製品の購入フローが成功することをテストした
- 購入失敗のシナリオ(残高不足、キャンセル)をテストした
- 購入の復元フローをテストした
- サブスクリプションの場合:更新、有効期限切れ、アップグレード/ダウングレードをテストした
フェーズ 2:アーキテクチャ
- StoreManager クラスが一元化されている(信頼できる唯一の情報源)
- StoreManager が ObservableObject(SwiftUI)である、または NotificationCenter を使用している
- トランザクションオブザーバーが
Transaction.updatesを通じて更新を監視している - すべてのトランザクション検証で
VerificationResultを使用している - エンタイトルメント付与後にすべてのトランザクションで
.finish()を呼び出している - アプリ起動時、またはストア表示前に製品を読み込んでいる
フェーズ 3:購入フロー
- UI コンテキストを指定した新しい
purchase(confirmIn:options:)を使用している(iOS 18.2+) - 購入時にすべての
PurchaseResultケース(success、userCancelled、pending)を処理している - エンタイトルメントを付与する前に購入トランザクションの署名を検証している
- サポート対応のため、購入トランザクションのレシート/識別子を保存している
- すべての購入で appAccountToken を設定している(サーバーバックエンドを使用する場合)
フェーズ 4:サブスクリプション管理(該当する場合)
- サブスクリプションのステータスを
Product.SubscriptionInfo.Statusで追跡している - 現在のエンタイトルメントを
Transaction.currentEntitlements(for:)で確認している - 有効期限、更新日、オファーのステータスに関する更新情報にアクセスしている
- サブスクリプションのビューで ProductView または SubscriptionStoreView を使用している
- 期限切れのサブスクリプション向けにウィンバックオファーを実装している
- 猶予期間と請求の再試行状態を処理している
フェーズ 5:復元と同期
- 購入の復元を実装している(App Store Review で必須)
- 復元に
Transaction.currentEntitlementsまたはTransaction.allを使用している - ファミリー共有のトランザクションを識別している(対応している場合)
- サーバー同期を実装している(バックエンドを使用する場合)
- デバイス間のエンタイトルメント同期をテストした
フェーズ 6:エラー処理
- ネットワークエラーを適切に処理している(再試行、ユーザーへのメッセージ表示)
- 無効な製品 ID を検出し、ログに記録している
- 購入失敗時にユーザーにわかりやすいエラーメッセージを表示している
- トランザクションの検証失敗をログに記録し、報告している
- 返金通知を処理している(App Store Server Notifications 経由)
フェーズ 7:テストと検証
- モックした Product/Transaction を使って、ユニットテストで購入ロジックを検証している
- ユニットテストでサブスクリプションステータスの判定を検証している
- StoreKit 構成を使った統合テストに合格している
- 実際の Apple ID を使ったサンドボックステストを完了した
- 本番リリース前に TestFlight でのテストを完了した
ステップ 1:StoreKit 構成を作成する(最初に実施!)
購入コードを書く前に必ず実施してください。
構成ファイルを作成する
- Xcode → File → New → File → StoreKit Configuration File
- 名前を付けて保存:
Products.storekit(またはアプリ名) - ターゲットに追加:✅(テスト用にアプリバンドルに含めます)
製品を追加する
「+」をクリックし、各製品タイプを追加します。
消耗型
Product ID: com.yourapp.coins_100
Reference Name: 100 Coins
Price: $0.99
非消耗型
Product ID: com.yourapp.premium
Reference Name: Premium Upgrade
Price: $4.99
自動更新サブスクリプション
Product ID: com.yourapp.pro_monthly
Reference Name: Pro Monthly
Price: $9.99/month
Subscription Group ID: pro_tier
すぐにテストする
- シミュレーターでアプリを実行する
- Scheme → Edit Scheme → Run → Options
- StoreKit Configuration:
Products.storekitを選択する - 確認する:商品が読み込まれ、購入が完了し、トランザクションが表示される
ステップ 2:StoreManager アーキテクチャを実装する
必須パターン:一元化された StoreManager
すべての購入ロジックは、単一の StoreManager を経由する必要があります。 アプリ内のあちこちで Product.purchase() を呼び出さないでください。
import StoreKit
@MainActor
final class StoreManager: ObservableObject {
// Published state for UI
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedProductIDs: Set<String> = []
// Product IDs from StoreKit configuration
private let productIDs = [
"com.yourapp.coins_100",
"com.yourapp.premium",
"com.yourapp.pro_monthly"
]
private var transactionListener: Task<Void, Never>?
init() {
// Start transaction listener immediately
transactionListener = listenForTransactions()
Task {
await loadProducts()
await updatePurchasedProducts()
}
}
deinit {
transactionListener?.cancel()
}
}
@MainActor を使用する理由:UI バインディングのため、公開プロパティはメインスレッド上で更新する必要があります。
商品を読み込む(起動時)
extension StoreManager {
func loadProducts() async {
do {
// Load products from App Store
let loadedProducts = try await Product.products(for: productIDs)
// Update published property on main thread
self.products = loadedProducts
} catch {
print("Failed to load products: \(error)")
// Show error to user
}
}
}
呼び出し元:App.init() または最初のビューの .task モディファイア
トランザクションを監視する(必須)
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
// Listen for ALL transaction updates
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
// Verify transaction signature
guard let transaction = try? result.payloadValue else {
print("Transaction verification failed")
return
}
// Grant entitlement to user
await grantEntitlement(for: transaction)
// CRITICAL: Always finish transaction
await transaction.finish()
// Update purchased products
await updatePurchasedProducts()
}
}
detached を使用する理由:トランザクションリスナーはビューのライフサイクルから独立して実行されます。
ステップ 3:購入フローを実装する
UI コンテキストを使った購入(iOS 18.2+)
extension StoreManager {
func purchase(_ product: Product, confirmIn scene: UIWindowScene) async throws -> Bool {
// Perform purchase with UI context for payment sheet
let result = try await product.purchase(confirmIn: scene)
switch result {
case .success(let verificationResult):
// Verify the transaction
guard let transaction = try? verificationResult.payloadValue else {
print("Transaction verification failed")
return false
}
// Grant entitlement
await grantEntitlement(for: transaction)
// CRITICAL: Finish transaction
await transaction.finish()
// Update state
await updatePurchasedProducts()
return true
case .userCancelled:
// User tapped "Cancel" in payment sheet
return false
case .pending:
// Purchase requires action (Ask to Buy, payment issue)
// Will be delivered via Transaction.updates when approved
return false
@unknown default:
return false
}
}
}
SwiftUI での購入(環境を使用)
struct ProductRow: View {
let product: Product
@Environment(\.purchase) private var purchase
var body: some View {
Button("Buy \(product.displayPrice)") {
Task {
do {
let result = try await purchase(product)
// Handle result
} catch {
print("Purchase failed: \(error)")
}
}
}
}
}
appAccountToken を設定する(バックエンドを使用する場合)
func purchase(
_ product: Product,
confirmIn scene: UIWindowScene,
accountToken: UUID
) async throws -> Bool {
// Purchase with appAccountToken for server-side association
let result = try await product.purchase(
confirmIn: scene,
options: [
.appAccountToken(accountToken)
]
)
// ... handle result
}
使用するタイミング:バックエンドで購入をユーザーアカウントに関連付ける必要がある場合
ステップ 4:トランザクションを検証する(必須)
常に VerificationResult を使用する
func handleTransaction(_ result: VerificationResult<Transaction>) async {
switch result {
case .verified(let transaction):
// ✅ Transaction signed by App Store
await grantEntitlement(for: transaction)
await transaction.finish()
case .unverified(let transaction, let error):
// ❌ Transaction signature invalid
print("Unverified transaction: \(error)")
// DO NOT grant entitlement
// DO finish transaction to clear from queue
await transaction.finish()
}
}
検証する理由:次のようなケースで権利を付与するのを防ぎます。
- 不正なレシート
- 脱獄済みデバイスのレシート
- 中間者攻撃
トランザクションのフィールドを確認する
func grantEntitlement(for transaction: Transaction) async {
// Check transaction hasn't been revoked
guard transaction.revocationDate == nil else {
print("Transaction was refunded")
await revokeEntitlement(for: transaction.productID)
return
}
// Grant based on product type
switch transaction.productType {
case .consumable:
await addConsumable(productID: transaction.productID)
case .nonConsumable:
await unlockFeature(productID: transaction.productID)
case .autoRenewable:
await activateSubscription(productID: transaction.productID)
default:
break
}
}
ステップ 5:現在の権利を追跡する
ユーザーが所有しているものを確認する
extension StoreManager {
func updatePurchasedProducts() async {
var purchased: Set<String> = []
// Iterate through all current entitlements
for await result in Transaction.currentEntitlements {
guard let transaction = try? result.payloadValue else {
continue
}
// Only include active entitlements (not revoked)
if transaction.revocationDate == nil {
purchased.insert(transaction.productID)
}
}
self.purchasedProductIDs = purchased
}
}
特定の商品を確認する
func isEntitled(to productID: String) async -> Bool {
// Check current entitlements for specific product
for await result in Transaction.currentEntitlements(for: productID) {
if let transaction = try? result.payloadValue,
transaction.revocationDate == nil {
return true
}
}
return false
}
ステップ 6:サブスクリプション管理を実装する
サブスクリプションのステータスを追跡する
extension StoreManager {
func checkSubscriptionStatus(for groupID: String) async -> Product.SubscriptionInfo.Status? {
// Get subscription statuses for group
guard let result = try? await Product.SubscriptionInfo.status(for: groupID),
let status = result.first else {
return nil
}
return status.state
}
}
サブスクリプションの状態を処理する
func updateSubscriptionUI(for status: Product.SubscriptionInfo.Status) {
switch status.state {
case .subscribed:
// User has active subscription
showSubscribedContent()
case .expired:
// Subscription expired - show win-back offer
showResubscribeOffer()
case .inGracePeriod:
// Billing issue - show payment update prompt
showUpdatePaymentPrompt()
case .inBillingRetryPeriod:
// Apple retrying payment - maintain access
showBillingRetryMessage()
case .revoked:
// Family Sharing access removed
removeAccess()
@unknown default:
break
}
}
StoreKit ビューを使用する(iOS 17+)
struct SubscriptionView: View {
var body: some View {
SubscriptionStoreView(groupID: "pro_tier") {
// Marketing content
VStack {
Image("premium-icon")
Text("Unlock all features")
}
}
.subscriptionStoreControlStyle(.prominentPicker)
}
}
ステップ 7:購入を復元する機能を実装する(必須)
復元フロー
extension StoreManager {
func restorePurchases() async {
// Sync all transactions from App Store
try? await AppStore.sync()
// Update current entitlements
await updatePurchasedProducts()
}
}
UI ボタン
struct SettingsView: View {
@StateObject private var store = StoreManager()
var body: some View {
Button("Restore Purchases") {
Task {
await store.restorePurchases()
}
}
}
}
App Store の要件:IAP を含むアプリでは、非消費型アイテムとサブスクリプションの復元機能を提供する必要があります。
ステップ 8:返金を処理する
返金通知を監視する
extension StoreManager {
func listenForTransactions() -> Task<Void, Never> {
Task.detached { [weak self] in
for await verificationResult in Transaction.updates {
await self?.handleTransaction(verificationResult)
}
}
}
@MainActor
private func handleTransaction(_ result: VerificationResult<Transaction>) async {
guard let transaction = try? result.payloadValue else {
return
}
// Check if transaction was refunded
if let revocationDate = transaction.revocationDate {
print("Transaction refunded on \(revocationDate)")
await revokeEntitlement(for: transaction.productID)
} else {
await grantEntitlement(for: transaction)
}
await transaction.finish()
}
}
ステップ 9:ユニットテスト
Store の応答をモックする
protocol StoreProtocol {
func products(for ids: [String]) async throws -> [Product]
func purchase(_ product: Product) async throws -> PurchaseResult
}
// Production
final class StoreManager: StoreProtocol {
func products(for ids: [String]) async throws -> [Product] {
try await Product.products(for: ids)
}
}
// Testing
final class MockStore: StoreProtocol {
var mockProducts: [Product] = []
var mockPurchaseResult: PurchaseResult?
func products(for ids: [String]) async throws -> [Product] {
mockProducts
}
func purchase(_ product: Product) async throws -> PurchaseResult {
mockPurchaseResult ?? .userCancelled
}
}
購入ロジックをテストする
@Test func testSuccessfulPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: Mock successful purchase
mockStore.mockPurchaseResult = .success(.verified(mockTransaction))
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: Entitlement granted
#expect(result == true)
#expect(manager.purchasedProductIDs.contains("com.app.premium"))
}
@Test func testCancelledPurchase() async {
let mockStore = MockStore()
let manager = StoreManager(store: mockStore)
// Given: User cancels
mockStore.mockPurchaseResult = .userCancelled
// When: Purchase product
let result = await manager.purchase(mockProduct)
// Then: No entitlement granted
#expect(result == false)
#expect(manager.purchasedProductIDs.isEmpty)
}
よくあるアンチパターン(絶対にしないでください)
❌ StoreKit Configuration を設定しない
// ❌ WRONG: Writing purchase code without .storekit file
let products = try await Product.products(for: productIDs)
// Can't test this without App Store Connect setup!
✅ 正しい方法:まず .storekit ファイルを作成し、Xcode でテストしてから実装します。
❌ .storekit の設定前にコードを書く
// ❌ Less ideal: Write code, test in sandbox, add .storekit later
let products = try await Product.products(for: productIDs)
let result = try await product.purchase(confirmIn: scene)
// "I tested this in sandbox, it works! I'll add .storekit config later."
✅ 推奨:まず .storekit 設定を作成してから、コードを書きます。
この状況に当てはまる場合:上記の「.storekit 設定を作成する前に、すでにコードを書いてしまった場合」セクションを参照し、選択肢(A、B、C)をご確認ください。
.storekit を先に用意する方がよい理由:
- 商品 ID の誤字を実行時ではなく Xcode で発見できます
- ネットワークリクエストなしで、より迅速に反復できます
- チームメイトがローカルでテストできます
- コード内に商品構成を記録できます
サンドボックステストには価値があります。実際の App Store インフラストラクチャに対して検証できるためです。ただし、最初に .storekit を用意しておけば、商品 ID をローカルで検証済みなので、サンドボックステストが簡単になります。
❌ 購入呼び出しが分散している
// ❌ WRONG: Purchase calls scattered throughout app
Button("Buy") {
try await product.purchase() // In view 1
}
Button("Subscribe") {
try await subscriptionProduct.purchase() // In view 2
}
✅ 正しい方法:すべての購入を一元化された StoreManager 経由で行います。
❌ トランザクションの完了を忘れる
// ❌ WRONG: Never calling finish()
func handleTransaction(_ transaction: Transaction) {
grantEntitlement(for: transaction)
// Missing: await transaction.finish()
}
✅ 正しい方法:権利を付与した後、必ず transaction.finish() を呼び出します。
❌ トランザクションを検証しない
// ❌ WRONG: Using unverified transaction
for await transaction in Transaction.all {
grantEntitlement(for: transaction) // Unsafe!
}
✅ 正しい方法:権利を付与する前に、必ず VerificationResult を確認します。
❌ トランザクションリスナーを無視する
// ❌ WRONG: Only handling purchases in purchase() method
func purchase() {
let result = try await product.purchase()
// What about pending purchases, family sharing, restore?
}
✅ 正しい方法:すべてのトランザクションソースに対応するため、Transaction.updates を監視します。
❌ 復元機能を実装しない
// ❌ WRONG: No restore button
// App Store will REJECT your app!
✅ 正しい方法:設定画面に、ユーザーが見つけやすい「購入を復元」ボタンを用意します。
検証
IAP の実装を完了とする前に、次の項目を確認してください。
コードの検査
次の検索を実行して、要件への準拠を確認します。
# Check StoreKit configuration exists
find . -name "*.storekit"
# Check transaction.finish() is called
rg "transaction\.finish\(\)" --type swift
# Check VerificationResult usage
rg "VerificationResult" --type swift
# Check Transaction.updates listener
rg "Transaction\.updates" --type swift
# Check restore implementation
rg "AppStore\.sync|Transaction\.all" --type swift
機能テスト
- StoreKit Configuration で各商品タイプを購入できる
- 購入をキャンセルしても状態が一貫している
- 購入を復元してアクセスを再取得できる
- サブスクリプションの更新/期限切れが想定どおりに動作する
- 返金されたトランザクションによってアクセスが取り消される
- ファミリー共有のトランザクションが識別される(対応している場合)
サンドボックステスト
- 実際の Apple ID を使ったサンドボックス購入が完了する
- TestFlight のベータテスターが購入フローの動作を確認する
- サーバーサイドの検証が機能する(バックエンドを使用する場合)
App Store Connect への提出(完全なチェックリストは app-store-submission を参照)
- 各 IAP 製品のレビュー用スクリーンショットをアップロードする(購入 UI を表示 — レビュー専用で、App Store には掲載されません)
- IAP 製品をこのバージョンに追加する(初回提出の場合:App Version → In-App Purchases セクション → Select → 各製品のチェックボックスを選択)
- 購入画面に利用規約とプライバシーポリシーへのリンクを表示する(DPLA Schedule 2 で必須。
SubscriptionStoreViewが自動的に対応します) - サブスクリプションの条件を明示する:価格、期間、自動更新、解約
リソース
WWDC:2025-241、2025-249、2023-10013、2021-10114
ドキュメント:/storekit、/appstoreserverapi
スキル:axiom-storekit-ref