SvelteKitとHonoを使って、Stripeの埋め込みチェックアウトとWebhookイベントの受け取る！

最近、決済処理が必要で**Stripe**を使い始めました！

受託開発多めなのであんまり決済周りの処理はやってこなかったんですが、利用する機会があったので一通り触ってみました。便利ですねー！

弊社、**地図屋**なので一般Webというよりは**特殊なデータ処理**とか**WebGL/Three.js**を触りがちなんですが、最近はモダンな**SvelteKit**と**Hono**をめちゃ推していってるので、今回はこれらを利用して以下の課題に取り込んでみようと思います！

- **SvelteKitにHonoを乗せる構成でフロントとバックエンドを組む**
- **Stripeの埋め込みチェックアウトを作成する**
- **StripeのWebhookイベントの受け取り**

イベントを受け取った後はDBの操作などなど行うと思いますが、この記事ではそこまで踏み込みません！

## 事前準備

まず、SvelteKitでプロジェクト作成していきましょう！
今回はTypeScriptを使っていきます。

```sh
❯ npx sv create svelte-stripe-sample
Need to install the following packages:
sv@0.6.6
Ok to proceed? (y) y

┌  Welcome to the Svelte CLI! (v0.6.6)
│
◇  Which template would you like?
│  SvelteKit minimal
│
◇  Add type checking with Typescript?
│  Yes, using Typescript syntax
│
◆  Project created
│
◇  What would you like to add to your project? (use arrow keys / space bar)
│  prettier, eslint, vitest
│
◇  Which package manager do you want to install dependencies with?
│  npm
│
◆  Successfully setup add-ons
│
◇  Installing dependencies with npm...
◆  Successfully installed dependencies
│

◇  Successfully formatted modified files
```

必要なパッケージのインストールしていきます。

```sh
npm i stripe @stripe/stripe-js hono zod @hono/zod-validator
```

SvelteKitでは**SSRを利用することができ**、尚且つ**`/api`のルートからAPIを配信**することができます。
さらに、このルートで**Honoを動かす**とかもできちゃいます。

- 参考
    - [SveltekitのAPI routesでHonoを利用する](https://qiita.com/Kanahiro/items/b109d944f09afd02e57f)

なので今回はこれを利用していきましょう！

まずは、`api`ディレクトリ以下に`+server.ts`を作成し、リクエストをapp（あとで作成するHonoアプリケーション）に流していきます。

ちなみに、`[...paths]`のようなフォルダを作成すると、`/api/hogehoge/fugafuga`にマッチするようになります。
- 参考: https://kit.svelte.jp/docs/advanced-routing#rest-parameters

`src/routes/api/[...paths]/+server.ts`

```ts
import app from "$lib/api/server";
import type { RequestHandler } from "@sveltejs/kit";

export const GET: RequestHandler = ({ request }) => app.fetch(request);
export const POST: RequestHandler = ({ request }) => app.fetch(request);
```

Honoのアプリケーションはこんな感じで作成します。
フォルダ構成やファイル名は適当ですが、とにかくサーバーでのみ動作するように、`.server.ts`と命名してあげます。
- 参考: https://kit.svelte.jp/docs/server-only-modules#your-modules

`src/lib/api/app/index.server.ts`

```ts
import { Hono } from "hono";
import stripe from "./stripe.server";

const health = new Hono().get("/", async (c) => {
	return c.text("OK!");
});

export default api;
```

サーバーを起動させて[http://localhost:5173/api](http://localhost:5173/api)に接続すると`OK!`と帰ってきます。

```sh
npm run dev
```

超簡単にSvelteKit上でHonoが動作しました！

## Stripeで商品設定など

Stripeではダッシュボードからサンドボックス環境を立ち上げて色々実験できるので、この環境を利用しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/019e8822-d32e-e6c6-69aa-1d7ad04e8d39.png)

適当に商品を作成してみましょう。
Stripeの商品登録の詳細などはちょっと端折りますが、今回は500円/月と300円/月のサブスクリプションを用意してみました。
適宜ダッシュボードから設定してください！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/89f2821d-580e-6576-735f-de4b01176d51.png)

（今回は利用しませんでしたが）サブスクのプランをユーザーが自由に変更できるようにするには、設定画面から「**ユーザーのサブスク変更**」を許可しておく必要があります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/1a457b2f-3ae7-eb07-6825-bfe8d5003979.png)

APIキーなどはトップページに記載があるので、`.env`に設定しておきましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/134e8ed8-6622-fa15-467a-eeab58072c22.png)

## Stripe CLIのセットアップ

Stripeは便利なCLIツールが用意されているので、インストールしていきましょう！
以下はmacOSでのコマンドですが、他のOSでも調べればすぐに出てくると思います。

```sh
brew install stripe/stripe-cli/stripe
```

以下のコマンドを入力するとブラウザが立ち上がり、パスコードなど入力するとcliでログインできます。


```sh
stripe login
```

CLIを使ってみましょう！
アプリケーションでStripeを利用する際には、lookup_keyを設定し、lookup_keyを検索することで動的に最新の価格などを取得することができるようです。

- **商品（product）とは**
    - 顧客に提供する特定の商品やサービス
    - スタンダードプランとプロフェッショナルプランがあるなら、別の商品
- **価格（price）とは**
    - 商品の定期購入や一回限りの購入の両方について請求する価格
    - 月額・年額・1回限りなどは、1つの商品で、価格が異なる感じ

デフォルトではlookup_keyが存在しないので、CLIを利用して付与していきましょう！

- **lookup_key**
    - 静的な文字列で、nullable
    - 動的に価格を取得するために利用される参照キー
    - アカウントでユニークらしい

まずはダッシュボードから商品のIDを取得しておきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/30b20904-aeb5-ea65-520c-396fc1ab610d.png)

今回なら`prod_RKzfg6gMWnjo4H`になりますね！

このIDを利用してCLIで作成した商品（Product）の情報を取得します！

```sh
❯ stripe products retrieve prod_RKzfg6gMWnjo4H
{
  "id": "prod_RKzfg6gMWnjo4H",
  "object": "product",
  "active": true,
  "attributes": [],
  "created": 1733322977,
  "default_price": "price_1QSJgbGdlPm03DeKRmLmmKbO",
  "description": null,
  "images": [],
  "livemode": false,
  "marketing_features": [],
  "metadata": {},
  "name": "のこのこスタンダード",
  "package_dimensions": null,
  "shippable": null,
  "statement_descriptor": null,
  "tax_code": null,
  "type": "service",
  "unit_label": null,
  "updated": 1733322977,
  "url": null
}
```

`default_price`に商品のデフォルト価格のIDが書かれているので、価格を取得してみます。

```sh
❯ stripe prices retrieve price_1QSJgbGdlPm03DeKRmLmmKbO
{
  "id": "price_1QSJgbGdlPm03DeKRmLmmKbO",
  "object": "price",
  "active": true,
  "billing_scheme": "per_unit",
  "created": 1733322977,
  "currency": "jpy",
  "custom_unit_amount": null,
  "livemode": false,
  "lookup_key": null,
  "metadata": {},
  "nickname": null,
  "product": "prod_RKzfg6gMWnjo4H",
  "recurring": {
    "aggregate_usage": null,
    "interval": "month",
    "interval_count": 1,
    "meter": null,
    "trial_period_days": null,
    "usage_type": "licensed"
  },
  "tax_behavior": "unspecified",
  "tiers_mode": null,
  "transform_quantity": null,
  "type": "recurring",
  "unit_amount": 300,
  "unit_amount_decimal": "300"
}
```

この価格IDを利用して、価格オブジェクトにlookup_keyを付与してみましょう！

```sh
❯ stripe prices update price_1QSJgbGdlPm03DeKRmLmmKbO \
  -d "lookup_key"="nokonoko_std_per_month"
{
  "id": "price_1QSJgbGdlPm03DeKRmLmmKbO",
  "object": "price",
  "active": true,
  "billing_scheme": "per_unit",
  "created": 1733322977,
  "currency": "jpy",
  "custom_unit_amount": null,
  "livemode": false,
  "lookup_key": "nokonoko_std_per_month",
  "metadata": {},
  "nickname": null,
  "product": "prod_RKzfg6gMWnjo4H",
  "recurring": {
    "aggregate_usage": null,
    "interval": "month",
    "interval_count": 1,
    "meter": null,
    "trial_period_days": null,
    "usage_type": "licensed"
  },
  "tax_behavior": "unspecified",
  "tiers_mode": null,
  "transform_quantity": null,
  "type": "recurring",
  "unit_amount": 300,
  "unit_amount_decimal": "300"
}
```

付与されましたね！もう一個の商品も同じことをしておきましょう！

## チェックアウトの実装

こんな感じのStripe組み込み決済画面を「チェックアウト」と呼ぶらしいです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/0bd674cb-e36b-0a8b-ff4b-d84b799aedab.png)

**アプリケーションのドメインから遷移せずに、画面に埋め込むタイプのチェックアウト**もあるので、これを実装していきましょう！

まずは一旦サブスク決済のフローを確認してみました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/ca0f304f-228b-00c7-3cb3-81b2c7c21dce.png)

ではやっていきましょう！

先ほども少し言及しましたが、まずはリポジトリのルートに`.env`を作成しておきましょう。

```text
PUBLIC_STRIPE_KEY=
SECRET_STRIPE_KEY=
STRIPE_WEBHOOK_SECRET=
```

`PUBLIC_STRIPE_KEY`と`SECRET_STRIPE_KEY`はダッシュボードのこれを設定できます。
`STRIPE_WEBHOOK_SECRET`はWebhookの章で後述します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/b3056054-e418-a8ca-a9e7-70883f17d699.png)


クライアント側のStripeのインスタンスを作ります。

`src/lib/stripe/client.ts`

```ts
import { PUBLIC_STRIPE_KEY } from "$env/static/public";
import { loadStripe } from "@stripe/stripe-js";

async function createStripeClient() {
	return await loadStripe(PUBLIC_STRIPE_KEY);
}

const stripeClient = await createStripeClient();

export default stripeClient;
```

次に、サーバー側のStripeのインスタンスを作ります。

ややこしいですが`@stripe/stripe-js`がクライアント用のライブラリで、`stripe`がサーバー用のライブラリです。

`src/lib/api/app/stripe.server.ts`

```ts
import { env } from "$env/dynamic/private";
import Stripe from "stripe";

export const stripe = new Stripe(env.SECRET_STRIPE_KEY || "");
```

HonoはRPCを利用し、フロントエンドとバックエンドでAPIの仕様を共有しながら開発することができます。
（TypeScriptで表も裏も書いちゃうメリットですね！）

- 参考
    - [見よ、これがHonoのRPCだ](https://zenn.dev/yusukebe/articles/a00721f8b3b92e)
    - [RPC](https://hono.dev/docs/guides/rpc)

まず、サーバー側は普通にHonoのインスタンスを作成し、`export type AppType = typeof app;`型情報を出力します。

`src/lib/api/server.ts`

```ts
import { Hono } from "hono";
import api from "./app/index.server";

const app = new Hono().route("/api", api);
export default app;

export type AppType = typeof app;
```

クライアントはそれを利用してhcを返す関数を書くことで、フロントエンドから利用することが出来るようになります！

`src/lib/api/client.ts`

```ts
import { hc } from "hono/client";
import type { AppType } from "./server";

export function makeApiClient(fetch: typeof global.fetch) {
	return hc<AppType>("/api", { fetch });
}
```

今回はさほど有効活用できるわけでじゃないので、RPCの話はここまで。
 
次に、商品を選択すると、埋め込みのチェックアウトが出てくる画面を作ります。
- 参考: [Sveltekit & StripeでWebページに『Embedded Checkout』をマウントさせる方法](https://deep.tacoskingdom.com/blog/261)

今回は商品情報は直接ソースコードに書いてしまいました。

```ts
<script lang="ts">
	import { makeApiClient } from "$lib/api/client";
	import stripeClient from "$lib/stripe/client";
	import "@sveltejs/kit";

	type product = {
		id: string;
		title: string;
		lookup_key: string;
	};
	const products: product[] = [
		{
			id: "prod_RKzfg6gMWnjo4H",
			title: "のこのこスタンダード",
			lookup_key: "nokonoko_std_per_month",
		},
		{
			id: "prod_RKzf45ctmQfvLd",
			title: "のこのこスペシャル",
			lookup_key: "nokonoko_special_per_month",
		},
	];

	let checkout: any;
	let isProcessing = false;

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

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

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

	const startCheckout = async (product: product) => {
		isProcessing = true;
		const client = makeApiClient(fetch);
		try {
			const response = await client.stripe.checkout.$post({
				json: product,
			});
			const resJson = await response.json();

			const clientSecret = resJson.clientSecret;

			if (clientSecret) {
				if (checkout) {
					await checkout.destroy();
				}
				checkout = await stripeClient?.initEmbeddedCheckout({ clientSecret });

				checkout.mount("#checkout");
			} else {
				isProcessing = false;
			}
		} catch (err) {
			if (checkout) {
				await checkout.destroy();
				isProcessing = false;
			}
		}
	};
</script>

<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
{#each products as product}
	<div on:click={closeOutside}>
		<h3>{product.title}を購入しますか？</h3>
		{#if isProcessing}
			<button on:click={handleCancel}> キャンセル </button>
		{:else}
			<button on:click={() => startCheckout(product)}> 購入する </button>
		{/if}
	</div>
{/each}

<div id="checkout"></div>
```

チェックアウトが完了した後のページを作ります。今回は`success!`と表示されるだけの画面にします。

`src/routes/success/+page.svelte`

```ts
success!
```

フロントエンドからリクエストがあったタイミングでチェックアウトのためのを`client_secret`返すAPIを作成します。

フロントエンドからlookup_keyを受け取り、StripeのSDKを利用して商品の価格IDを取得します。

その後、sessionを作成してフロントエンドに返します。

チェックアウトには「**どのユーザーがサブスクリプションをしたのか**」わかるように**customerId**が必要ですが、今回はダミーのemailと適当なidを用意し、チェックアウトすることにしました。

`src/lib/api/app/stripe.server.ts`

```ts
import { env } from "$env/dynamic/private";
import { Hono } from "hono";
import Stripe from "stripe";

export const stripe = new Stripe(env.SECRET_STRIPE_KEY || "");

const app = new Hono();
app.post("/checkout", async (c) => {
	const { lookup_key } = await c.req.json();
	const url = new URL(c.req.url);
	const protocol = url.protocol;
	const host = url.host;

	const prices = await stripe.prices.list({
		lookup_keys: [lookup_key],
	});
	const priceId = prices.data[0].id;

	const email = "nokonoko@example.com";
	let customerId = "dummy_user";

	try {
		await stripe.customers.retrieve(customerId);
	} catch {
		const customer = await stripe.customers.create({
			email,
		});
		customerId = customer.id;
	}

	try {
		const session = await stripe.checkout.sessions.create({
			ui_mode: "embedded",
			customer: customerId,
			payment_method_types: ["card"],
			client_reference_id: "dummy_user",
			line_items: [
				{
					price: priceId,
					quantity: 1,
				},
			],
			mode: "subscription",
			return_url: `${protocol}//${host}/success?session_id={CHECKOUT_SESSION_ID}`,
		});

		return c.json({ clientSecret: session.client_secret! });
	} catch (err) {
		console.error(err);
		return c.json({ message: "error" });
	}
});

export default app;
```

http://localhost:5173 に接続してみましょう！

「のこのこスタンダード」の「購入する」ボタンを押すと300円の決済画面が出てきて、もう一方だと設定した通り500円の画面が出てくると思います！

すげー！！！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/203944/bfa076e3-6228-a229-85db-b1f2749161c4.png)

## Webhookをイベントを受け取る

チェックアウトからサブスクリプションを申し込むと、作成したsuccess画面に遷移します。
このタイミングでStripe本体には「**〇〇さんが〇〇プランの決済が完了**」といった情報が格納されるため、入力したクレジットカードなどから毎月決済されます。

が、これだけだとただ**課金されるだけで自分らで作成したアプリケーション側には何の情報も反映されません**ね。

StripeではWebhookを設定することができるため、`/api/stripe/webhook`新たなエンドポイントを作成しStripeに設定してあげましょう。

こうすることで「決済完了」や「プラン変更」「解約」などさまざまなイベントを受け取り、サービス内のDBを更新する、といったことが可能となります。

ではエンドポイントを作成します。

`src/lib/api/app/stripe.server.ts`

```ts
import { env } from "$env/dynamic/private";
import { Hono } from "hono";
import Stripe from "stripe";

export const stripe = new Stripe(env.SECRET_STRIPE_KEY || "");

const app = new Hono();
app.post("/checkout", async (c) => {
	const { lookup_key } = await c.req.json();
	const url = new URL(c.req.url);
	const protocol = url.protocol;
	const host = url.host;

	const prices = await stripe.prices.list({
		lookup_keys: [lookup_key],
	});
	const priceId = prices.data[0].id;

	const email = "nokonoko@example.com";
	let customerId = "dummy_user";

	try {
		await stripe.customers.retrieve(customerId);
	} catch {
		const customer = await stripe.customers.create({
			email,
		});
		customerId = customer.id;
	}

	try {
		const session = await stripe.checkout.sessions.create({
			ui_mode: "embedded",
			customer: customerId,
			payment_method_types: ["card"],
			client_reference_id: "dummy_user",
			line_items: [
				{
					price: priceId,
					quantity: 1,
				},
			],
			mode: "subscription",
			return_url: `${protocol}//${host}/success?session_id={CHECKOUT_SESSION_ID}`,
		});

		return c.json({ clientSecret: session.client_secret! });
	} catch (err) {
		console.error(err);
		return c.json({ message: "error" });
	}
});

// ここから追加！
app.post("/webhook", async (c) => {
	const sig = c.req.header("stripe-signature");
	const body = await c.req.text();

	if (!sig) {
		return c.json({ message: "" }, { status: 400 });
	}

	try {
		// `/webhook`は誰でもリクエストできるので、stripeからのリクエストであることを検証する
		const event = stripe.webhooks.constructEvent(
			body,
			sig,
			env.STRIPE_WEBHOOK_SECRET,
		);

		switch (event.type) {
			case "payment_intent.created": {
				console.log(event.data.object);
				break;
			}
			default:
				break;
		}
		return c.json({ message: "" }, { status: 200 });
	} catch (err) {
		const errorMessage = `Webhook signature verification failed. ${
			err instanceof Error ? err.message : "Internal server error"
		}`;
		console.log(errorMessage);
		return c.json({ message: "" }, { status: 400 });
	}
});

export default app;
```

今回はAPIの個数が2つなので実装しませんでしたが、普通は認証・認可の機能などを持つ認証ミドルウェアを実装すると思います。

ただ、`/webhook`のエンドポイントは**外部（Stripe）からリクエストが飛んでくるので認証処理を挟むことができません**。

なので、Stripeから送信される「**生のリクエスト**」を設定されたシークレットを利用して検証することで、Stripeからの**正規のリクエストであることを保証**します。
（ちなみに、**「生のリクエスト」が重要らしく`OpenAPIHono`は利用できなさそう**でした。）

本番環境ではStripeのダッシュボードからWebhook受け取りエンドポイントのURLを設定し、尚且つシークレットも取得してきますが、ローカル環境でも開発することができます。

Stripe CLIを利用して、以下のようにlocalhostのエンドポイントをフォワードするように設定しましょう。

`whsec_`から始まる文字列がシークレットなので`STRIPE_WEBHOOK_SECRET`に設定してあげましょう。

```sh
% stripe listen --forward-to http://localhost:5173/api/stripe/webhook
> Ready! You are using Stripe API Version [2024-11-20.acacia]. Your webhook signing secret is whsec_xxxxxxxxxxxxxxxxxxxxxxxxxxxx (^C to quit)
```

この状態で`stripe listen`が起動したまま、もう一度サブスクするなり、Stripeでの操作を行ってみてください。
もしくは、別なターミナルを開いて、以下のようにコマンドを実行してみましょう！

```sh
% stripe trigger checkout.session.completed
```

すると ターミナルにこんな画面が表示されているはずですね！

```sh
2024-12-05 12:26:20   --> invoice.payment_succeeded [evt_1QSVhoGdlPm03DeKPMG23y7C]
2024-12-05 12:26:20  <--  [200] POST http://localhost:5173/api/stripe/webhook [evt_1QSVhoGdlPm03DeKPMG23y7C]
2024-12-05 12:36:41   --> customer.created [evt_1QSVrpGdlPm03DeKvxAhZFZj]
2024-12-05 12:36:41  <--  [200] POST http://localhost:5173/api/stripe/webhook [evt_1QSVrpGdlPm03DeKvxAhZFZj]
```

ローカルホストでもWebhookのイベント受け取れました！
eventの種類が受け取れるので、イベントに応じてDBを更新し、課金プランを切り替えるなどすればOKです！

## まとめ

ということでタイトルにある通り「**SvelteKitとHonoを使って、Stripeの埋め込みチェックアウトとWebhookイベントの受け取る！**」というところまでやっていきました！

「**SvelteKitでHonoを利用する**」という話と「**Stripeを利用してチェックアウトを機能させる**」と「**StripeのWebhookイベントをHonoで受け取る**」という3つの話をしてしまいましたが、それぞれとても簡単なのでプロダクトにも簡単に取り込めそうだなーと感じました！

これからも諸々使い倒していきたいです！