STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

現在のページ状態を保持したまま別ウィンドウで決済を行う

こんにちは。STORES で Webエンジニアをしております、takeuchiです。

Webアプリケーションで決済を提供する場合、ユーザーはアプリから決済代行業者 (PSP) が用意した決済画面へ遷移します。 この決済画面でユーザーが支払い情報を入力し、クレジットカード会社や銀行などの実際の決済サービスと通信して処理が行われます。支払い処理が完了すると、決済代行業者はその結果を保持し、ユーザーを再びアプリ側の指定された画面(例:完了画面や結果通知画面)へ自動的に戻します。

ただ今回実装していたページではブラウザのページ遷移を伴う決済フローを組み込むにあたって、次のような課題がありました。

  • 決済画面へ遷移すると、ページ内のステート(フォーム入力値など)は失われる
  • 一方で、このページでは決済前にユーザーが多くの情報を入力しており、決済完了後もその入力内容を前提とした後続の操作を行う必要があった

入力値を保持しつつ決済フローを行うための方法の検討

ユーザーが決済を開始すると、通常は決済代行業者が用意した決済画面に遷移します。しかしページ遷移が発生すると、元ページに保持していた入力値は消えてしまいます。

これを防ぐために、

  • localStorage や DB に入力値を保存しておき、決済後に復元する

といった方法も検討しました。

しかし入力項目が多く、依存関係も複雑といった事情があり、入力値を保存・復元するアプローチは実装コストがかかります。

そこで、元のページは残したまま、決済処理だけ別ウィンドウで行うという方法を採用しました。

別ウィンドウで処理が完結すれば、 元ページの状態は一切変わらず保持されるため、 「入力値が消える問題」を根本的に回避できます。

決済フローを別ウィンドウで実行する

処理の流れをシンプルに分解すると以下のようになります。

  • window(元ページ)
    ユーザーが入力した情報を保持したままpaymentWindowを開き待機する

  • paymentWindow(決済ウィンドウ)
    決済画面に遷移し、callback を受け取り、
    最終的に決済サービス側の処理の完了するとwindow に postMessage を送信する

これにより、元ページは遷移せず入力値を保持したまま、 決済結果だけを受け取って処理を続行できます。

以下は全体をシーケンス図で表したものです。

sequenceDiagram
    participant W1 as 元ページ(window)
    participant W2 as 決済ウィンドウ(paymentWindow)
    participant Server as バックエンド
    participant EXT as 決済代行業者

    Note over W1: ユーザーがフォーム入力中

    %% --- 決済開始 ---
    W1->>W1: 「決済する」をクリック
    W1->>W2: window.open()

    W1->>Server: 決済開始
    Server->>EXT: 決済代行業者へリクエスト
    EXT->>Server: redirectUrl を返す
    Server->>W1: redirectUrl を返す

    %% --- popup ウィンドウで決済 ---
    W1->>W2: paymentWindow.location.href = redirectUrl
    W2->>EXT: redirectUrl へ遷移

    Note over EXT: ユーザーが決済画面で操作

    EXT->>W2: callbackUrl へリダイレクト

    %% --- 決済完了後の通知 ---
    W2->>W1: postMessageで決済代行業者側の処理が完了したことを通知する
    W2->>W2: close

    %% --- 決済の最終確認 ---
    W1->>Server: 決済確定
    Server->>EXT: 決済確定処理またはステータス確認
    EXT->>Server: succeeded / failed
    Server->>W1: 最終結果を返す

    %% --- UI更新 ---
    W1->>W1: 決済成功/失敗のUI更新

元ページ(window)側の実装例

export default function PaymentButton() {
  const handleClick = async () => {
    const paymentWindow = window.open("about:blank", "_blank");

    if (!paymentWindow) {
      alert("ポップアップがブロックされた可能性があります");
      return;
    }

    // 決済開始 API
    const response = await fetch("/api/payment/start", { method: "POST" });
    const { redirectUrl } = await response.json();

    paymentWindow.location.href = redirectUrl;

    // postMessage を待つ
    const { externalSessionId } = await new Promise<{
      externalSessionId: string;
    }>((resolve) => {
      const handler = (event: MessageEvent) => {
        // メッセージの送信元 window の検証
        if (event.source !== paymentWindow) return;
        // メッセージの送信元 origin の検証
        if (event.origin !== window.location.origin) return;

        const data = event.data;
        if (!data || data.type !== "payment:returned") return;

        window.removeEventListener("message", handler);
        resolve(data);
      };

      window.addEventListener("message", handler);
    });

    // 決済最終確認
    const confirmResponse = await fetch("/api/payment/confirm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ externalSessionId }),
    });

    const { status } = await confirmResponse.json();

    if (status === "succeeded") {
      // 決済成功時の処理
    } else {
      // 決済失敗時の処理
    }
  };

  return (
    <button onClick={handleClick} className="px-4 py-2 bg-blue-600 text-white">
      決済する
    </button>
  );
}

※追記:元ページ(window)側の実装例で event.origin の検証が漏れていたため、event.origin の検証を行うよう修正しました。

決済ウインドウ(paymentWindow)側の処理

postMessage の第二引数の targetOrigin はイベントを配信するウインドウのオリジンを指定します。 イベントが配信されるためには、送信先とオリジンが完全に一致する必要があります。

この仕組みにより、悪意のあるサイトにデータが送信されることを防ぐことができます。

"use client";

import { useEffect } from "react";

export default function PaymentCallbackPage() {
  useEffect(() => {
    const url = new URL(window.location.href);
    const externalSessionId = url.searchParams.get("session_id");

    if (externalSessionId) {
      window.opener.postMessage(
        {
          type: "payment:returned",
          externalSessionId,
        },
        window.location.origin
      );
    }

    window.close();
  }, []);

  return (
    <div className="flex flex-col items-center justify-center h-screen">
      <h1 className="text-xl font-semibold">決済中です...</h1>
      <p className="text-gray-600 mt-2">ウィンドウは自動的に閉じられます。</p>
    </div>
  );
}

発生した問題

実装中に「Safari では window.open がポップアップブロックされ開けないが、Chrome では問題なく開く」という挙動に遭遇しました。

HTML Standard には「ユーザーのアクティベーション状態(user activation)」を追跡する仕組みが定義されています。 ユーザーにとって迷惑なポップアップの表示などを防ぐため、ユーザーがそのページを操作した直後だけ特定の API を許可します。

html.spec.whatwg.org

この仕様では、

  • transient activation
  • sticky activation

という概念を定義しており、window.open() などの特定の API は transient activation を持つ状態でしか呼ぶことはできないとされています。 これはクリック直後でしかポップアップは開けないという挙動を規定するものです。

クリックなどのユーザー操作から一定時間だけ有効な状態(transient activation)が生まれ、この状態でなければ window.open() などのAPIはブロックされます。

developer.mozilla.org

transient activation は一定の時間が過ぎると期限切れになります。 ブラウザによって挙動が変わる原因はこのタイムアウトの時間が各ブラウザによって異なる時間が設定されているためでした。

このポップアップブロックの実装では、

  1. ユーザーが「決済する」をクリック
  2. バックエンドへ決済開始 API を呼び出す
  3. API のレスポンスを受けてから window.open()を実行

という流れでした。

Safari では 2 の通信待機中に transient activation が失効してしまい、 クリックからわずかに遅れたタイミングで window.open を呼んでしまうことでブロックされていました。

解決策:クリック直後にwindow.open() を実行しておく

最終的には user activation のタイムアウトが起きないように、クリック直後にウインドウを開くことで解決しました。

const paymentWindow = window.open("about:blank"); // クリック直後に呼ぶ
const response = await fetch("/api/payment/start"); 
paymentWindow.location.href = redirectUrl;

まとめ

今回の実装では、決済フローを別ウィンドウに切り離すことで、 元ページの入力内容をそのまま保ちながら決済代行業者と連携できるようになりました。 少し癖はありますが、 「ページ遷移を挟む外部サービスを利用しつつ、元のページ状態を保持したい」 という場面では有効なアプローチかと思います。