サイトアイコン

toLog

Stripe のサブスクリプションのサンプルを Server Actions で構築してみる

  • 更新日:
  • 投稿日:

この記事は最終更新日から半年以上が経過しています。

はじめに

Stripe のサブスクリプションサンプルを Server Actions で構築してみました。
対象の公式サンプルは、サブスクリプション組み込みの QuickStart です。

構築例

今回の検証結果のリポジトリも用意しています。
雑な検証なので、参考程度に留めていただければと思います 🙏

ディレクトリ構成

Next.js の初期構成に対して payment と言うディレクトリでサブスクリプションの決済処理を組み込んでいます。
Stripe イベントを監視する Webhook は、app/api/payment/webhook に route handler で配置しています。

1tree .
2.
3├── app
4│   ├── api
5│   │   └── payment
6│   │       └── webhook
7│   │           └── route.ts
8│   ├── favicon.ico
9│   ├── globals.css
10│   ├── layout.tsx
11│   ├── page.tsx
12│   └── payment
13│       ├── App.css
14│       ├── actions
15│       │   ├── createCheckoutSession.tsx
16│       │   └── createPortalSession.tsx
17│       └── page.tsx
18└── lib
19    └── stripe
20        └── getServerStripe.ts
21
229 directories, 10 files

準備

.env.localSTRIPE_SECRET_KEY に Stripe の API キーを、STRIPE_WEBHOOK_SECRET に webhook のシークレットを設定してください。

1STRIPE_SECRET_KEY=""
2STRIPE_WEBHOOK_SECRET=""
3
4NEXT_PUBLIC_ORIGIN="http://localhost:3000"

ローカルで Stripe イベントを監視するためにも、Stripe CLI でシミュレーションコマンドを実行しておきます。
今回のサンプルでは、http://localhost:3000/api/payment/webhook がローカルサーバーの Webhook のエンドポイントになります。

1stripe listen --forward-to http://localhost:3000/api/payment/webhook

payment/page.tsx

本家のサンプルと特段大きな変更点はありません。
ので、次のリンクを参照いただければと思います 🙏

payment/actions/createCheckoutSession.tsx

Checkout を実行するための Session 作成の処理です。

本家のサンプルは次です。

1app.post('/create-checkout-session', async (req, res) => {
2  const prices = await stripe.prices.list({
3    lookup_keys: [req.body.lookup_key],
4    expand: ['data.product'],
5  });
6  const session = await stripe.checkout.sessions.create({
7    billing_address_collection: 'auto',
8    line_items: [
9      {
10        price: prices.data[0].id,
11        quantity: 1,
12
13      },
14    ],
15    mode: 'subscription',
16    success_url: `${YOUR_DOMAIN}/?success=true&session_id={CHECKOUT_SESSION_ID}`,
17    cancel_url: `${YOUR_DOMAIN}?canceled=true`,
18  });
19
20  res.redirect(303, session.url);
21});

Server Actions の場合です。

1"use server";
2
3import { redirect } from "next/navigation";
4import { zfd } from "zod-form-data";
5import Stripe from "stripe";
6
7import { getServerStripe } from "@/lib/stripe/getServerStripe";
8
9const Schema = zfd.formData({
10  lookup_key: zfd.text(),
11});
12
13export default async function createCheckoutSesssion(formData: FormData) {
14  let session: Stripe.Checkout.Session | undefined;
15
16  try {
17    const stripe = getServerStripe();
18
19    const { lookup_key } = Schema.parse(formData);
20
21    const prices = await stripe.prices.list({
22      lookup_keys: [lookup_key],
23      expand: ["data.product"],
24    });
25
26    session = await stripe.checkout.sessions.create({
27      billing_address_collection: "auto",
28      line_items: [
29        {
30          price: prices.data[0].id,
31          quantity: 1,
32        },
33      ],
34      mode: "subscription",
35      success_url: `${process.env.NEXT_PUBLIC_ORIGIN}/payment?success=true&session_id={CHECKOUT_SESSION_ID}`,
36      cancel_url: `${process.env.NEXT_PUBLIC_ORIGIN}/payment?canceled=true`,
37    });
38
39    if (!session?.url)
40      throw new Error("Failed to create checkout session url.");
41  } catch (e) {
42    console.error(e);
43    throw e;
44  }
45
46  redirect(session.url);
47}

一部リダイレクト先のパスを変更してますが、本家と大まかな処理は変わりません。
formData のバリデーションは、zod-form-data を利用していますが、バリデーションができれば何でも良いかと思います。

payment/actions/createPortalSession.tsx

カスタマーポータルを開始するための Session 作成の処理です。

本家のサンプルは次です。

1app.post('/create-portal-session', async (req, res) => {
2  const { session_id } = req.body;
3  const checkoutSession = await stripe.checkout.sessions.retrieve(session_id);
4
5  const returnUrl = YOUR_DOMAIN;
6
7  const portalSession = await stripe.billingPortal.sessions.create({
8    customer: checkoutSession.customer,
9    return_url: returnUrl,
10  });
11
12  res.redirect(303, portalSession.url);
13});

Server Actions の場合です。

1"use server";
2
3import { redirect } from "next/navigation";
4import { zfd } from "zod-form-data";
5import Stripe from "stripe";
6
7import { getServerStripe } from "@/lib/stripe/getServerStripe";
8
9const Schema = zfd.formData({
10  session_id: zfd.text(),
11});
12
13export default async function createPortalSession(formData: FormData) {
14  let portalSession: Stripe.BillingPortal.Session | undefined;
15
16  try {
17    const stripe = getServerStripe();
18
19    const { session_id } = Schema.parse(formData);
20
21    const checkoutSession = await stripe.checkout.sessions.retrieve(session_id);
22
23    portalSession = await stripe.billingPortal.sessions.create({
24      customer: checkoutSession.customer as string,
25      return_url: `${process.env.NEXT_PUBLIC_ORIGIN}`,
26    });
27  } catch (e) {
28    console.error(e);
29    throw e;
30  }
31
32  redirect(portalSession.url);
33}

api/payment/webhook/route.ts

Stripe イベントの監視用 webhook の API です。

本家のサンプルは次です。

1app.post(
2  '/webhook',
3  express.raw({ type: 'application/json' }),
4  (request, response) => {
5    let event = request.body;
6    const endpointSecret = 'whsec_12345';
7    if (endpointSecret) {
8      const signature = request.headers['stripe-signature'];
9      try {
10        event = stripe.webhooks.constructEvent(
11          request.body,
12          signature,
13          endpointSecret
14        );
15      } catch (err) {
16        console.log(`⚠️  Webhook signature verification failed.`, err.message);
17        return response.sendStatus(400);
18      }
19    }
20    let subscription;
21    let status;
22    switch (event.type) {
23      case 'customer.subscription.trial_will_end':
24        subscription = event.data.object;
25        status = subscription.status;
26        console.log(`Subscription status is ${status}.`);
27        break;
28      case 'customer.subscription.deleted':
29        subscription = event.data.object;
30        status = subscription.status;
31        console.log(`Subscription status is ${status}.`);
32        break;
33      case 'customer.subscription.created':
34        subscription = event.data.object;
35        status = subscription.status;
36        console.log(`Subscription status is ${status}.`);
37        break;
38      case 'customer.subscription.updated':
39        subscription = event.data.object;
40        status = subscription.status;
41        console.log(`Subscription status is ${status}.`);
42        break;
43      default:
44        console.log(`Unhandled event type ${event.type}.`);
45    }
46    response.send();
47  }
48);

Server Actions の場合です。

1import Stripe from "stripe";
2import { headers } from "next/headers";
3import { NextRequest, NextResponse } from "next/server";
4
5import { getServerStripe } from "@/lib/stripe/getServerStripe";
6
7export async function POST(req: NextRequest) {
8  let event: Stripe.Event | undefined;
9  let subscription: Stripe.Subscription | undefined;
10  let status: Stripe.Subscription.Status | undefined;
11
12  try {
13    const stripe = getServerStripe();
14
15    const signature = headers().get("stripe-signature");
16    if (signature === null) {
17      throw new Error("stripe-signature is null");
18    }
19
20    // WARNING: 生のリクエストボディでないとエラーになる
21    const rawBody = await req.text();
22
23    if (process.env.STRIPE_WEBHOOK_SECRET === undefined) {
24      throw new Error("STRIPE_WEBHOOK_SECRET is undefined");
25    }
26
27    event = stripe.webhooks.constructEvent(
28      rawBody,
29      signature,
30      process.env.STRIPE_WEBHOOK_SECRET
31    );
32  } catch (e) {
33    console.log(`⚠️  Webhook signature verification failed.`, e);
34
35    return new NextResponse("Failed to payment webhook.", {
36      status: 400,
37    });
38  }
39
40  switch (event.type) {
41    case "customer.subscription.trial_will_end":
42      subscription = event.data.object;
43      status = subscription.status;
44      console.log(`Subscription status is ${status}.`);
45      break;
46    case "customer.subscription.deleted":
47      subscription = event.data.object;
48      status = subscription.status;
49      console.log(`Subscription status is ${status}.`);
50      break;
51    case "customer.subscription.created":
52      subscription = event.data.object;
53      status = subscription.status;
54      console.log(`Subscription status is ${status}.`);
55      break;
56    case "customer.subscription.updated":
57      subscription = event.data.object;
58      status = subscription.status;
59      console.log(`Subscription status is ${status}.`);
60      break;
61    default:
62      console.log(`Unhandled event type ${event.type}.`);
63  }
64
65  return new NextResponse("Success.", {
66    status: 200,
67  });
68}

一点注意点として、stripe.webhooks.constructEvent に渡す第一引数の payload は、生のリクエストボディでないとエラーになります。
Github の Issue では、Buffer に変換しているのですが await req.text() のテキストデータを渡すだけでも動作することを確認しています。

おわりに

route handler 側に寄せるパターンもあるのですが、Server Actions によるコロケーションパターンで決済処理の見通しが良くなるなと感じました。
一方で、認証をどう組み込むかも悩みどころなのですが、今回は省略しています 😅


プロフィール画像

canji

とにかく私的にサービスを作りたい発作を起こしている。お腹はペコペコ。

  • toLog Tools icon
  • dots icon