「規約ページはちゃんと公開してるのに、なぜ落ちる?」——自動更新サブスク(IAP)を載せた iOS アプリの審査リジェクト通知を開いて、最初に頭をよぎったのがこれでした。この記事は、自動更新サブスクを実装して App Review を通すまでに何でつまずき、どう直したかの記録です。読者として、iOS アプリに自動更新サブスク課金を実装して審査を通そうとしている開発者を想定しています。
跳ね返されたのは、playpark で開発している Ima. のビルドでした。Ima. は、一緒にいる時間だけ、その場の全員でスマホを手放すための iOS アプリです。家族の食卓などで全員が「いま置こう」と合意したときだけセッションが始まり、相手から離れると自動で解ける。我慢で依存を断つ禁欲ツールというより、目の前の相手との時間を守るための合意ベースの仕掛けを目指しています。その Ima. に載せた「Ima. Pro」という自動更新サブスクリプション——これを最初に積んだバイナリが、一度 App Store 審査でリジェクトされたわけです。
Ima. は App Store にリリース済みです。
ちなみにリリース後、LP の「近日公開」バッジ2箇所を App Store ボタンへ差し替える作業も入りました。審査を通すというのは、こういう「公開済み導線への切り替え」までやってようやく一区切り、ということでもあります。
まず落ちたのは Guideline 3.1.2(c) だった
最初のリジェクト理由は Guideline 3.1.2(c) — 自動更新サブスクリプションの必須情報不足 でした。version 1.0 のビルドが、自動更新サブスクに求められる開示要件を満たしていない、という指摘です。
ここで誤読しかけました。「規約が無い」と言われたわけではありません。利用規約(EULA)ページ自体は ima.playpark.co.jp/terms に公開済みで、これで開示は足りていると思いきや、落ちた本当の理由は別でした。購入画面とメタデータに、その規約・プライバシーポリシーへの「機能するリンク」が無い——ページがあるかどうかではなく、課金 UI からその場で踏めるかどうかを見られている、ということです。
直したのは購入画面(PaywallView)です。設定画面で既に使っていた SafariView パターンをそのまま流用して、規約とプライバシーへのリンクを購入画面の中に置きました。
// App Store Guideline 3.1.2(c): 自動更新サブスクリプションを提示する画面には
// 利用規約(EULA)とプライバシーポリシーへの機能するリンクが必須。
private var legalLinks: some View {
HStack(spacing: 12) {
Button("利用規約") {
presentedURL = URL(string: "https://ima.playpark.co.jp/terms")
}
Text("·").foregroundStyle(.secondary)
Button("プライバシーポリシー") {
presentedURL = URL(string: "https://ima.playpark.co.jp/privacy")
}
}
.font(.caption2)
}
合わせて、規約ドキュメントに「利用料金・サブスクリプション」条項を新設し(料金・自動更新・トライアル・解約・ファミリー共有・返金方針を明記)、App Store の説明文にもサブスク情報と EULA/Privacy の URL を追記しています。コミットのメッセージにも「自動更新サブスクの規約/プライバシーリンク不足を解消 (App Review 3.1.2c)」と残してあって、後から「なぜこの条項が増えたんだっけ」を辿れるようにしました。バイナリ変更なので、リンク追加 → 新ビルド提出 → メタデータ反映 → 再提出、という一周が必要になります。
価格をハードコードしていたら、それも開示要件で危ない
「動いてるんだから価格は文字列で十分でしょ」——最初はそう思っていました。でも審査の目で見ると、これが実際どうなの?という話になります。3.1.2(c) を読み込むと、自動更新の開示文(「いくらで・どの周期で更新されるか」)も整っていないといけません。当の最初の実装は価格を文字列でハードコードしていた。これは二重にまずい。表示と実際の課金額がズレうるし、地域ごとの通貨・価格にも追従できません。
なので開示文の価格を、StoreKit の Product.displayPrice から引くように直しました(コミットは「自動更新開示の価格を StoreKit displayPrice から取得(ハードコード除去)」)。displayPrice は StoreKit がロケールと App Store Connect の価格表から組み立てた、表示用の文字列です。
private var renewalDisclosureText: String {
let monthly = manager.products
.first { $0.id == SubscriptionProductID.monthly.rawValue }?
.displayPrice ?? "¥980"
let yearly = manager.products
.first { $0.id == SubscriptionProductID.yearly.rawValue }?
.displayPrice ?? "¥7,800"
return "無料トライアル終了後、月額 \(monthly)(または年額 \(yearly))で自動更新されます。" +
"期間終了の 24 時間以上前に解約しない限り更新されます。" +
"購入は Apple ID に請求され、設定でいつでも管理・解約できます。"
}
?? "¥980" のフォールバックは「商品取得に失敗したときの保険」であって、本来は通らない経路です。開示文に出る金額は、購入ボタンに出る product.displayPrice と同じソースから引いている、というのがポイントです。ここを別々に持つと、片方を直してもう片方を直し忘れる、という審査リジェクトの温床になります。
偽の比較表を撤去した
実は最初の Paywall には、Pro と無料の差を見せる比較表が載っていました。問題は、その表がまだ実装していない機能(複数グループ対応・記録の保存日数拡張・カスタムメッセージ)を「Pro でできること」として並べていたことです。動かない機能をセールスポイントとして提示するのは、審査以前に誠実さの問題でもあります。
そこでこの偽比較表を全部撤去し、「正直な枠組み文言+トライアルの開示」へ作り直しました(コミットは「偽比較表を撤去しスポンサー枠組み文言とトライアル開示へ改修」)。残したのは、Ima. の全機能に制限はない、家族のうちひとりが契約すればファミリー共有で全員が使える、という事実ベースの説明だけです。
private var sponsorFraming: some View {
VStack(spacing: 12) {
Text("ひとりが続ければ、家族みんなが続けられる")
.font(.headline)
Text("Ima. のすべての機能に制限はありません。家族のうちひとりが契約すれば、ファミリー共有でつながった家族全員がそのまま使えます。")
.font(.footnote)
.foregroundStyle(.secondary)
if manager.isEligibleForFreeTrial {
Text("最初の 1 ヶ月は無料。その後は自動更新で、いつでも解約できます。")
.font(.footnote.bold())
}
}
}
機能差で売るのをやめると、Paywall に書くことが「正直に何を提供するか」だけに絞られます。皮肉なことに、審査リジェクトをきっかけに Paywall のコピーが一番シンプルになりました(最初からそう書けという話ではある)。
isPro と isEligibleForFreeTrial の意味をはっきりさせる
無料1ヶ月トライアルを足すにあたって、課金状態を表す変数の意味を明文化しました。バグの大半は「この Bool は結局何を表してるんだっけ」が曖昧なところから来るからです(正直、その「あいまいな Bool を放置した過去の自分」が一番の容疑者でした)。
SubscriptionManager には2つの軸を置きました。isPro は「世帯が entitled か(トライアル中・有料・Family Sharing 共有のいずれか)」、isEligibleForFreeTrial は「無料トライアル提示の文言を出すかどうか」です。
/// 世帯 entitled を表す: トライアル中・有料・Family Sharing 共有のいずれか。
/// SessionAccessPolicy の isEntitled 入力として SessionCoordinator から参照される。
@Published private(set) var isPro: Bool = false
/// 無料トライアル提示文言の出し分け用。安全側デフォルト true
/// (CTA を出し、実際の適格性は StoreKit が購入時に強制する)。
@Published private(set) var isEligibleForFreeTrial: Bool = true
isEligibleForFreeTrial を true 既定にしているのは意図的です。適格性の最終判定は StoreKit が購入時に強制するので、アプリ側は「分からないなら CTA を出しておく」で安全側に倒せます。実際の適格性は商品ロード時に subscription.isEligibleForIntroOffer から非同期で引いて更新します。表示の都合と、課金の正しさを別レイヤーに分けておく、というのが効きました。
セッション開始の可否そのものは、StoreKit にも UI にも依存しない純粋型に切り出しています。入力は「初回味見を消費済みか」と「世帯が entitled か」の2つだけです。
struct SessionAccessPolicy {
func decide(tastedConsumed: Bool, isEntitled: Bool) -> SessionAccessDecision {
if isEntitled { return .allowed(.entitled) }
if !tastedConsumed { return .allowed(.freeTaste) }
return .blocked
}
}
未契約でも初回1セッションだけはフル体験できる(Stage 0 の「味見」)、という設計です。このフラグはセッションが開始に成功した瞬間に立てます。完了時ではありません。完了時にすると、force-quit で何度でも無料セッションを繰り返せてしまうからです。守るべき1ヶ月トライアルのほうは StoreKit がサーバー側で固定するので、こちらの味見フラグが再インストールでリセットされる程度は許容、と割り切っています。
審査員にサンドボックス購入をさせない: Review Mode
ここが課金アプリの審査でいちばん詰まりやすいところでした。Ima. は「相手の端末が物理的に近くにある」ことを BLE で確かめてからセッションを始めます。でも審査員の手元に、ペアになる2台目の端末はありません。さらに今回はそこに課金ゲートが乗っています。審査員が機能を確認するのにサンドボックス購入が要る状態だと、購入待ちで審査が詰まります。
対策として、ディープリンク(ima://review-demo?token=...)で起動する審査専用の Review Mode を用意しました。本物の状態機械や合意フローはそのまま動かしつつ、外部依存だけをモックに差し替える隔離された導線です。BLE は近接値を手で流すモックに、Shield は呼び出し回数だけ記録するスパイに置換します。
そのうえで重要なのが、デモ用のコーディネーターを課金エンタイトルメントのゲートから外すことです。Review Mode の組み立てでは isEntitled クロージャに固定で true を注入し、味見ストアもインメモリ版にしています。
let coordinator = SessionCoordinator(
ble: ble,
shield: shield,
selectionProvider: { FamilyActivitySelection() },
activeSessionStore: .inMemory(),
watchBridge: noopBridge,
standardDefaults: volatileDefaults,
tasteStore: InMemoryStage0TasteStore(),
isEntitled: { true } // 審査員はサンドボックス購入不要
)
このゲート除外、一度デグレで戻してしまったことがあって、コミットにも「デモ coordinator を entitlement ゲートから除外(審査員はサンドボックス購入不要・回帰修正)」と残しています。ここを外し忘れると、審査専用の導線なのに審査員が購入を求められる、という本末転倒な詰みになります。
入口ガードの実装側はこうなっています。デバッグ用のバイパスとは別に、本番経路では SessionAccessPolicy の判定を必ず通します。
if !entitlementBypass {
switch SessionAccessPolicy().decide(
tastedConsumed: tasteStore.tastedConsumed,
isEntitled: isEntitled()
) {
case .allowed(.freeTaste):
tasteStore.markConsumed() // start 成功の瞬間に消費
case .allowed(.entitled):
break
case .blocked:
accessGate = .blocked
return
}
}
Review Mode は、この本番の isEntitled() に { true } を流し込むことで、「ロジックは本物・課金だけ素通し」を構造的に作っています。本番ビルドには imaBypassEntitlementGate のような抜け道は存在せず、隔離はコンストラクタ注入だけで担保する、という形です。
審査ノートに IAP の通り方を全部書いておく
最後は地味ですが効く話です。Review Mode を作っても、審査員がそれに気づかなければ意味がありません。なので、審査ノート(fastlane の review_information/notes.txt)に、IAP とフリー体験のフロー全体を英語で明記しました(コミットは「IAP/フリー体験説明を notes.txt に追記しチェックリスト同期」)。
書いたのは、初回セッションは購入もサインインも不要で無料(Stage 0)であること、その最初のセッションが終わると次の起動時に1ヶ月無料トライアル付きの Paywall が出ること、2つの商品はどちらも「Ima. Pro」グループの自動更新サブスクで Family Sharing 有効であること、そして Review Mode が onboarding → セッション → Paywall → トライアル購入 → 次セッションまでをインメモリのサンドボックスで再演するので、審査中に実際の StoreKit トランザクションは発生しないこと——です。
The first session is free with no purchase or sign-in required (Stage 0 free
taste). After that first session ends, the next "Enter Ima." tap surfaces the
Paywall with a 1-month free trial. ... The Review Mode above replays the full
flow ... against an in-memory sandbox, so no real StoreKit transactions occur
during review.
このノートは手で App Store Connect に貼り直すのではなく、ファイルを編集して fastlane の submit 準備経由で反映されるようにしてあります。手作業で貼ると「コードは直したのにノートが古いまま」がすぐ起きるので、ノートと提出物を同じフローに乗せておく、というのが審査を何周もした末の結論でした。提出前チェックリストも、手で明記していた項目を「自動化済み」に書き換えています。
まとめ
自動更新サブスクの審査は、コードの正しさより「開示が揃っているか」「審査員が買わずに確認できるか」で落ちる、という体感でした。Guideline 3.1.2(c) は規約ページの有無ではなく購入画面からの機能するリンクを見ている、開示の価格は displayPrice と同じソースから引かないとズレる、動かない機能を比較表で売ってはいけない、審査専用導線はエンタイトルメントゲートから外さないと購入待ちで詰まる、そして Review Mode は審査ノートに書いて初めて届く。どれも StoreKit のドキュメントの一行目には書いていない、提出して跳ね返されて初めて分かる類のものでした。
iOS アプリに自動更新サブスクを載せて審査を通そうとしている方の、Paywall 設計や審査導線の組み方の参考になれば嬉しいです。Ima. では課金よりも「一緒にいる時間をどう守るか」のほうに本質があるので、合意や近接の設計については別記事で触れています。



