🏦

【最新版】もう迷わない!Stripeを使用したサブスク実装方法(Next.js, TypeScript)

2024/05/17に公開

個人開発でStripeの実装がスムーズにいったので解説する

バージョンの違いやAPIの実装などで初心者の方は決済機能の実装がうまくいかないことが多々あると思います。自分も最初エラーが出まくったりしていたのですが、この記事の手順でやればほぼ無問題で実装からテストまでいけたので手順を追って解説したいと思います。

また、ユーザーの認証関係の解説は省いていますのでご了承ください。

DBはNeon、ORMはDrizzleを使用しています。

それぞれの参考記事
https://qiita.com/nuko-suke/items/e940ff8ccde35d09f668
https://qiita.com/tsukasaI/items/bbfac8f4319981d2bcaf

手順1 lib/stripe.tsの作成

import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_API_KEY!, {
    apiVersion: "2024-04-10", // ここは必ず最新verにする
    typescript: true
})

注意点!
apiVersion: "2024-04-10"の部分は必ず最新のバージョンを指定するようにしてください。
Stripeの公式リファレンスで確認できます。ここが最新になっていないとエラーが発生する可能性が高いです。

手順2 STRIPE_API_KEYを取得して.envに追記

ここからはStripeのサイトにってAPIキーを取得します

STRIPE_API_KEYの取得方法

  1. まずは以下のリンクからアカウントを作成しましょう
    すでにアカウントを持っている方はダッシュボードからstripeを使用するプロジェクトアカウントを作成します。
    https://stripe.com/jp

  2. 新規のプロジェクトを作成します

  1. プロジェクトアカウントが作成されたら開発者向けAPIKeyからシークレットキーを取得します。


  2. 取得したシークレットキーを.envファイルに追加しましょう

STRIPE_API_KEY="sk_test_51PEkdapsoiuhaomfaizTYlc5Nqnv12qhIpL4DoxqeIMK4lNYVWEkwLfSF5v1FFqQZJkbLasd;kjmopkpoae3T6IEyg"

注意点!
STRIPE_API_KEYは他人に見せないようにしましょう。上記のコードは記事用にテキトーに書いたものです。

手順3 lib/utils.ts作成と.envにURL追記

utils.ts
absoluteUrl()の実装

export function absoluteUrl(path: string) {
  return `${process.env.NEXT_PUBLICK_APP_URL}${path}`
}

.env

NEXT_PUBLICK_APP_URL="http://localhost:3000" // 追記
STRIPE_API_KEY="sk_test_51PEkdapsoiuhaomfaizTYlc5Nqnv12qhIpL4DoxqeIMK4lNYVWEkwLfSF5v1FFqQZJkbLasd;kjmopkpoae3T6IEyg"

absoluteUrl()の解説

絶対URLの作成

Webアプリケーションでは、URLはサーバーやクライアントの実行環境によって異なる可能性があります。たとえば、ローカル環境ではhttp://localhost:3000が使用され、本番環境ではhttps://www.example.comなどのドメインが使用されます。

このabsoluteUrl関数で、そのような環境に依存しない絶対URLを生成しておきます。
相対URLでは、サービス側でURLを適切に解決できない可能性があるためです。

例えば、/shopという相対URLだけでは、アプリケーションがどのホストで実行されているかわかりません。そのため、NEXT_PUBLIC_APP_URLを付与することで、完全な絶対URLを生成できます。

例)
ローカル環境: http://localhost:3000/shop
本番環境: https://www.example.com/shop

このように、環境に依存しない絶対URLを生成することで、アプリケーションがどの環境で実行されていても、URLが適切に解決されるという感じです。

まとめると、absoluteUrl関数ではNEXT_PUBLIC_APP_URLを含めて、環境に依存しない絶対URLを生成して、これによりStripeなどの外部サービスと確実に統合できるという流れです。

手順4 ユーザーのサブスクリプションschemaを設定

export const userSubscription = pgTable("userSubscriptions", {
    id: serial("id").primaryKey(),
    userId: text("user_id").notNull().unique(),
    stripeCustomerId: text("stripe_customer_id").notNull().unique(),
    stripeSubscriptionId: text("stripe_subscription_id").notNull().unique(),
    stripePriceId: text("stripe_price_id").notNull(),
    stripeCurrentPeriodEnd: timestamp("stripe_current_period_end").notNull(),
});

手順5 ユーザーのサブスクリプション情報を取得

db/queries.ts

const DAY_IN_MS = 86_400_000; // 1日をmsで表記
export const getUserSubscription = cache(async() => {
    const {userId} = await auth();
    if(!userId) return null;

    const data =await db.query.userSubscription.findFirst({
        where: eq(userSubscription.userId, userId),
    });
    if(!data) return null;
    // サブスク終了期間が現在の時刻よりも先であるか
    const isActive = data.stripePriceId && data.stripeCurrentPeriodEnd?.getTime()! + DAY_IN_MS > Date.now();
    return {
        ...data,
        isActive: !!isActive
    };
});

手順6 課金ポータルへのURLを生成

actions/userSubscription.tsの作成

"use server"

import { absoluteUrl } from "@/lib/utils"
import { auth, currentUser } from "@clerk/nextjs/server";
import { getUserSubscription } from '../db/queries';
import { stripe } from "@/lib/stripe";

const returnUrl = absoluteUrl("/shop");

// 課金ポータルへのURLを生成
export const createStripeUrl = async () => {
    const { userId } = await auth();
    const user = await currentUser();

    if(!userId || !user) {
        throw new Error('ユーザーが見つかりません');
    }

    const userSubscription = await getUserSubscription();
    if(userSubscription && userSubscription.stripeCustomerId) {
        const stripeSession = await stripe.billingPortal.sessions.create({
            customer: userSubscription.stripeCustomerId,
            return_url: returnUrl
        });

        return { data: stripeSession.url};
    }
    const stripeSession = await stripe.checkout.sessions.create({
        mode: "subscription",
        payment_method_types: ["card"],
        customer_email: user.emailAddresses[0].emailAddress,
        line_items: [
            {
                quantity: 1,
                price_data: {
                    currency: "JPY",
                    product_data: {
                        name: "Proコース",
                        description: "サブスク登録",
                    },
                    unit_amount: 1000, // サブスク価格1000円に設定
                    recurring: {
                        interval: "month"
                    }
                }
            }
        ],
        metadata: {
            userId
        },
        success_url: returnUrl,
        cancel_url: returnUrl,
    });

    return { data: stripeSession.url}
};

詳しく解説

export const createStripeUrl = async () => {
  const { userId } = await auth();
  const user = await currentUser();

  if (!userId || !user) {
    throw new Error('ユーザーが見つかりません');
  }

最初に、createStripeUrl()でユーザーの課金ポータルへのURLを生成しています。次にauth()関数を呼び出してユーザーIDを取得し、currentUser()関数を呼び出して現在のユーザー情報を取得しています。ユーザーIDとユーザー情報が取得できない場合は、エラーをスローします。

作成したcreateStripeUrl()はUIのサブスク登録を実装したいボタンなどにに付与すればOKです。

例)

const onUpgrade = () => {
    startTransition(() => {
        createStripeUrl().then((response) => {
            if(response.data){
                window.location.href = response.data;
            }
        }).catch(() => toast.error('エラーが発生しました'));
    });
}

<Button
variant="primary"
onClick={onUpgrade} // サブスク登録ボタンに実装
disabled={pending}
>
    {hasActiveSubscription ? "設定" : "UPGRADE"}
</Button>

getUserSubscription()でユーザーのサブスクリプション情報を取得

  const userSubscription = await getUserSubscription();

  if (userSubscription && userSubscription.stripeCustomerId) {
    const stripeSession = await stripe.billingPortal.sessions.create({
      customer: userSubscription.stripeCustomerId,
      return_url: returnUrl
    });

    return { data: stripeSession.url };
  }

サブスクリプションが存在し、stripeCustomerIdがある場合、Stripeの課金ポータルセッションを作成し、URLを返します。

billingPortal.sessionsはStripeのクライアントライブラリが提供するメソッドです。
Stripeのクライアントライブラリは、Stripeの各種APIやリソースにアクセスするためのメソッドを提供しています。billingPortal.sessionsは、そのうちの1つで、Stripe課金ポータルのセッションを作成するためのメソッドになります。

Stripe課金ポータルとは、ユーザーが購読の管理やクレジットカード情報の更新などを行えるウェブページのことです。このポータルにアクセスするためのURLを生成するのが、billingPortal.sessions.create()の役割です。

次に、getUserSubscription()を呼び出して、ユーザーの現在の購読情報を取得します。ユーザーが既に購読していて、StripeのカスタマーIDが存在する場合はstripe.billingPortal.sessions.create()を使って課金ポータルのセッションを作成します。このメソッドにはユーザーのStripeカスタマーIDと、課金ポータルからリダイレクトされる際のリターンURLを渡します。その後、作成されたセッションのURLを返せばOK。

  const stripeSession = await stripe.checkout.sessions.create({
    mode: "subscription",
    payment_method_types: ["card"],
    customer_email: user.emailAddresses[0].emailAddress,
    line_items: [
      {
        quantity: 1,
        price_data: {
          currency: "JPY",
          product_data: {
            name: "Proコース",
            description: "サブスク登録",
          },
          unit_amount: 1000,
          recurring: {
            interval: "month"
          }
        }
      }
    ],
    metadata: {
      userId
    },
    success_url: returnUrl,
    cancel_url: returnUrl,
  });

  return { data: stripeSession.url };

ユーザーが購読していない場合の対処として、stripe.checkout.sessions.create()を使ってStripeチェックアウトセッションを作成しています。このメソッドには以下の設定を渡しています。

mode: "subscription": 購読モードを指定
payment_method_types: ["card"]: 支払い方法としてクレジットカードを許可
customer_email: ユーザーのメールアドレス
line_items: 購読プランの詳細を指定
quantity: 1: 数量
price_data: 価格情報
currency: "JPY": 通貨
product_data: 商品情報
name: 商品名
description: 商品説明
unit_amount: 1000: 価格(1000円)
recurring: { interval: "month" }: 毎月の課金
metadata: { userId }: ユーザーIDをメタデータとして追加
success_url: 決済成功時のリダイレクト先URL
cancel_url: 決済キャンセル時のリダイレクト先URL

まとめると、この関数は以下の手順で動作します。

ユーザーIDとユーザー情報を取得
ユーザーが既に購読している場合は、課金ポータルのURLを生成
ユーザーが購読していない場合は、新規購読のためのチェックアウトURLを生成

生成されたURLは、フロントエンドで表示されたり、リダイレクト先として使用できます。

手順7 StripeのWebhook処理を実装

DBの違による記載方法以外はほぼ定型文みたいな感じです。

app/api/webhooks/stripe/route.ts

import db from "@/db/drizzle";
import { userSubscription } from "@/db/schema";
import { stripe } from "@/lib/stripe";
import { eq } from "drizzle-orm";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import Stripe from "stripe";

// stripe.webhooks.constructEvent:Stripeの署名を検証し、イベントを構築
export async function POST(req: Request) {
    const body = await req.text();
    const signature = headers().get("Stripe-Signature") as string;

    let event: Stripe.Event;
    try {
        event = stripe.webhooks.constructEvent(
            body,
            signature,
            process.env.STRIPE_WEBHOOK_SECRET!
        );
    } catch (error: any) {
        return new NextResponse('Webhook error:${error.message}',
            {
                status: 400
            }
        );
    }

// ユーザーが新規のサブスク支払いを成功させ、Checkoutのプロセスが終了した時の処理
    const session = event.data.object as Stripe.Checkout.Session;
    if(event.type === "checkout.session.completed"){
        const subscription = await stripe.subscriptions.retrieve(
            session.subscription as string
        );
        if(!session?.metadata?.userId) {
            return new NextResponse("ユーザーIDが必要です", {status: 400});
        }

        await db.insert(userSubscription).values({
            userId: session.metadata.userId,
            stripeSubscriptionId: subscription.id,
            stripeCustomerId: subscription.customer as string,
            stripePriceId: subscription.items.data[0].price.id,
            stripeCurrentPeriodEnd: new Date(
                subscription.current_period_end * 1000
            )
        });
    }

// 既存のサブスクリプションの継続的な支払い(例えば毎月の支払い)が成功したとき
    if(event.type === "invoice.payment_succeeded"){
        const subscription = await stripe.subscriptions.retrieve(
            session.subscription as string
        );
        await db.update(userSubscription).set({
            stripePriceId: subscription.items.data[0].price.id,
            stripeCurrentPeriodEnd: new Date(
                subscription.current_period_end * 1000
            )
        }).where(eq(userSubscription.stripeSubscriptionId, subscription.id));
    }

    return new NextResponse(null, {status: 200});
};

手順8 STRIPE_WEBHOOK_SECRETの取得

https://docs.stripe.com/get-started/development-environment#install
ターミナルで以下実行

// CLI インストール
brew install stripe/stripe-cli/stripe
// インストールしたCLIでログイン
stripe login
// 自分のプロジェクトのurlを使用
stripe listen --forward-to localhost:3000/api/webhooks/stripe
// このコマンドを実行後、各CLIでエラーが出ていないか確認する
stripe trigger payment_intent.succeeded 

解説

順番にstripe CLIでコマンドを実行していく

まずCLI インストール後にstripe loginを実行すると以下のような画面になりますのでEnterを押す

その後、アクセス許可を求められるのでプロジェクト名を確認してアクセス許可を押す

stripe listen --forward-to localhost:3000/api/webhooks/stripe

次に上記コマンドを実行します。
/api/webhooks/stripeの部分は自分のプロジェクトディレクトリのURLになりますので、それに合わせて変更しましょう。実行すると下記のようにwebhookのエンドポイントが表示されますので、これをコピーします。

そして、コピーしたエンドポイントを.envファイルに貼り付けます
.env

NEXT_PUBLICK_APP_URL="http://localhost:3000"
STRIPE_API_KEY="sk_test_51PEkdapsoiuhaomfaizTYlc5Nqnv12qhIpL4DoxqeIMK4lNYVWEkwLfSF5v1FFqQZJkbLasd;kjmopkpoae3T6IEyg"
STRIPE_WEBHOOK_SECRET="whsec_22b77947c64fc89c8d6bba57316351d74cd26f84cafdc7dcb8dcdd12641c0bdc" // 追記

ミドルウェアにエンドポイントを追記

midlewares.ts

import {
    clerkMiddleware,
    createRouteMatcher
  } from '@clerk/nextjs/server';
  
  const isProtectedRoute = createRouteMatcher([
    '',
  ]);
  
  export default clerkMiddleware((auth, req) => {
    if (isProtectedRoute(req)) auth().protect();
    publicRoutes: ["/", "/api/webhooks/stripe"] // ここに追記
  });

export const config = {
  matcher: ["/((?!.+.[w]+$|_next).*)", "/", "/(api|trpc)(.*)"],
};

stripe trigger payment_intent.succeeded

最後に上記コマンドを実行して、各ターミナルでエラーが発生していないことを確認しましょう。Trigger succeeded!と出ていればOKです。

下記のように失敗すると500が、修正して成功すると200がきます。

最後に

カード情報を入力してDBにユーザー情報が登録されているか確認できれば成功です

Discussion