個人開発でShopifyアプリを作り、App Storeに公開しました。審査は5回目の提出で承認されました。つまり4回落ちています。
面白い(そして少し怖い)のは、4回とも自動テストは1件も落ちていなかったことです。落ちた原因は毎回、自分のコードの内側ではなく「外部システムとの境界」にありました。
同じ轍を踏む人を減らすため、4回分の指摘と原因、そして最終的にたどり着いた対策を全部書きます。
前提
- アプリ: 日本向けの領収書・インボイス発行アプリ(購入者が宛名を入力して自分でPDFを発行する)
- 構成: Remix(Shopify公式テンプレート) + Cloudflare Workers + D1 + KV
- 配布: 公開配布(App Store掲載)
- 課金: Managed Pricing(無料プラン + Pro $6.99)
1回目: Checkout UI拡張が「注文状況ページ」で描画されない
指摘(要件5.6.1): 注文状況ページに配置したはずのボタンが表示されない。
原因: Checkout UI拡張のターゲット設定です。サンクスページ(purchase.thank-you.block.render)と注文状況ページ(customer-account.order-status.block.render)は別ターゲットで、1つのエントリポイントで両方を賄おうとしていたのが誤りでした。ターゲットごとにエントリを分離して解決しました。
ハマりどころは、コードを直しただけでは終わらない点です。
- Checkoutエディタで、サンクスページと注文状況ページの両方に個別にブロックを配置する必要がある(片方に置いても、もう片方には出ない)
- 注文状況ページの拡張は、未ログイン状態では仕様上表示されない
つまり「自分の環境で表示されない」ときに、コードのバグなのか配置漏れなのかログイン状態なのかの切り分けが必要でした。
同時に要件4.5.4/4.5.5で「テスト手順に認証情報を記載せよ」とも言われました。審査員はテストストアにログインして確認するので、パスワード保護されたストアフロントのパスワードなども書いておく必要があります。
2回目: デモ動画が日本語のみ
指摘(要件4.5.3): デモ動画が日本語のみで、セットアップ手順と課金フローが収録されていない。
日本市場向けアプリなので日本語で作っていましたが、審査員は日本語話者とは限りません。「英語または英語字幕」「セットアップ → コア機能 → 課金フローを段階的に」が要求でした。
作り直しにあたって、動画制作をコード化しました。HTMLで作ったスライドをPuppeteerで自動再生・録画し、npm run screencast の1コマンドで再生成できる形にしています。台本を直せば動画が作り直せるので、その後の仕様変更にも追随できました(実際このあと2回作り直しています)。
技術的な罠: MediaRecorderが出力するWebMはDuration情報とCuesを持たないためシークできません。そのままアップロードすると審査員が動画を巻き戻せない状態になります。ffmpeg -c copy でremuxする工程をパイプラインに組み込んで解決しました。
3回目: 審査員のストアが「INR建て」だった
これがいちばん学びの大きかった指摘です。
指摘(要件2.1.1): アプリがインボイス生成に失敗する。
審査員のスクリーンキャストを見せてもらって原因が確定しました。審査員のテストストアは基本通貨がINR(インドルピー)で、全注文が非JPYでした。当時のアプリは「日本向けだから」という理由でJPY注文以外を弾くガードを入れていたため、審査員の環境では何をしても発行できない状態でした。
さらに動画では、審査員が「この注文で発行」ボタンを3回クリックして諦めていました。ボタンを押すと発行フォームが表示される設計だったのですが、フォームが注文一覧の下(画面外)に描画されていたため、押しても何も起きないように見えていたのです。
教訓は2つ。
- 審査員の環境はコントロールできない。通貨・言語・タイムゾーン・テーマ・プランはすべて相手側の条件です。「対象市場が日本だから」は通用しません。通貨制限のような構造的な制約は、App Storeアプリでは原則として持ち込めないと考えたほうがよいです
-
クリックの結果は画面内に出す。スクロールしないと見えない位置に結果を描画すると「無反応」と判定されます。フォームを一覧の上に移動し、選択時に
scrollIntoViewするようにしました
多通貨対応そのものは、金額表記の方針さえ決めれば難しくありませんでした。日本円は ¥16,940、それ以外は 1,860.36 INR のようにISOコードを後置しています(₹や₩は日本語フォントにグリフがなく、そのまま描画すると豆腐になるためです)。
4回目: 有料 → 無料に戻してもUIが「Pro」のまま
指摘(要件1.2.2): Billing APIの実装が正しくない。Proから無料プランへダウングレードしたのに、アプリのUIが更新後のプランを表示しない。
原因: Managed Pricingは、無料プランでも status: ACTIVE のサブスクリプションを作ります。
判定ロジックが「ACTIVEなサブスクリプションが1件でもあればPro」だったため、無料プランに戻してもProのままになっていました。修正は「課金額 > 0、またはトライアル日数 > 0ならPro」という判定に変更。開発ストアではテスト価格が$0になるため、トライアル日数を保険にしています。
ついでに見つかったもう1つのバグ: プラン選択ページのURLに使うハンドルを、リリースバージョン名(ks-receipt-1)にしていました。正しくはアプリハンドル(ks-receipt)です。「アップグレード」ボタンがプラン選択ページに到達できていませんでした。
なお、承認直後のリダイレクトに付く ?charge_id=...&plan_handle=free|pro は判定に使ってはいけません(クエリは偽装できます)。反映遅延時の再照会トリガーとしてのみ使っています。
共通する構造:落ちた場所はいつも「境界」だった
改めて4回を並べると、こうなります。
| 回 | 指摘 | 落ちた場所 |
|---|---|---|
| 1 | 拡張が描画されない | Shopify管理画面の配置操作とターゲット仕様 |
| 2 | 動画が日本語のみ | 審査員という人間の言語 |
| 3 | 発行できない | 審査員ストアの通貨設定 |
| 4 | プラン表示が古い | Managed PricingのAPI仕様 |
全部、自分のコードの外側です。当時テストは110件ありましたが、1件も落ちませんでした。落ちるはずがありません。テストは自分が想定した世界の中でしか動かないからです。
対策1: 「外形」を機械で検証する
まず、本番URLに対する外形テストを書きました。デプロイスクリプトの最終ステップとして自動実行され、失敗したら異常終了します。
[OK] トップページ: 200 / 雛形なし / 実機能あり / 未実装機能の記載なし
[OK] Webhook 6種: 不正HMACを401で拒否
[OK] App Proxy: 署名なしを400で拒否
[OK] 購入者フロー: 正規署名POSTで実際にPDFを取得(Content-Type/PDFヘッダ/サイズ検証)
[OK] 管理画面が未認証で500やスタックトレースを露出しない
とくに効いたのは**「未実装機能の記載がないこと」を機械で検証**する項目です。構想段階の機能(印影)の記述が審査資料に残っていて、これは要件4.5.4違反になり得ます。人間のレビューでは何度も見落としました。
対策2: 再提出の前に、多角監査を回す
4回目の指摘に対応したあと、再提出の直前に「次に指摘されうる箇所」を洗い出す監査を回しました。課金・エラー処理・リスティング整合・セキュリティ・インストール導線の5観点で洗い出し、各指摘を別のレビュアーに懐疑的に裏取りさせて誤検知を捨てる、という形です。
結果、7件の実在する問題が見つかりました。たとえばこんなものです。
- 4回目の修正が1画面だけ直っていた(ホームは直したが発行履歴画面が無料上限を表示したまま。同一アプリ内で「無制限」と「/15枚」が矛盾していた)
- 実装済みのCheckout拡張を、アプリ内で「準備中」と表示していた(1回目の指摘と同じ機能)
- 公開トップページがRemixの雛形のままだった
- 未処理例外で英語の
Application Errorとスタックトレースが管理画面に露出していた - ハングルやデーヴァナーガリーの宛名が、豆腐ですらなく無言の空白でPDF化されていた(フォントサブセット時に
NOTDEF_OUTLINEを設定していなかった)
正直なところ、この監査をやらずに再提出していたら5回目の差し戻しはほぼ確実でした。
ここから得た運用ルールが2つあります。
- 状態依存の表示は必ず共通コンポーネントにする。プラン表示を画面ごとに書いていたせいで修正漏れが起きました
- 「出ないこと」を検証するときは、「出る」側の陽性対照を併設する。表示されないことのテストは、実装が壊れていても通ってしまいがちです
対策3: テストを4層に分ける
最終的にこうなりました。
- ユニット— 計算・判定ロジック
- 統合(25件)— 実D1・実KV・workerd実行。発行フロー、無料枠の境界、同時発行の競合、GDPR削除まで
-
PDF内容の回帰 — 生成したPDFから
pdftotextでテキストを抽出し、通貨別表記・軽減税率注記・ギフト時の金額非表示を機械検証(毎回の目視をやめられました) - 本番外形スモーク(16項目)— デプロイ後に自動実行
4層に増やしても、今回落ちた4件のうち機械検出できるのは限られています。だからこそ外部境界は「人間が別視点で疑う」工程が必要でした。
おわりに
「4回落ちた」と書くと失敗談に見えますが、審査員の指摘はいずれも実際のマーチャントが遭遇する問題でした。INR建てのストアで発行できないアプリも、プラン変更が反映されないアプリも、本当に困るのはお金を払う人です。
Shopifyアプリの審査に挑む方は、提出前にこれだけは確認してみてください。
- 自分のストアの通貨・言語・プランを変えても動くか
- 課金プランを下げたときにUIが追随するか
- 拡張の配置手順がアプリ内に書いてあるか
- リスティングとテスト手順に、実装していない機能が書かれていないか
質問があればコメントでどうぞ。