Sveltekit & StripeでWebページに『Embedded Checkout』をマウントさせる方法


※ 当ページには【広告/PR】を含む場合があります。
2024/10/27
DockerとSripe CLIを使ってダッシュボードなしでオンライン決済を操作・管理する
蛸壺の技術ブログ|Sveltekit & StripeでWebページに『Embedded Checkout』をマウントさせる方法

弊社は決済APIとして
『Stripe』を採用しています。

Stripe

通常、Stripeは
『Checkout』とよばれるAPIでstripe側の用意するWEBページでカスタマー決済を行い、決済処理は完了した時点で、元のサイトドメインへリダイレクトされるような手順を踏みます。

設計者の思惑は色々とあるかと思いますが、決済作業はできるだけ自社のドメイン内で行わせたい、というニーズに答える形で登場したのが、
『Embedded Checkout』という機能です。

今回はSveltekitでこの「Embedded Checkout」を統合させたときの作業の内容を防備録として残します。


合同会社タコスキングダム|蛸壺の技術ブログJavascript(js)&Typescript(ts)プログラミング入門〜これから学ぶ人のためのおすすめ書籍&教材の手引き

超JavaScript 完全ガイド 2024

Embedded Checkout(ページ埋め込みCheckout)を使おう

現状、実装しやすいのはStripe「Checkout」で、こちらを長らく採用としてきたStripeユーザーがほとんどかと思います。

この場合、決済するカスタマーは一旦、Stripeの提供する決済ページに直にアクセスする必要がありました。

昨今のセキュリティ事情に敏感なカスタマーからすると、当初アクセスしていたドメイン外へ誘導されるのは、何かしらストレスであり、商品の購入に至る前に離脱される可能性が増して、ちょっとした機会損失のリスクとも捉えられるようになりました。

そこで着目すべきは、「Embedded Checkout」という比較的新しい決済方法です。

Embedded Checkoutは、Checkout Sessionを
ui_mode: 'embedded'として作成し、決済時のReturnされる client_secretの値を使いCheckoutページとしてiframe内で表示することができます。

Sveltekitプロジェクトでの事前準備

SveltekitからEmbedded Checkoutを利用する場合、まずサーバー用とクライアント用の両方のstripeライブラリを導入します。

事始めの前の若干の注意点として、クライアント側では
stripe-jsを使います。

参考|stripe-js

他方、サーバーサイドで利用されるライブラリは、
stripe(node.js API)という名前で区別されています。

参考|stripe

似て非なるものですので、2つを混合して使わないようにしましょう。

では、プロジェクトにこれらをインストールしましょう。

            
            $ yarn add stripe @stripe/stripe-js
        
なお、.envファイルには、Stripe APIのシークレットや、サーバードメインのベースURLなど直接コードに書くのは憚られる変数を仕込んでおきます。

            
            VITE_PUBLIC_BASE_URL=http://hoge.hoge.com
#ローカル(ポート5173)で試す場合には
#VITE_PUBLIC_BASE_URL=http://localhost:5173
VITE_STRIPE_API_KEY=pk_****
VITE_STRIPE_SECRET_KEY=sk_****
        

Embedded Checkoutの実装作業

さて、簡単のために、Sveltekitプロジェクトのsrcフォルダ内で、今回のテーマに関連するファイル構造だけを示すと、以下のようにしています。

            
            src/
├── app.html
├── lib
│   └── components
│        └── CheckoutModalEmbedded.svelte
└── routes
    ├── +layout.svelte
    ├── +page.svelte
    ├── api
    │   └── checkout_embedded
    │         └── +server.ts
    ├── checkout-return
    │   ├── +page.server.ts
    │   └── +page.svelte
    └── shop
        ├── +page.svelte
        └── +page.ts
        
まずはSvelteコンポーネントとしてEmbedded Checkout UIを使うことを念頭に、実装はおおよそ以下のようになります。

            
            <script lang="ts">
    import '@sveltejs/kit';
    import { goto } from '$app/navigation';
    import type { Writable } from 'svelte/store';
    import { getContext } from 'svelte';
    import { loadStripe } from '@stripe/stripe-js';

    //👇簡単な商品情報モデル
    export let product: {id: number, title: string, price: number};

    let checkout: any;
    //👇Checkout処理中かどうかのフラグ
    let isProccessing = false;

    //👇ContextからStripeキー(writableストア)を取得
    const stripe_key: Writable<string> = getContext('stripe_key');
    let sk = '';
    stripe_key.subscribe(value => { sk = value; })

    function closeOutside(event: any) {
        if (event.target != event.currentTarget) { return; }
        close();
    }

    function close() {
        history.back();
    }

    const handleCancel = async () => {
        if (checkout) {
            await checkout.destroy();
        }
        if (isProccessing) {
            isProccessing = false;
        }
        close();
    };

    //👇Embedded Checkout UIを表示して決済を開始させる
    const startCheckout = async () => {
        isProccessing = true;
        try {
            //☆👇決済APIの秘密情報はサーバー側のロジックで処理させる
            const response = await fetch(`/api/checkout_embedded`, {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify(product),
            });

            const { clientSecret } = await response.json();

            if (clientSecret) {
                const stripe = await loadStripe(sk);
                if (checkout) { await checkout.destroy(); }
                checkout = await stripe?.initEmbeddedCheckout({ clientSecret });

                //ここで#checkout要素にEmbedded Checkout UIをマウント
                checkout.mount('#checkout');
            } else {
                console.error("Invalid response data:", clientSecret);
                checkout.unmount();
                isProccessing = false;
            }
        } catch (err) {
            console.error("Error in startCheckout:", err);
            if (checkout) {
                await checkout.destroy();
                isProccessing = false;
            }
        }
    };
</script>

<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div on:click={closeOutside}>
    <h3>この商品を購入しますか?</h3>
    {#if isProccessing}
        <button on:click={handleCancel}>
            キャンセル
        </button>
    {:else}
        <button on:click={startCheckout}>
            購入する
        </button>
    {/if}
</div>

<!--👇Embedded Checkout UIのマウント先 -->
<div id="checkout"></div>
        
このコンポーネントを適当なページで呼び出せば、Embedded Checkoutが表示されるようになります。

まだ残りの機能を実装していないので、順次編集していきます。

決済処理でのセキュアなパートはAPIルートを設けて、サーバー側で処理するようにします。

            
            import Stripe from "stripe";
import { json } from '@sveltejs/kit';

//👇Stripe APIキーによる初期化
const stripe_sk_key = import.meta.env.VITE_STRIPE_SECRET_KEY!
const stripe = new Stripe(stripe_sk_key);

const baseUrl = import.meta.env.VITE_PUBLIC_BASE_URL!;

export async function POST({request, cookie}: any) {
    const { title, price, id } = await request.json();
    try {
        //👇チェックアウトセッションの作成
        const session = await stripe.checkout.sessions.create({
            ui_mode: 'embedded',
            payment_method_types: ["card"],
            metadata: {
                product_id: id,
                issued_date: new Date(new Date().toUTCString()).toISOString()
            },
            //👇テストだけで使うユーザー名(-->本番で使うには別のロジックが必要)
            client_reference_id: "dummy_user",
            line_items: [
                {
                price_data: {
                    currency: "jpy",
                    product_data: {
                        name: title,
                    },
                    unit_amount: price,
                },
                quantity: 1,
                },
            ],
            mode: "payment",
            return_url: `${baseUrl}/checkout-return?session_id={CHECKOUT_SESSION_ID}`,
        });
        return json({clientSecret: session.client_secret});
    } catch (err: any) {
        return json({ message: err.message });
    }
}

export async function GET({ url }: any) {
    const _sid = url.searchParams.get('session_id');
    const session = await stripe.checkout.sessions.retrieve(_sid);
    return json({
        status: session.status,
        customer_email: session.customer_details!.email,
        product_id: session.metadata!.product_id,
        issued_date: session.metadata!.issued_date
    });
}
        
ここではアプリケーション内部から/api/checkout_embeddedルートにPOSTでアクセスすると新規の決済作業が、GETでは決済完了後のセッション情報がそれぞれ分岐して処理されます。

ついで、決済後に返却されるページも必要になります。

            
            <script lang="ts">
    export let data: any;
</script>

{#if data.status === 'complete'}
    <section id="success">
        <p>
        [ご注文日時:{data.issued_date}]
        商品ID({data.product_id})をお買い上げありがとうございました!
        </p>
    </section>
{:else}
    <section id="fail">
        <p>お取引ステータス:[{data.status}]</p>
        <p>何かしらの内部エラーでトランザクションは失敗しました...</p>
    </section>
{/if}
        
また、リターンページに決済結果が返される際の処理は+page.server.tsに記述しておきます。

            
            import type { PageServerLoad } from './$types';

export const load = (async ({ url, fetch }: any) => {
    const sessionId = url.searchParams.get('session_id');

    const response = await fetch(`/api/checkout_embedded?session_id=${sessionId}`, {
        method: "GET",
    });

    return await response.json();
}) satisfies PageServerLoad;
        

適当なルートページでEmmbedded Checkoutコンポーネントを呼び出す

さきほどまでの内容で、Emmbedded CheckoutがSvelteコンポーネントで使えるようになりました。

ここでは一例として、
shopというルートでEmmbedded Checkoutを呼び出してみることにしましょう。

            
            <script lang="ts">
    import { setContext } from 'svelte';
    import { writable } from 'svelte/store';
    import type { PageData } from './$types';
    import CheckoutModalEmbedded from "../lib/component/CheckoutModalEmbedded.svelte";

    export let data: PageData;

    const stripe_key = writable();
    $: stripe_key.set(data.stripe_key);
    //👇子コンポーネントがアクセスできるようにContextに追加
    setContext('stripe_key', stripe_key);
</script>

<div>
    <CheckoutModalEmbedded product={product}/>
</div>
        
この際、Stripe APIの公開キーのほうは+page.tsでクライアント側から取得させてもOKです。

            
            import type { PageLoad } from './$types';

export const load: PageLoad = () => {
    return {
        stripe_key: import.meta.env.VITE_STRIPE_API_KEY ?? ''
    };
}
        

CSP(Content Security Policy)にホワイトリスト登録する

Sveltekitでは、
Cross Site Scripting (XSS)対応として、基本的にクロスドメインを介したスクリプトのやり取りが出来ないようにされています。

参考|SvelteKit Content Security Policy: CSP for XSS Protection

つまり、Stripe側の提供するJSスクリプトを動作させる場合、CSPにStripeオリジンのスクリプトを通過させるように設定しないといけません。

SveltekitでCSPのホワイトリストを追加する場合、
svelte.config.jsを以下のように編集しておきます。

            
            import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';

//...省略

/** @type {import('@sveltejs/kit').Config} */
const config = {
    preprocess: [
        vitePreprocess({ script: true })
    ],
    kit: {
        //...省略
        csp: {
            directives: {
                'script-src': [
                    'self',
                    'https://*.stripe.com',
                ]
            }
        }
    }
};

export default config;
        
これによって、ビルド後にサイトのメタ情報で、stripe側から提供されるスクリプトが許可されることになります。

これですべての設定が完了したら、サーバー起動して動くかどうかをチェックしてみましょう。

独自ドメインにEmbedded Checkoutがマウント出来ていたらOKです。

合同会社タコスキングダム|蛸壺の技術ブログ

これだけでも十分、見栄えがオンラインショップらしくなりました。


合同会社タコスキングダム|蛸壺の技術ブログJavascript(js)&Typescript(ts)プログラミング入門〜これから学ぶ人のためのおすすめ書籍&教材の手引き

超JavaScript 完全ガイド 2024

まとめ

以上、SveltekitにStripeのEmbedded Checkoutを実装するときに注意したいポイントなどを中心に解説していきました。

少し難しく感じられたかもしれませんが、この機会にネットショップを自前でフルスクラッチするのに欠かせない、決済機能を比較的簡単に構築できるため、じっくりと使い方を理解されると良いでしょう。
記事を書いた人

記事の担当:taconocat

ナンデモ系エンジニア

主にAngularでフロントエンド開発することが多いです。 開発環境はLinuxメインで進めているので、シェルコマンドも多用しております。 コツコツとプログラミングするのが好きな人間です。

合同会社タコスキングダム|蛸壺の技術ブログJavascript(js)&Typescript(ts)プログラミング入門〜これから学ぶ人のためのおすすめ書籍&教材の手引き