サブスクリプションアプリを Rork で作って TestFlight には通った、けれど審査リジェクトの通知が届いた。理由を読むと「Guideline 3.1.1 — In-App Purchase」と書いてあって、本文には「ユーザーがアカウントを復元する手段が見当たらない」というメッセージ。私はこのリジェクトを過去に3回受けたことがあって、毎回原因は同じところにあります。
意外なほど多くの個人開発者が「Restore Purchases ボタンを実装したつもり」で詰まっています。ボタンは画面に置いてあるのに、審査チームは「動いていない」と判定して返してくる。今回はその原因を、Rork で生成された React Native コードベースを前提に、最短で直す手順としてまとめます。
なぜ Restore Purchases が必須なのか
Apple Developer Program License Agreement と App Store Review Guideline 3.1.1 では、サブスクリプションや非消費型 IAP を販売するアプリに、購入を復元する手段の提供を義務付けています。これは法律上の保護というよりは、ユーザー体験の保護です。
具体的な利用シーンを想像すると分かりやすいです。ユーザーが iPhone を機種変更した、アプリを一度削除して再インストールした、あるいは家族で同じ Apple ID を共有していて別の端末で課金ステータスを引き継ぎたい。こうした場面で「もう一度お金を払ってください」と言われたら、誰でも怒ります。Apple は「すでに支払った購入を、ユーザーがいつでも追加課金なしに再有効化できるボタン」を要求しています。
ここで重要なのは、Apple にとっての「動いている」の定義です。ボタンを押すと StoreKit を呼び出して、Apple ID に紐付いたアクティブなトランザクションをアプリ側に渡し、そのトランザクションを根拠にアプリが会員権限を再付与します。この一連のフローが画面遷移なしに完結することを審査で確認します。ボタンが「Coming Soon」のアラートを出すだけでは、当然リジェクトです。
リジェクトされる4つの典型パターン
実際にリジェクトされたコードを見てきた経験から、原因はだいたい4種類に分けられます。
ボタンが画面のどこにもありません。 これは初心者によくあるパターンで、Stripe の決済 UI を React Native アプリに移植したまま IAP の仕様を読まずに公開すると起こります。Apple は「ボタンを設定画面または購入画面のどちらかに配置すること」を要求しています。
ボタンはあるが API を呼んでいありません。 Rork の AI に「Restore Purchases ボタンを作って」とだけ伝えると、見た目だけ整って onPress で Alert.alert("Restored!") を出すだけのコードを生成することがあります。これでは StoreKit と通信していません。
API は呼ぶが結果を反映していありません。 getAvailablePurchases() を呼んで配列を取得しているのに、取得したトランザクションを基にアプリ側の権限フラグ(例: AsyncStorage の is_premium)を更新していないケース。ユーザーから見ると「ボタンを押しても何も起きない」アプリです。
Sandbox 環境でしかテストしていありません。 TestFlight で動いているからといって、本番リリース後のレシート検証フローを通っていないことがあります。Sandbox レシートは sandbox.itunes.apple.com に送るのに対して、本番は buy.itunes.apple.com に送る必要があり、ここを切り替えていないと本番でだけ Restore が失敗します。
expo-in-app-purchases での実装
Rork で生成された Expo プロジェクトで IAP を扱う場合、選択肢は expo-in-app-purchases(Expo SDK 49 まで標準サポート、現在は extras 扱い)か react-native-iap(コミュニティ製、機能豊富)の2択になります。新規プロジェクトなら後者を推奨しますが、すでに expo-in-app-purchases で書かれているコードを修正する場合の最小実装を示します。
// app/utils/restorePurchases.ts
import * as InAppPurchases from 'expo-in-app-purchases';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Alert } from 'react-native';
export async function restorePurchases(): Promise<boolean> {
try {
// StoreKit との接続を確認
await InAppPurchases.connectAsync();
// Apple ID に紐付いた購入履歴を取得
const { results, responseCode } = await InAppPurchases.getPurchaseHistoryAsync({
useGooglePlayCache: false, // iOS では無視されるが明示的に設定
});
if (responseCode !== InAppPurchases.IAPResponseCode.OK) {
throw new Error(`Restore failed with code ${responseCode}`);
}
// アクティブなサブスクリプションを抽出
const activeSubscription = results?.find(
(purchase) =>
purchase.productId === 'com.example.app.premium_monthly' &&
purchase.transactionReceipt &&
!purchase.acknowledged === false
);
if (activeSubscription) {
// 権限フラグを更新(最も忘れられやすい部分)
await AsyncStorage.setItem('is_premium', 'true');
await AsyncStorage.setItem('premium_restored_at', new Date().toISOString());
Alert.alert('復元完了', 'サブスクリプションを復元しました。');
return true;
} else {
Alert.alert('復元する購入なし', 'この Apple ID で購入された有効なサブスクリプションが見つかりませんでした。');
return false;
}
} catch (error) {
console.error('[restorePurchases] error:', error);
Alert.alert('エラー', '購入の復元に失敗しました。ネットワークを確認してもう一度お試しください。');
return false;
} finally {
await InAppPurchases.disconnectAsync();
}
}このコードのポイントは、AsyncStorage.setItem('is_premium', 'true') の行が必ず含まれていることです。getPurchaseHistoryAsync が成功しても、アプリ内で会員権限のフラグを更新しない限り、ユーザーから見ると「ボタンを押しても何も変わらない」状態のままです。
react-native-iap での実装
新規プロジェクトや、より細かい制御が必要な場合は react-native-iap を使います。こちらの方が現在は活発にメンテナンスされていて、StoreKit 2 の async/await にも対応しています。
// app/utils/restorePurchases.ts
import {
initConnection,
endConnection,
getAvailablePurchases,
finishTransaction,
Purchase,
} from 'react-native-iap';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Alert } from 'react-native';
const PRODUCT_IDS = {
PREMIUM_MONTHLY: 'com.example.app.premium_monthly',
PREMIUM_YEARLY: 'com.example.app.premium_yearly',
};
export async function restorePurchases(): Promise<boolean> {
try {
await initConnection();
// Apple ID 配下のすべての有効な購入を取得
const purchases: Purchase[] = await getAvailablePurchases();
// サブスクリプション系の productId に絞り込み
const activePurchases = purchases.filter((p) =>
Object.values(PRODUCT_IDS).includes(p.productId)
);
if (activePurchases.length === 0) {
Alert.alert('復元なし', 'この Apple ID で購入された有効なサブスクリプションが見つかりませんでした。');
return false;
}
// 最新の購入を権限の根拠にする
const latestPurchase = activePurchases.sort(
(a, b) => (b.transactionDate ?? 0) - (a.transactionDate ?? 0)
)[0];
// サーバー側でレシート検証する場合はここで送信
// const verified = await verifyReceiptOnServer(latestPurchase.transactionReceipt);
// アプリ内の権限フラグを更新
await AsyncStorage.setItem('is_premium', 'true');
await AsyncStorage.setItem('premium_product_id', latestPurchase.productId);
// トランザクションを完了状態にする(重要: これを忘れると同じ購入が何度も復元イベントとして来る)
await finishTransaction({ purchase: latestPurchase, isConsumable: false });
Alert.alert('復元完了', 'プレミアム機能をご利用いただけます。');
return true;
} catch (error: any) {
console.error('[restorePurchases] error:', error);
Alert.alert('エラー', error?.message ?? '購入の復元に失敗しました。');
return false;
} finally {
await endConnection();
}
}finishTransaction の呼び出しを忘れると、次回起動時にも同じトランザクションが「未処理」として降ってきて、ユーザーが Restore を押すたびに同じ購入を再認識する挙動になります。これは見た目の不具合だけでなく、Apple の審査チームがランダムに何度も Restore を押して挙動を確認するため、リジェクト理由になることがあります。
設定画面に必ずボタンを置く
実装ができたら、ユーザーがすぐ見つけられる場所に Restore Purchases ボタンを配置します。Apple が推奨する位置は2つあります。
ひとつはサブスクリプション購入画面の中。新規ユーザーが購入を検討するときに「すでに購入済みの場合はこちら」という選択肢を見せます。もうひとつは設定画面のサブスクリプション関連項目。これが審査でチェックされる主要な場所です。
// app/(tabs)/settings.tsx
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { restorePurchases } from '@/utils/restorePurchases';
export default function SettingsScreen() {
return (
<View style={styles.container}>
<Text style={styles.sectionTitle}>サブスクリプション</Text>
<TouchableOpacity
style={styles.row}
onPress={() => restorePurchases()}
accessibilityLabel="購入を復元する"
accessibilityRole="button"
>
<Text style={styles.label}>購入を復元 (Restore Purchases)</Text>
</TouchableOpacity>
</View>
);
}accessibilityLabel と accessibilityRole を必ず指定してください。Apple の審査チームは VoiceOver でアプリを操作することがあり、ラベルがない要素は「機能していない」と判定されることがあります。
Sandbox での確認手順
実装が終わったら、本物の課金が走らない Sandbox 環境でテストします。手順は次の通りです。
まず App Store Connect の「ユーザーとアクセス」から Sandbox テスター用のメールアドレスを作成します。実機の「設定 → App Store → サンドボックスアカウント」にこのアカウントでサインインしておきます。本来の Apple ID とは別のアカウントが必要なので、Gmail のエイリアス機能(yourname+sandbox1@gmail.com のような書き方)を使うと管理しやすくなります。
次に TestFlight ではなく、Xcode から直接実機にビルドします。TestFlight ビルドだと一部のエッジケース(特に解約後の挙動)がテストしづらいためです。アプリを起動して購入フローを通し、一度購入したあとアプリを削除して再インストール、Restore Purchases ボタンを押して権限が戻ることを確認します。
ここで重要な確認ポイントがあります。Sandbox では月額サブスクリプションが3分で1ヶ月分の更新サイクルとして動きます。短時間で「更新 → 解約 → 復元」のシナリオを再現できるので、エッジケースを潰すには絶好のタイミングです。
リジェクト後の再申請メッセージ
もし運悪くリジェクトされた場合、再申請時のレビューノート(Resolution Center への返信)に次のメッセージを添えると、再審査がスムーズに進みやすくなります。
Thank you for the feedback. We have implemented the Restore Purchases functionality in the Settings screen. The button is located at Settings → Subscription → Restore Purchases. Tapping the button will call StoreKit's getAvailablePurchases() and restore active subscriptions tied to the user's Apple ID. We have tested this with sandbox accounts and confirmed that the user's premium status is correctly re-activated after a fresh install.
具体的な配置場所と動作内容を明記することで、審査担当者が迷わずに再確認できます。「修正しました」だけでは再リジェクトの確率が上がるので、「どこに」「何を」実装したかを必ず添えてください。
関連記事
実装後に他のリジェクト要因も潰しておきたい方は、Rork アプリが App Store 審査で3回リジェクトされた理由と対処法を併せて読むと、よくあるリジェクトパターン全般を把握できます。Privacy Manifest 関連のリジェクト対応はPrivacy Manifest API Declaration リジェクト修正ガイドに詳しくまとめてあります。
StoreKit 2 で本格的にサブスクリプション基盤を作る場合は、Rork Max で StoreKit 2 の In-App Purchase を実装する完全ガイドが、Family Sharing の挙動を含めた本番運用はFamily Sharing で IAP が消える4つのトリガーが参考になります。
書籍で体系的に
全体を振り返って
Restore Purchases は「ボタンを置く」だけでは終わらず、StoreKit から取得した購入情報を根拠にアプリ内の権限フラグを更新するところまでが最低条件です。今日からできる一歩としては、現在のコードで getAvailablePurchases の返り値を AsyncStorage に反映する処理が入っているかを確認してみてください。入っていなければ、上記の実装を参考に1時間以内に修正できます。次の審査リジェクトを未然に防ぐ、最も投資対効果の高い修正のひとつです。