Step 1: 商品設計と App Store Connect での構成
実装前に、App Store Connect での商品設定を Claude Code と一緒に設計します。
Claude Code へのプロンプト例
私のアプリは写真編集アプリです。以下の課金モデルを実装したいです:
- プレミアムフィルター(消耗型、1回購入 ¥120)
- Pro月額サブスクリプション(¥480/月)
- Pro年額サブスクリプション(¥3,800/年)
StoreKit 2 のProduct ID命名規則と App Store Connect の設定項目を教えてください。
Claude Code は以下のような命名規則を提案してくれます。
Product ID の命名規則(推奨):
com.yourcompany.appname.premium_filter ← 消耗型
com.yourcompany.appname.pro_monthly ← 月額サブスクリプション
com.yourcompany.appname.pro_yearly ← 年額サブスクリプション
App Store Connect 設定チェックリスト:
- Subscription Group 名: "Pro Features"(日英で設定)
- 年額は月額の10ヶ月相当に設定(割引感を出す)
- 試用期間: 7日間無料トライアル(月額のみ推奨)
- Grade: 月額を1、年額を2に設定
これらの Product ID は Xcode の StoreKit Configuration File(.storekit ファイル)に追加します。ローカルテスト用の設定ファイルを作成しておくことで、App Store Connect に商品を実際に登録する前にシミュレーターでテストできます。
Step 2: StoreKit 2 コア実装 — PurchaseManager の構築
アプリ全体で課金状態を管理する PurchaseManager を実装します。Claude Code に以下を依頼します。
SwiftUI アプリで StoreKit 2 を使ったPurchaseManagerを作成してください。
要件:
- @Observable マクロを使用(iOS 17以降対象)
- consumable と auto-renewable subscription の両方に対応
- Transaction.updates のリスナーを起動時に開始
- isSubscribed プロパティで現在のサブスクリプション状態を管理
- エラーハンドリングを全ケース網羅
Claude Code が生成するコアの実装です。
import StoreKit
import Observation
@Observable
final class PurchaseManager {
// MARK: - 購入可能な商品一覧
private ( set ) var products: [Product] = []
// MARK: - サブスクリプション状態
private ( set ) var isSubscribed: Bool = false
// MARK: - 購入済み消耗型アイテムのID群
private ( set ) var purchasedConsumables: Set < String > = []
// MARK: - エラー状態
private ( set ) var purchaseError: String ?
// MARK: - ローディング状態
private ( set ) var isPurchasing: Bool = false
// MARK: - Product ID 定義
private let productIDs: Set < String > = [
"com.yourcompany.appname.premium_filter" ,
"com.yourcompany.appname.pro_monthly" ,
"com.yourcompany.appname.pro_yearly"
]
// Transaction監視タスク
private var updateListenerTask: Task< Void , Error > ?
init () {
// アプリ起動時にTransactionリスナーを開始
updateListenerTask = listenForTransactions ()
Task {
await loadProducts ()
await updateSubscriptionStatus ()
}
}
deinit {
updateListenerTask ? . cancel ()
}
// MARK: - 商品一覧の取得
@MainActor
func loadProducts () async {
do {
// App Store Connectから商品情報を取得
let fetchedProducts = try await Product. products ( for : productIDs)
// 価格順にソート
products = fetchedProducts. sorted { $0 .price < $1 .price }
} catch {
// 商品取得失敗(ネットワーク障害などで発生)
purchaseError = "商品情報の取得に失敗しました: \( error. localizedDescription ) "
}
}
// MARK: - 購入処理
@MainActor
func purchase ( _ product: Product) async {
isPurchasing = true
purchaseError = nil
do {
// 購入リクエスト
let result = try await product. purchase ()
switch result {
case . success ( let verificationResult) :
// レシートの検証
let transaction = try checkVerified (verificationResult)
// サブスクリプション状態の更新
await updateSubscriptionStatus ()
// 消耗型アイテムの場合は購入完了を記録
if product.type == .consumable {
purchasedConsumables. insert (product.id)
// 消耗型はfinishを忘れずに呼ぶ
await transaction. finish ()
} else {
await transaction. finish ()
}
case .userCancelled :
// ユーザーが購入をキャンセル(エラーではない)
break
case .pending :
// 保護者の承認待ち(Ask to Buy)などの状態
purchaseError = "購入の承認を待っています。保護者の方に確認してください。"
@unknown default:
purchaseError = "予期しない購入結果です。もう一度お試しください。"
}
} catch StoreKitError. userCancelled {
// ユーザーキャンセルは正常なフロー
break
} catch StoreKitError. notAvailableInStorefront {
purchaseError = "この商品は現在の地域では購入できません。"
} catch StoreKitError. networkError ( let urlError) {
purchaseError = "ネットワークエラー: \( urlError. localizedDescription ) "
} catch {
purchaseError = "購入処理に失敗しました: \( error. localizedDescription ) "
}
isPurchasing = false
}
// MARK: - レシート検証
private func checkVerified < T >( _ result: VerificationResult<T>) throws -> T {
switch result {
case . unverified ( _ , let verificationError) :
// Appleの署名検証に失敗した場合(改ざんの可能性)
throw verificationError
case . verified ( let signedType) :
return signedType
}
}
// MARK: - サブスクリプション状態の更新
@MainActor
func updateSubscriptionStatus () async {
var hasActiveSubscription = false
// 現在有効な全Transactionを確認
for await result in Transaction.currentEntitlements {
do {
let transaction = try checkVerified (result)
// サブスクリプション商品かつ有効期限内であることを確認
if transaction.productType == .autoRenewable
&& transaction.revocationDate == nil {
hasActiveSubscription = true
}
} catch {
// 検証エラーのTransactionはスキップ
continue
}
}
isSubscribed = hasActiveSubscription
}
// MARK: - Transaction更新のリスナー
private func listenForTransactions () -> Task< Void , Error > {
return Task. detached {
// App外での購入(サブスクリプション更新など)を監視
for await result in Transaction.updates {
do {
let transaction = try self . checkVerified (result)
await self . updateSubscriptionStatus ()
await transaction. finish ()
} catch {
// 検証エラーのTransactionはfinishしない
print ( "Transaction verification failed: \( error ) " )
}
}
}
}
// MARK: - 購入の復元
@MainActor
func restorePurchases () async {
isPurchasing = true
do {
// App Store に購入履歴の同期を要求
try await AppStore. sync ()
await updateSubscriptionStatus ()
} catch {
purchaseError = "購入の復元に失敗しました: \( error. localizedDescription ) "
}
isPurchasing = false
}
}
この実装で特に重要な点をいくつか解説します。
Transaction.updates リスナーについて : App外でのサブスクリプション更新(自動更新・プランアップグレードなど)を受け取るために必須です。アプリ起動時に開始し、deinit でキャンセルします。これを忘れると、サブスクリプションの自動更新後にアプリを再起動するまで有効期限が反映されません。
checkVerified() について : StoreKit 2 はローカルでのレシート検証を自動的に行いますが、結果は VerificationResult で返ってきます。unverified ケースを無視して進むと、改ざんされたトランザクションを処理してしまいます。
Step 3: サブスクリプション管理 — 更新・解約・グレードアップ
サブスクリプションの管理で最も複雑なのは、状態遷移の処理です。ユーザーは月額から年額にアップグレードしたり、解約後にまた購読を再開したりします。
Claude Code に状態遷移図の設計を依頼します。
StoreKit 2 のサブスクリプション状態管理で、以下のケースを
enum で表現してください:
- アクティブ(月額)
- アクティブ(年額)
- 試用期間中
- 解約済み(有効期限内)
- 期限切れ
- 購入なし
生成されたコードを元に、詳細な状態管理を追加します。
enum SubscriptionStatus : Equatable {
case notSubscribed // 未購読
case inTrial (expiresDate: Date) // 試用期間中
case active (plan: SubscriptionPlan, expiresDate: Date) // 有効
case gracePeriod (expiresDate: Date) // 支払い猶予期間
case billingRetry // 支払い再試行中
case expired // 期限切れ
case revoked // 払い戻し済み
var isEntitled: Bool {
switch self {
case .inTrial, .active, .gracePeriod :
return true
default:
return false
}
}
}
enum SubscriptionPlan : String {
case monthly = "com.yourcompany.appname.pro_monthly"
case yearly = "com.yourcompany.appname.pro_yearly"
}
次に、PurchaseManager に詳細な状態取得メソッドを追加します。
// MARK: - 詳細なサブスクリプション状態の取得
@MainActor
func fetchSubscriptionStatus () async -> SubscriptionStatus {
let subscriptionIDs: [ String ] = [
SubscriptionPlan.monthly. rawValue ,
SubscriptionPlan.yearly. rawValue
]
// サブスクリプション商品の最新ステータスを取得
guard let product = products. first ( where : { subscriptionIDs. contains ( $0 .id) }),
let statuses = try? await product.subscription ? .status else {
return .notSubscribed
}
// 最も優先度の高いステータスを返す
for status in statuses {
guard let renewalInfo = try? status.renewalInfo.payloadValue,
let transaction = try? status.transaction.payloadValue else {
continue
}
switch status.state {
case .subscribed :
let plan = SubscriptionPlan ( rawValue : transaction.productID) ?? .monthly
let expiresDate = transaction.expirationDate ?? Date ()
// 試用期間中かどうかを確認
if transaction.offerType == .introductoryOffer {
return . inTrial ( expiresDate : expiresDate)
}
return . active ( plan : plan, expiresDate : expiresDate)
case .inGracePeriod :
return . gracePeriod ( expiresDate : transaction.expirationDate ?? Date ())
case .inBillingRetryPeriod :
return .billingRetry
case .revoked :
return .revoked
case .expired :
// 解約済みだが有効期限内のケースを確認
if let expiresDate = transaction.expirationDate, expiresDate > Date () {
let plan = SubscriptionPlan ( rawValue : transaction.productID) ?? .monthly
return . active ( plan : plan, expiresDate : expiresDate)
}
return .expired
@unknown default:
continue
}
}
return .notSubscribed
}
Step 4: App Store Server API でのサーバーサイド検証
クライアント側の VerificationResult は Apple が署名を検証してくれるため、アプリの中だけで完結する機能であればそれで十分です。サーバーが必要になるのは、複数デバイス間で購読状態を揃えたいとき、Web 側でも同じ権利を有効にしたいとき、そして払い戻しをサーバー主導で反映したいときに限られます。
ここで一度立ち止まりたい点があります。
App Store Server API が返すレスポンスは JSON のように見えますが、肝心の中身は signedTransactionInfo という JWS(署名付きトークン) です。この署名を検証せずにペイロードだけを取り出して権利を付与すると、「サーバーサイド検証」と名前のついた処理が、実際には何も検証していない状態になります。
気になったので手元で確かめました。ES256 で署名した JWS を用意し、そこからペイロードだけを別の productId に差し替えたもの、ヘッダーを alg: none に書き換えたものの3種類を、2つのパーサに通します。片方は「Base64URL をデコードしてクレームを読むだけ」、もう片方は「公開鍵で署名を検証してからクレームを読む」実装です。
入力 デコードのみ(署名を見ない) 署名検証あり
正規のトークン 受理 → pro.monthly 受理 → pro.monthly
ペイロードを pro.lifetime に改ざん 受理 → pro.lifetime 拒否(JWSSignatureVerificationFailed)
alg: none の自作トークン受理 → pro.lifetime 拒否(JOSENotSupported)
デコードのみの実装は3件すべてを受理しました。JWS のペイロードは Base64URL で符号化されているだけで、暗号的には平文と変わりません。署名検証を飛ばしたサーバーは、クライアントから届いた文字列をそのまま信じる関数を一枚挟んだだけ、という理解が正確です。100年先まで有効な永久ライセンスを名乗るトークンも素通りします。
Apple は検証済みのオブジェクトを返す公式ライブラリ @apple/app-store-server-library を配布しています。証明書チェーンの検証(リーフ証明書 → 中間証明書 → Apple Root CA G3)とルート証明書の失効確認まで面倒を見てくれるので、自前で jose を組むより確実です。手で組む場合は、JWS ヘッダーの x5c に入っている証明書チェーンを検証し、そのリーフ証明書の公開鍵で署名を検証する、という順序を守ります。ヘッダーの公開鍵をそのまま信用してはいけません。
環境の切り替えを NODE_ENV で決めない
もう一つ、サーバー実装でよく踏む落とし穴があります。本番とサンドボックスのエンドポイントを NODE_ENV で振り分ける書き方です。
// ❌ サーバーの環境変数と、トランザクションの発生した環境は別物
const baseUrl = process.env. NODE_ENV === 'production'
? 'https://api.storekit.itunes.apple.com'
: 'https://api.storekit-sandbox.itunes.apple.com' ;
transactionId は環境ごとに独立した名前空間を持ちます。本番サーバー(NODE_ENV=production)に届く検証リクエストが、常に本番の購入とは限りません。TestFlight ビルドの購入はサンドボックス扱いですし、審査担当者がアプリを操作したときの購入もサンドボックスです。本番エンドポイントにサンドボックスの transactionId を投げると、Apple は 4040010 TransactionIdNotFoundError を返します。
Apple が案内している手順は、環境変数ではなく問い合わせの結果で環境を判定する というものです。まず本番に問い合わせ、4040010 が返ってきたらサンドボックスに問い合わせ直す。両方で同じエラーなら、その transactionId はどちらにも存在しない。
これを踏まえた実装です。
import { SignJWT, importPKCS8 } from 'jose' ;
import { SignedDataVerifier, Environment } from '@apple/app-store-server-library' ;
const PRODUCTION_URL = 'https://api.storekit.itunes.apple.com' ;
const SANDBOX_URL = 'https://api.storekit-sandbox.itunes.apple.com' ;
class AppStoreServerAPI {
private readonly bundleId : string ;
private readonly issuerId : string ;
private readonly keyId : string ;
private readonly privateKey : string ;
private readonly appAppleId : number ;
// Apple Root CA G3 等のルート証明書(DER)を読み込んで渡す
private readonly rootCerts : Buffer [];
constructor ( config : {
bundleId : string ;
issuerId : string ;
keyId : string ;
privateKey : string ; // YOUR_PRIVATE_KEY(.p8 の中身)
appAppleId : number ;
rootCerts : Buffer [];
}) {
this .bundleId = config.bundleId;
this .issuerId = config.issuerId;
this .keyId = config.keyId;
this .privateKey = config.privateKey;
this .appAppleId = config.appAppleId;
this .rootCerts = config.rootCerts;
}
// JWT トークンの生成(有効期限は最大60分)
private async generateToken () : Promise < string > {
const privateKey = await importPKCS8 ( this .privateKey, 'ES256' );
return new SignJWT ({ bid: this .bundleId })
. setProtectedHeader ({ alg: 'ES256' , kid: this .keyId, typ: 'JWT' })
. setIssuer ( this .issuerId)
. setIssuedAt ()
. setAudience ( 'appstoreconnect-v1' )
. setExpirationTime ( '20m' ) // 使い回す場合は失効の余裕を持たせる
. sign (privateKey);
}
private async request ( path : string , baseUrl : string ) : Promise < Response > {
const token = await this . generateToken ();
return fetch ( `${ baseUrl }${ path }` , {
headers: { Authorization: `Bearer ${ token }` },
});
}
// 本番 → サンドボックスの順に問い合わせ、成功した側の環境も返す
private async requestWithEnvironmentFallback (
path : string
) : Promise <{ body : unknown ; environment : Environment }> {
const production = await this . request (path, PRODUCTION_URL );
if (production.ok) {
return { body: await production. json (), environment: Environment. PRODUCTION };
}
if (production.status === 404 ) {
const { errorCode } = ( await production. json (). catch (() => ({}))) as {
errorCode ?: number ;
};
// 4040010 = TransactionIdNotFoundError。サンドボックス側を確認する
if (errorCode === 4040010 ) {
const sandbox = await this . request (path, SANDBOX_URL );
if (sandbox.ok) {
return { body: await sandbox. json (), environment: Environment. SANDBOX };
}
throw new Error ( `transactionId not found in either environment: ${ path }` );
}
}
throw new Error ( `App Store Server API error: ${ production . status }` );
}
// トランザクション情報を取得し、JWS の署名を検証してから返す
async getVerifiedTransaction ( transactionId : string ) {
const { body , environment } = await this . requestWithEnvironmentFallback (
`/inApps/v1/transactions/${ transactionId }`
);
const { signedTransactionInfo } = body as { signedTransactionInfo : string };
// 証明書チェーンの検証を含む。ここを省略すると検証したことにならない
const verifier = new SignedDataVerifier (
this .rootCerts,
true , // オンラインでの証明書失効確認を有効にする
environment,
this .bundleId,
this .appAppleId
);
return verifier. verifyAndDecodeTransaction (signedTransactionInfo);
}
// サブスクリプション状態の取得(全デバイス共通)
async getSubscriptionStatuses ( transactionId : string ) {
const { body } = await this . requestWithEnvironmentFallback (
`/inApps/v1/subscriptions/${ transactionId }`
);
return body;
}
}
export { AppStoreServerAPI };
verifyAndDecodeTransaction() は検証に失敗すると例外を投げます。つまり、返り値を受け取れた時点で「Apple が署名した、このバンドル ID 宛の、この環境のトランザクション」であることが確定しています。権利の付与はここから先で行います。
判定に使うのは environment を含めた組です。サンドボックスのトランザクションで本番の権利を付与しないよう、Environment.SANDBOX が返ってきたときの扱いは明示的に決めておきます。TestFlight のテスターに機能を開放するのか、テスト扱いにして課金状態は付与しないのか。ここを曖昧にしたまま本番に出すと、TestFlight 経由で無料の永久ライセンスが配られている、という状態になり得ます。
参考: TransactionIdNotFoundError | Apple Developer Documentation
Step 5: ペイウォール UI の設計パターン(SwiftUI)
Claude Code に SwiftUI でのペイウォール実装を依頼する際、シンプルな要求から始めて徐々に詳細化していくアプローチが効果的です。
struct PaywallView : View {
@Environment (PurchaseManager. self ) private var purchaseManager
@State private var selectedPlan: Product ?
@State private var showError: Bool = false
@Environment (\.dismiss) private var dismiss
var body: some View {
NavigationStack {
ScrollView {
VStack ( spacing : 24 ) {
// ヘッダー: プレミアム機能の魅力訴求
paywallHeader
// 機能一覧
featureList
// プラン選択
planSelector
// 購入ボタン
purchaseButton
// 復元リンク
restoreButton
// 利用規約・プライバシーポリシー
legalLinks
}
. padding ()
}
. navigationTitle ( "Pro にアップグレード" )
. navigationBarTitleDisplayMode (.inline)
. toolbar {
ToolbarItem ( placement : .navigationBarLeading) {
Button ( "閉じる" ) { dismiss () }
}
}
. alert ( "エラー" , isPresented : $showError) {
Button ( "OK" ) { purchaseManager.purchaseError = nil }
} message : {
Text (purchaseManager.purchaseError ?? "" )
}
. onChange ( of : purchaseManager.purchaseError) { _ , error in
showError = error != nil
}
. task {
// ビュー表示時に最新の商品情報を取得
await purchaseManager. loadProducts ()
// デフォルトで年額プランを選択(割引感を強調)
selectedPlan = purchaseManager.products. first {
$0 .id == SubscriptionPlan.yearly. rawValue
}
}
}
}
// MARK: - ヘッダー
private var paywallHeader: some View {
VStack ( spacing : 12 ) {
Image ( systemName : "star.fill" )
. font (. system ( size : 64 ))
. foregroundStyle (.yellow)
Text ( "Pro で写真編集を解放しよう" )
. font (.title2. bold ())
. multilineTextAlignment (.center)
Text ( "プロフェッショナルなフィルター・高度な編集ツール・広告なし体験" )
. font (.body)
. foregroundStyle (.secondary)
. multilineTextAlignment (.center)
}
}
// MARK: - プラン選択UI
private var planSelector: some View {
VStack ( spacing : 12 ) {
ForEach (purchaseManager.products. filter {
$0 .type == .autoRenewable
}) { product in
PlanCard (
product : product,
isSelected : selectedPlan ? .id == product.id,
isYearly : product.id == SubscriptionPlan.yearly. rawValue
)
. onTapGesture {
withAnimation (. easeInOut ( duration : 0.2 )) {
selectedPlan = product
}
}
}
}
}
// MARK: - 購入ボタン
private var purchaseButton: some View {
Button {
guard let plan = selectedPlan else { return }
Task {
await purchaseManager. purchase (plan)
if purchaseManager.isSubscribed {
dismiss ()
}
}
} label : {
Group {
if purchaseManager.isPurchasing {
ProgressView ()
. tint (.white)
} else {
Text (selectedPlan. map {
"始める — \( $0 . displayPrice ) "
} ?? "プランを選択してください" )
. fontWeight (.semibold)
}
}
. frame ( maxWidth : . infinity )
. frame ( height : 56 )
}
. buttonStyle (.borderedProminent)
. disabled (selectedPlan == nil || purchaseManager.isPurchasing)
}
// MARK: - 購入復元
private var restoreButton: some View {
Button ( "以前の購入を復元する" ) {
Task {
await purchaseManager. restorePurchases ()
if purchaseManager.isSubscribed {
dismiss ()
}
}
}
. font (.footnote)
. foregroundStyle (.secondary)
}
// MARK: - 法的リンク
private var legalLinks: some View {
HStack ( spacing : 8 ) {
Link ( "利用規約" , destination : URL ( string : "https://yourapp.com/terms" ) ! )
Text ( "・" )
Link ( "プライバシーポリシー" , destination : URL ( string : "https://yourapp.com/privacy" ) ! )
}
. font (.caption)
. foregroundStyle (.secondary)
}
}
// MARK: - プランカードコンポーネント
struct PlanCard : View {
let product: Product
let isSelected: Bool
let isYearly: Bool
var body: some View {
HStack {
VStack ( alignment : .leading, spacing : 4 ) {
HStack {
Text (isYearly ? "年額プラン" : "月額プラン" )
. font (.headline)
if isYearly {
// 年額の割引バッジ
Text ( "2ヶ月分お得" )
. font (.caption. bold ())
. padding (.horizontal, 8 )
. padding (.vertical, 2 )
. background (.green)
. foregroundColor (.white)
. clipShape (.capsule)
}
}
if isYearly {
// 月換算額を表示(購買意欲向上)
Text ( "月あたり約 \( monthlyEquivalent ) /月換算" )
. font (.caption)
. foregroundStyle (.secondary)
} else {
product.subscription ? .introductoryOffer. map { _ in
Text ( "7日間無料トライアル付き" )
. font (.caption)
. foregroundStyle (.green)
}
}
}
Spacer ()
VStack ( alignment : .trailing) {
Text (product.displayPrice)
. font (.title3. bold ())
Text (isYearly ? "/ 年" : "/ 月" )
. font (.caption)
. foregroundStyle (.secondary)
}
}
. padding ()
. background (
RoundedRectangle ( cornerRadius : 12 )
. fill (isSelected ? Color.accentColor. opacity ( 0.1 ) : Color (.systemGray6))
. stroke (isSelected ? Color.accentColor : Color.clear, lineWidth : 2 )
)
}
// 年額から月換算を計算
private var monthlyEquivalent: String {
guard let decimal = product.price as Decimal ? ,
decimal > 0 else { return "" }
let monthly = decimal / 12
// ロケールに合わせたフォーマット
return product.priceFormatStyle. format (monthly)
}
}
Step 6: テスト戦略 — StoreKit Testing と Sandbox
StoreKit 2 のテストは、3つの段階で行います。
① Xcode StoreKit Testing(ローカル)
プロジェクトに .storekit 構成ファイルを追加することで、App Store に接続せずに課金フローをテストできます。Claude Code に依頼します。
XCTestで PurchaseManager の以下のケースをテストするコードを書いてください:
1. loadProducts() で商品リストが返ってくること
2. purchase() で成功した場合に isSubscribed が true になること
3. purchase() でキャンセルした場合にエラーにならないこと
4. restorePurchases() が呼び出せること
import XCTest
import StoreKit
@testable import YourApp
final class PurchaseManagerTests : XCTestCase {
var purchaseManager: PurchaseManager !
override func setUp () async throws {
purchaseManager = PurchaseManager ()
// StoreKit Testing の設定ファイルを読み込む
try await SKTestSession.shared. clearTransactions ()
}
// 商品読み込みのテスト
func testLoadProductsReturnsProducts () async throws {
await purchaseManager. loadProducts ()
XCTAssertFalse (purchaseManager.products. isEmpty , "商品リストが空です" )
XCTAssertEqual (purchaseManager.products. count , 3 , "3商品が読み込まれるはず" )
}
// 購入成功のテスト
func testPurchaseMonthlySubscriptionSucceeds () async throws {
await purchaseManager. loadProducts ()
guard let monthlyProduct = purchaseManager.products. first ( where : {
$0 .id == "com.yourcompany.appname.pro_monthly"
}) else {
XCTFail ( "月額商品が見つかりません" )
return
}
// テスト環境での購入実行
await purchaseManager. purchase (monthlyProduct)
XCTAssertNil (purchaseManager.purchaseError, "エラーが発生しました: \( purchaseManager. purchaseError ?? "" ) " )
XCTAssertTrue (purchaseManager.isSubscribed, "購入後に isSubscribed が true になるはず" )
}
}
② Sandbox 環境でのテスト
Sandbox 環境では、以下の状況をテストします。
サブスクリプションの自動更新 : Sandbox では更新間隔が短縮されます(1ヶ月 → 5分)
解約後の期限切れ : Sandbox で解約し、期限切れ後の状態を確認します
払い戻しシナリオ : App Store Connect の Sandbox 管理画面から払い戻しをシミュレートできます
Sandbox テスト用のアカウントは App Store Connect の「ユーザーとアクセス → サンドボックス → テスター」で作成します。Claude Code に「Sandbox テストのチェックリストを作成してください」と依頼すると、見落としがちなケースを網羅したリストを生成してくれます。
Step 7: よくある実装ミスと Claude Code による防止策
StoreKit 2 の実装でよくある失敗を3つ紹介します。Claude Code に「このコードの StoreKit 2 実装を確認してください」と渡すだけで、これらの問題を検出してくれます。
ミス① Transaction.updates を App 起動時に開始していない
// ❌ 間違い: ViewModelやViewで遅れてリスナーを開始する
struct ContentView : View {
var body: some View {
Text ( "Hello" )
. task {
// このタイミングでは App 起動直後のトランザクションを取り逃す
for await result in Transaction.updates { ... }
}
}
}
// ✅ 正しい: App初期化時に必ず開始する
@main
struct YourApp : App {
@State private var purchaseManager = PurchaseManager () // init内でリスナーを開始
var body: some Scene {
WindowGroup {
ContentView ()
. environment (purchaseManager)
}
}
}
ミス② 消耗型アイテムで Transaction.finish() を呼び忘れる
// ❌ 間違い: finish()を呼ばないと次回購入が「待機中」になる
case . success ( let result) :
let transaction = try checkVerified (result)
grantConsumableItem () // アイテムを付与
// finish() がない!→ 次回購入時に同じTransactionが再処理される
// ✅ 正しい: アイテム付与後に必ずfinish()
case . success ( let result) :
let transaction = try checkVerified (result)
grantConsumableItem ()
await transaction. finish () // 必須
ミス③ currentEntitlements の判定でサブスクリプションIDを確認していない
// ❌ 間違い: 全Transactionを有効なサブスクリプションとして処理
for await result in Transaction.currentEntitlements {
isSubscribed = true // 一度購入した消耗型も含まれてしまう
}
// ✅ 正しい: productTypeとrevocationDateを確認
for await result in Transaction.currentEntitlements {
let transaction = try checkVerified (result)
if transaction.productType == .autoRenewable
&& transaction.revocationDate == nil {
isSubscribed = true
}
}
Claude Code への確認の仕方は「このPurchaseManagerの実装で StoreKit 2 のベストプラクティスから外れている箇所はありますか?」と質問するだけです。上記のような問題を検出し、修正案とともに提示してくれます。
Step 8: App Store 審査に向けた最終確認
App Store の審査では、課金実装に関して特に以下の点がチェックされます。Claude Code に「App Store ガイドライン 3.1.1 と 3.1.2 の観点でこの実装を確認してください」と依頼するのが効果的です。
審査通過のポイントは以下の通りです。
購入復元ボタンの設置(必須) : サブスクリプションを含むアプリは「購入を復元する」ボタンが必須です。ない場合はリジェクトされます
利用規約・プライバシーポリシーのリンク(必須) : ペイウォール画面に必ず表示します
試用期間の明確な表示 : 無料トライアルがある場合、期間終了後に課金が始まることを明記します
価格の明確な表示 : 「¥480/月」のように単位を含めて表示します
サブスクリプション管理への導線 : 「Appleの設定アプリからサブスクリプションを管理できます」という旨のテキストを含めることを推奨します
iOS テスト関連の実装については Claude Code で作る iOS テスト自動化基盤 も参考にしてください。
公式ドキュメントには書かれていない StoreKit 2 の運用知見
Apple の公式ドキュメントは「正しい使い方」を丁寧に説明していますが、「実運用で何が起こるか」については沈黙していることが多いです。ここからは、実際に本番で課金を回してみて初めて分かった挙動を、デバッグに時間を取られた順に整理します。
Transaction.updates のタイミングは予測できない
公式の StoreKit 2 ガイドでは「アプリ起動直後に Transaction.updates を購読し始める」とだけ書かれています。しかし実運用では、以下のような順序で Transaction が届くことがあります。
アプリ起動から 0.3 秒後(同期完了済みの場合)
アプリ起動から 3〜5 秒後(バックグラウンドフェッチ後)
アプリがフォアグラウンドに戻った瞬間(リジューム時)
数分後、まったく予期しないタイミング(サーバー側で払い戻しが処理された場合)
つまり、ペイウォール UI を purchaseManager.purchasedProductIDs.isEmpty だけで判定すると、起動直後に「未購入扱い」となってペイウォールが一瞬表示される現象が起こります。対策として、私のアプリでは UserDefaults に最後の購入状態をキャッシュし、Transaction.updates の初回フェッチ完了まではキャッシュを優先する設計にしました。
@MainActor
final class PurchaseManager : ObservableObject {
@Published private ( set ) var isInitialFetchComplete = false
@Published private ( set ) var purchasedProductIDs: Set < String > = []
private let cacheKey = "cached_purchased_product_ids"
init () {
// 1. キャッシュから即座に復元
if let cached = UserDefaults.standard. array ( forKey : cacheKey) as? [ String ] {
purchasedProductIDs = Set (cached)
}
// 2. バックグラウンドで Transaction.updates を購読
Task { await observeTransactions () }
}
private func observeTransactions () async {
// 起動時に既存のエンタイトルメントを同期
await syncEntitlements ()
isInitialFetchComplete = true
// 以後の更新を継続的に監視
for await update in Transaction.updates {
await handleTransaction (update)
}
}
private func syncEntitlements () async {
var ids: Set < String > = []
for await result in Transaction.currentEntitlements {
if case . verified ( let tx) = result {
ids. insert (tx.productID)
}
}
purchasedProductIDs = ids
UserDefaults.standard. set ( Array (ids), forKey : cacheKey)
}
}
ペイウォール側では isInitialFetchComplete を待ち、未完了時はキャッシュを信頼する分岐を入れます。これだけで「起動時のペイウォール一瞬表示」のサポート問い合わせがほぼゼロになりました。
サンドボックスでサブスクリプションの自動更新が「速すぎる」
App Store Connect のサンドボックスでは、月額サブスクリプションが現実時間で 5 分ごとに更新される仕様になっています。これは便利な一方、テスト中に意図せず更新が走ってログが汚れる原因にもなります。
私はサンドボックス専用のテストアカウントを 5 つ作り、それぞれ「新規購入」「更新済み」「解約済み」「失効済み」「リファンド済み」の状態を担当させる運用にしました。Apple のドキュメントには明記されていませんが、サンドボックスアカウントの状態は App Store Connect の「Sandbox Testers」セクションでアカウント単位で確認できます。
Ask to Buy の承認待ちを「失敗」として扱わない
ファミリー共有の Ask to Buy が有効な端末では、購入ボタンを押しても即座には完了しません。product.purchase() の戻り値は .pending になり、保護者が承認するまで宙に浮きます。この状態を「購入失敗」としてアラート表示だけで終わらせると、承認後にトランザクションが届いても UI が何も反応せず、ユーザーからは課金が消えたように見えます。App Store の審査でも、ここは指摘対象になります。
正しくは、.pending を「保留中」という第三の状態として持ち、承認は Transaction.updates 経由で後から届く前提で組みます。
enum PurchasePhase : Equatable {
case idle
case purchasing
case awaitingApproval // Ask to Buy の承認待ち
case completed
case failed ( String )
}
@MainActor
func purchase ( _ product: Product) async {
phase = .purchasing
do {
let result = try await product. purchase ()
switch result {
case . success ( let verification) :
let transaction = try checkVerified (verification)
await updatePurchasedProducts ()
await transaction. finish ()
phase = .completed
case .pending :
// 承認待ち。ここでは finish() できるトランザクションが存在しない
phase = .awaitingApproval
case .userCancelled :
phase = .idle
@unknown default:
phase = .idle
}
} catch {
phase = . failed (error.localizedDescription)
}
}
.pending のときに Transaction.finish() を呼ぼうとしてクラッシュするコードを一度書いたことがあります。この分岐にはそもそも Transaction が存在しないためです。承認された購入は、起動時から回している Transaction.updates のリスナー側にだけ届きます。つまり、リスナーを PurchaseManager の init で開始していないと、Ask to Buy の購入は永久に反映されません。
Sandbox での再現手順も残しておきます。
App Store Connect の Sandbox テスターを2つ作り、片方を「お子様」相当のアカウントとして扱う
実機の設定アプリ → Developer → Sandbox Apple Account でお子様側のテスターにサインイン
同画面の Ask To Buy をオンにする
アプリで購入 → .pending に落ちることを確認
設定アプリの Sandbox Apple Account 画面に承認要求が並ぶので、そこから承認
アプリをフォアグラウンドに戻し、Transaction.updates 経由で権利が付与されることを確認
この 6 ステップは QA チェックリストに固定で入れています。承認までの待ち時間は Sandbox でも数秒から数十秒とばらつくため、awaitingApproval の画面には「承認され次第、自動で有効になります」という文言を置き、ユーザーが購入ボタンを連打しないようにしています。
appAccountToken を必ず付ける
Product.purchase() の引数に appAccountToken: UUID を渡せます。公式ドキュメントでは「ユーザーを識別したい場合に使用」とだけ書かれており、優先度が低そうに見えますが、これがないと払い戻し対応で大きく困ります。
let result = try await product. purchase ( options : [
. appAccountToken (userUUID) // 自社サーバーで発行した安定 ID
])
App Store Server Notifications v2 で払い戻し通知が届いたとき、signedTransactionInfo に appAccountToken が入っていれば、自社 DB の特定ユーザーに対する権利剥奪を確実に実行できます。appAccountToken がないと、originalTransactionId をユーザーに紐づけて保存するロジックを別途用意する必要が出てきます。最初から appAccountToken を付ける運用にしておく方が、後で困りません。
Stripe × Lab サイトの経験から見た StoreKit 2 の設計思想
Dolice Labs の4サイト(Claude Lab / Gemini Lab / Antigravity Lab / Rork Lab)では、Web 側のサブスクリプション課金に Stripe を採用しています。記事単体購入・月額プラン(Pro)・永久プラン(Premium)・チップという 4 種類の決済を扱っており、Stripe の設計思想と StoreKit 2 の設計思想を行き来する機会が多くあります。両者を比較すると、StoreKit 2 の独特な制約と利点が見えてきます。
Webhook 駆動 vs アプリ駆動の根本的な違い
Stripe では、ほぼすべての状態変化が Webhook で自社サーバーに通知される ことを前提に設計します。customer.subscription.updated や invoice.paid のような明確なイベント名が決まっており、それぞれの発火タイミングも公式ドキュメントで保証されています。
StoreKit 2 にも App Store Server Notifications v2 という同等の仕組みがありますが、主な状態取得手段はアプリ側の Transaction.updates です。アプリがオフラインの間に発生した状態変化を、サーバー側だけで完結して扱う設計は、Apple のエコシステムでは不自然に映ります。
この違いは、設計に次のような影響を与えます。
真実のソース(Source of Truth) : Stripe は完全にサーバー、StoreKit はアプリとサーバーの両方
ユーザー識別の確実性 : Stripe は Customer ID で 1:1、StoreKit は appAccountToken を付けないと曖昧
テストの容易さ : Stripe は CLI で任意のイベントを発火可能、StoreKit はサンドボックスの挙動に縛られる
この比較から学んだのは、StoreKit 2 を採用する場合でも、サーバー側の真実のソースを別に持つべき ということです。Dolice Labs では Cloudflare KV に site:{site}:article:{email}:{slug} というキーで購入記録を保存していますが、アプリ側でも同じパターンを採用しました。Realm の purchased_products テーブルにレコードを書き込み、Transaction.updates ともサーバー側の状態とも整合性を取るロジックを Claude Code に書いてもらいました。
価格の柔軟性で StoreKit が勝る場面
Stripe では、価格変更や A/B テストを行うために Price を複数作成して切り替える必要があります。StoreKit では、App Store Connect の Price Tier を変更すれば全ユーザーに即座に反映されます。
しかし、これは諸刃の剣でもあります。壁紙アプリで月額 ¥120 → ¥150 に変更したとき、既存ユーザーには影響を与えず、新規ユーザーにのみ新価格が適用されました。Stripe では、この「既存ユーザーは旧価格で継続」を実現するために、複数の Price を維持して新規のみ新 Price を割り当てる必要があり、運用負荷が大きく異なります。
解約フローのコントロール
Stripe では、ユーザーが解約するためのリンクを自社で実装できます(Customer Portal でも、独自 UI でも)。StoreKit では、解約画面は Apple の「サブスクリプション管理」設定画面に飛ばすしかありません 。これはユーザー保護の観点では正しい設計ですが、解約理由の収集や引き留めの導線を仕込む余地がほぼないことも意味します。
この制約に気づいたとき、「アプリ内の解約直前画面で何らかのフックを使ってアンケートを表示できないか」と Claude Code に相談しました。Claude Code はすぐに「Apple のガイドラインに反する可能性が高く、審査で指摘される」と教えてくれました。代わりに、ペイウォール表示時にユーザーが過去に解約していた場合のみ「以前ご利用いただいたサービスについて、改善のために一言だけお聞かせいただけますか」と表示する代替案を提示してくれました。設計の制約を AI と一緒に確認しながら進められる利点を強く感じた場面でした。
広告とサブスクリプションをどう配分するか
サブスクリプションの「設計」は、コードを書く時間より判断を悩む時間の方が圧倒的に長い領域です。広告収益がまだ主力だった時期に「広告を一切外してサブスクリプションだけにする」案を何度も検討し、最終的にハイブリッドを選びました。その判断の根拠を、Claude Code と何度も対話しながら詰めた形で整理しておきます。
判断軸 1: ARPU か MAU か
サブスクリプションは ARPU(ユーザー当たり収益)を上げる施策で、広告は MAU(月間アクティブユーザー)からの収益化です。両者は対立しないように見えて、UI 設計のレベルでぶつかります。広告枠を残せばペイウォールの威力が弱まり、ペイウォールを強くすれば離脱率が上がって広告収益も落ちます。
私が選んだのは、「無料ユーザーは広告 + 全機能、有料ユーザーは広告なし + 限定壁紙」 という配分でした。広告を完全に外さなかった理由は、課金率が想定より低く業界平均の 2〜3% に落ち着いたからです。完全サブスク化を選ばなかったのは、AdMob からの安定収益を失う方が個人開発者にとってリスクが大きいと判断したためです。
判断軸 2: 月額か買い切りか
これは延々と悩むテーマです。最終的に 両方提供する 形に落ち着きました。Dolice Labs でも Pro(月額)と Premium(永久)の両方を提供しています。
判断の根拠は「読者の心理コストを最小化したい」という点でした。月額は安いが「いつ解約しよう」と考え続ける負担があり、買い切りは高いが「もう何も悩まなくていい」という安心感を提供できます。どちらを選ぶかはユーザー自身の判断で、こちらは選択肢を提示するだけです。
iOS アプリでも同じ思想で、消耗型 IAP(個別壁紙の購入)+ 非消耗型 IAP(永久のプロ機能)+ 月額サブスクリプションの 3 段構えを試したことがあります。Claude Code に「この 3 つの違いをユーザーに 1 画面で説明する UI を作ってください」と依頼したところ、左から「お試し」「中期」「長期」のメンタルモデルで並べる案を出してくれました。実装してみると、特に長期利用想定のユーザーが買い切りを選ぶ比率が上がりました。
判断軸 3: 価格は読者にとって「フェア」か
価格設定は、自分の作ったものの価値を静かに主張する行為でもあります。安すぎると「自信がないのかな」と思われ、高すぎると「強欲だな」と感じられる。
Dolice Labs では月額を ¥580、永久を ¥2,480 に設定しています(日本円ベース)。これは「ファミレスのランチくらいの月額」と「単行本2冊くらいの永久」という基準で決めました。iOS アプリでも同じ感覚で、月額 ¥150〜¥250 のレンジで設計しています。Tier の刻み方は App Store Connect の Price Tier の制約に従いつつ、$0.99/$1.99/$2.99 のような「99セント表記」は意図的に避けています。誠実さに欠ける印象があるからです。
判断軸 4: 投資対効果が測れない実装に時間をかけない
最後に、これは StoreKit の実装そのものとは関係ありませんが、最も重要な判断軸です。サブスクリプション周りのコードは、書けば書くほどバグが増えます。
たとえば「サブスクリプションがあと 3 日で切れる場合、ペイウォール内に色を変えた警告を表示する」のような細かい UX 改善は、本当に課金率に貢献するでしょうか。この種の改善には何度も時間をかけましたが、A/B テストの結果は 「ほぼ無関係」 という結論に至りました。
Claude Code と作業するようになって変わったのは、「実装にかかる時間」が短くなった分、こうした細かい改善も気軽に試せるようになったことです。1 時間で実装できれば、1 週間で結果を見て「効果なし」と判断するコストも下がります。個人開発者にとって AI が真に変えたのは、ここだと感じています。
全体を振り返って
StoreKit 2 の実装は、API の理解さえできれば Claude Code との組み合わせで大幅に効率化できます。この記事で実装したポイントを整理します。
PurchaseManager で課金状態をアプリ全体で管理し、Transaction.updates を起動時から監視します
VerificationResult の checkVerified() でレシート検証を必ず行います
サーバー側では signedTransactionInfo の JWS 署名と証明書チェーンを検証し、環境は NODE_ENV ではなく 4040010 からのフォールバックで判定します
サブスクリプション状態は productType・revocationDate の両方を確認します
消耗型アイテムは付与後に Transaction.finish() を呼びます
テストは Xcode StoreKit Testing → Sandbox → 本番の順で段階的に行います
次のステップとして、App Store Server Notifications v2 の Webhook 受信を実装することをおすすめします。サブスクリプションのキャンセルや払い戻しをリアルタイムに検知し、サーバーサイドの状態を即座に更新できます。Claude Code に「App Store Server Notifications v2 の Webhook を Express.js で受信するコードを書いてください」と依頼すれば、署名検証込みの完全な実装を数分で得られます。
App Store Connect の設定自動化については Claude Code × App Store Connect リリース自動化 もあわせてご覧ください。