1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Stripe / JP_StripesAdvent Calendar 2024

Day 16

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

Posted at

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

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

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

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

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

事前準備

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

❯ 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

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

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

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

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

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

ちなみに、[...paths]のようなフォルダを作成すると、/api/hogehoge/fugafugaにマッチするようになります。

src/routes/api/[...paths]/+server.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と命名してあげます。

src/lib/api/app/index.server.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に接続するとOK!と帰ってきます。

npm run dev

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

Stripeで商品設定など

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

image.png

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

image.png

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

image.png

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

image.png

Stripe CLIのセットアップ

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

brew install stripe/stripe-cli/stripe

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

stripe login

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

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

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

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

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

image.png

今回ならprod_RKzfg6gMWnjo4Hになりますね!

このIDを利用してCLIで作成した商品(Product)の情報を取得します!

❯ 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が書かれているので、価格を取得してみます。

❯ 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を付与してみましょう!

❯ 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

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

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

image.png

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

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

PUBLIC_STRIPE_KEY=
SECRET_STRIPE_KEY=
STRIPE_WEBHOOK_SECRET=

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

image.png

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

src/lib/stripe/client.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

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

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

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

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

src/lib/api/server.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

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

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

今回はさほど有効活用できるわけでじゃないので、RPCの話はここまで。

次に、商品を選択すると、埋め込みのチェックアウトが出てくる画面を作ります。

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

<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

success!

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

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

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

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

src/lib/api/app/stripe.server.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

Webhookをイベントを受け取る

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

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

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

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

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

src/lib/api/app/stripe.server.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に設定してあげましょう。

% 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での操作を行ってみてください。
もしくは、別なターミナルを開いて、以下のようにコマンドを実行してみましょう!

% stripe trigger checkout.session.completed

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

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つの話をしてしまいましたが、それぞれとても簡単なのでプロダクトにも簡単に取り込めそうだなーと感じました!

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

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?