Shu Digital Works

Articles

投稿記事

Top > Articles > プログラミング > Next.js × Rails|Stripe Connect によるショップのオンボーディング実装

Next.js × Rails|Stripe Connect によるショップのオンボーディング実装

プログラミング EC Next.js Rails Stripe 決済
公開日 更新日
Next.js × Rails|Stripe Connect によるショップのオンボーディング実装

前回は、ステートレスなカート機能の実装について解説しました。
カートに商品を入れられるようになったら、
次に必要なのは 決済処理 です。
今回の自作アプリでは、決済に Stripe を採用しています。
お金が動かないテストモードを使った実装を解説します。
今回のゴールは、Stripe Connectを使って、
ショップオーナーが登録、更新できるようにすることです。

Stripeにはさまざまな機能がありますが、このアプリでは大きく次の 2つ を使用しています。

1つ目:Stripe Connect(マーケットプレイス向け)
プラットフォーム運営者として、
出品者(ショップオーナー)にStripeアカウントを登録してもらい、
アプリ側で管理しつつ、手数料を取る仕組みを Stripe Connect で実現しています。
本記事では、この Stripe Connect の仕組みと実装の流れ を中心に解説します。

2つ目:Stripe Checkout(購入者向け決済)
ユーザーがカートの商品を購入できるように、
決済画面をStripeに委譲する Stripe Checkout を利用しています。
Checkoutの実装については、次回の記事で詳しく取り上げます

バージョン情報

フロントエンド

  • TypeScript 5.9.2
  • React 19.1.0
  • Next.js 15.5.0
  • Auth.js 5.0.0-beta.29
  • TailwindCSS 4.1.12

バックエンド

  • Ruby 3.3.9
  • Rails 8.0.2.1
  • Postgresql 16.10
  • Docker 28.4.0

Stripeとは

Stripe は、オンライン決済を簡単に導入できるサービスです。
クレジットカードやApple Payなどの支払いを、
開発者が複雑な金融処理を気にせずに実装できます。
セキュリティや決済の承認・失敗処理、
請求書の作成、決済画面のホスティングなど、
お金のやり取りに関する面倒な部分を Stripe がすべて肩代わりしてくれるため、
ECサイト・サブスク・寄付サイトなど幅広い場面で利用されています。

Stripe Connectとは

Stripe Connect は、
「複数の販売者が存在するプラットフォーム向け」の決済ソリューションです。
ECモールやスキルマーケットのように、出品者(ショップオーナー)へ売上を分配したり、
プラットフォーム側が手数料を自動で差し引く仕組みを、
ほぼコードだけで構築できます。
出品者ごとにStripeアカウントを紐付けたり、
決済後の売上を自動送金したりと、
通常は金融業務レベルの処理を、
シンプルなAPIで安全に実現できるのが特徴です。

RailsのStripeの導入

gem "stripe", "~> 17.0"

stripeのgemを入れて、
コンテナをbuildし直します。

Rails.application.routes.draw do

  namespace :api do
    # 略
    resource :stripe_accounts, only: [ :create ]
    resource :stripe_webhooks, only: [ :create ]
  end
end

stripe_accountsのcreateと、
stripe_webhooksのcreateを作ります。

stripe_accounts という名前にしているのは、
Stripe の “Account(接続アカウント)” を Rails 側で扱うためのエンドポイントだからです。
ショップオーナーが Stripe アカウントを登録したり、更新したりする処理を担当します。

Rails から Stripe に情報を送るのは通常の API 呼び出しで、
Stripe が処理した結果を Rails に通知してくれる仕組みが Webhook です。

api/stripe_accounts_controller.rbの解説

class Api::StripeAccountsController < ActionController::API
  include RailsJwtAuth

  def create
    shop = current_user.shop
    return render json: { error: "Shop not found" }, status: :not_found unless shop

    base_url = ENV["NEXT_URL"]
    return render json: { error: "NEXT_URL is not set" }, status: :unprocessable_entity unless base_url.present?

    begin
      account_id = shop.stripe_connect_account_id
      unless account_id.present?
        account = Stripe::Account.create({
          type: "express",
          country: "JP",
          capabilities: {
            card_payments: { requested: true },
            transfers: { requested: true }
          }
        })
        shop.update!(stripe_connect_account_id: account.id)
        account_id = account.id
      end

      account = Stripe::Account.retrieve(account_id)

      # 常にStripeの最新状態で判定(DBのラグに依存しない)
      onboarded = account.charges_enabled || account.details_submitted

      if onboarded
        login_link = Stripe::Account.create_login_link(account_id)
        render json: { login_url: login_link.url }, status: :ok
      else
        account_link = Stripe::AccountLink.create({
          account: account_id,
          refresh_url: "#{base_url}/dashboard/shop#refresh",
          return_url: "#{base_url}/dashboard/shop/refresh",
          type: "account_onboarding"
        })
        render json: { onboarding_url: account_link.url }, status: :ok
      end
    rescue Stripe::StripeError => e
      Rails.logger.error("Stripe error: #{e.message}")
      render json: { error: "Stripe連携エラー: #{e.message}" }, status: :unprocessable_entity
    end
  end
end

解説します。

class Api::StripeAccountsController < ActionController::API
  include RailsJwtAuth

  def create

RailsJwtAuthをincludeすることで、
ログインしたユーザーしか処理できないようにします。
認証の詳細はこちら

createを定義します。
このコントローラーは、createだけです。

shop = current_user.shop
return render json: { error: "Shop not found" }, status: :not_found unless shop

今ログインしているユーザーのショップを取ってshopに代入します。
shopが取れなかったらjsonを返して処理を終わらせます。

 base_url = ENV["NEXT_URL"]
    return render json: { error: "NEXT_URL is not set" }, status: :unprocessable_entity unless base_url.present?

.envにフロントエンドのNEXT_URLを環境変数として設定しています。
base_urlに代入します。
取れなければフロントに422を返します。

begin

beginを使って、例外がある場合下の方でキャッチします。

account_id = shop.stripe_connect_account_id

shop.stripe_connect_account_idを取得します。
最初は存在しないので、下のunlessが実行されます。
2回目以降は存在するので、unlessが実行されません。

unless account_id.present?
  account = Stripe::Account.create({
    type: "express",
    country: "JP",
    capabilities: {
      card_payments: { requested: true },
      transfers: { requested: true }
    }
  })

  shop.update!(stripe_connect_account_id: account.id)
  account_id = account.id
end

初回はaccount_idが取れないので実行されます。
Stripe::Account.create()を使って、
ショップオーナー用のアカウントを作ります。

typeはexpressを指定します。
マーケットプレイスを作る場合はexpressを使います。
countryにJPを指定します。

capabilitesは何ができるようになるかを設定します。
card_paymentsのtrueはカードによる支払いを有効化します。
transfersのtrueは、Stripeがお金を受け取った後、
ショップオーナーに送金できるようにします。

Stripe::Accountのインスタンスが返ってくるので、
をaccountに代入します。

  shop.update!(stripe_connect_account_id: account.id)
  account_id = account.id
end

account.idに作られたStripeのアカウントのidがあるので
自分のDBのshopの情報をアップデートします。
2回目以降は新規作成せずに、
このstripe_connect_account_idを取得して使うので、
新規作成はされません。

account = Stripe::Account.retrieve(account_id)

account_idをStripe::Account.retrieve()に渡して、
Stripe::Accountのインスタンスが返ってくるので受け取ります。

# 常にStripeの最新状態で判定(DBのラグに依存しない)
onboarded = account.charges_enabled || account.details_submitted

これはオンボーディングが完了しているかのフラグです。
DBにもオンボーディングのフラグがありますが、
Stripeの方のものを使います。
account.details_submittedは、詳細を記入したけど、審査待ちです。
account.charges_enabledは、審査が終了し、入金可能になっている状態です。
どちらかがtrueならonboardedはtrueになります。

if onboarded
  login_link = Stripe::Account.create_login_link(account_id)
  render json: { login_url: login_link.url }, status: :ok
else
  account_link = Stripe::AccountLink.create({
    account: account_id,
    refresh_url: "#{base_url}/dashboard/shop#refresh",
    return_url: "#{base_url}/dashboard/shop/refresh",
    type: "account_onboarding"
  })
  render json: { onboarding_url: account_link.url }, status: :ok
end

onboardedがtrueなら、
expressのダッシュボードにログインできるリンクを作って、
ログインリンクをフロントに返します。
ダッシュボードで動いた金額を見たり、
ショップオーナーの情報をアップデートしたりできます。

onboardedがfalseなら、
account_idを元に、
オンボーディングの新規のリンクを作ってフロントに返します。
typeはaccount_onboardingを指定します。
refresh_urlは、途中でエラー・キャンセルしたときに戻ってくるURLです。
return_urlは、オンボーディング完了後に戻ってくるURLです。

rescue Stripe::StripeError => e
  Rails.logger.error("Stripe error: #{e.message}")
  render json: { error: "Stripe連携エラー: #{e.message}" }, status: :unprocessable_entity
end

途中で例外でStripe::StripeErrorが発生した場合
ログに記入して、エラーをフロントに返します。

Next.jsのStripeボタン

"use client";

import { Button } from "@/components/ui/button";

export default function StripeButton() {
  return (
    <Button
      className="w-full sm:w-auto"
      onClick={async () => {
        const res = await fetch("/api/stripe_accounts", {
          method: "POST",
          cache: "no-store",
        });
        if (!res.ok) return;
        const data = await res.json();
        const url = data.onboarding_url || data.login_url;
        if (url) window.location.href = url;
      }}
    >
      <span className="font-bold">Stripe連携</span>
    </Button>
  );
}

src/app/dashboard/shop/StripeButton.tsxの中身です。
このボタンをショップのダッシュボードに設置しているので、
これを押すことで、Stripeに飛びます。

"use client";

クライアントコンポーネントとして扱います。

import { Button } from "@/components/ui/button";

ボタンをインポートします。

export default function StripeButton() {
  return (
    <Button
      className="w-full sm:w-auto"
      onClick={async () => {
        const res = await fetch("/api/stripe_accounts", {
          method: "POST",
          cache: "no-store",
        });

クリックするとBFFのProxyを経由して、
先ほどのstripe_accounts_controllerのcreateにアクセスします。
BFFのProxyの詳細はこちら

        if (!res.ok) return;
        const data = await res.json();
        const url = data.onboarding_url || data.login_url;
        if (url) window.location.href = url;
      }}
    >
      <span className="font-bold">Stripe連携</span>
    </Button>
  );
}

resが取れなかった場合は中断します。
resを取れた場合、json()でオブジェクト化してdataに入れます。
Railsから帰ってきた、onboarding_urlかlogin_urlを
urlに代入して、そこへ飛びます。
これでストライプのオンボーディング画面か、
ログイン画面に飛べます。

Webhookとは

Webhook は、Stripe が処理したイベントを Rails に自動で通知してくれる仕組みです。
Stripe Connect では、
「アカウントの審査が進んだ」「情報が更新された」「支払いが成功した」
などの状態が Stripe 内で発生します。
サーバー側(Rails)はこれを自分で取りに行く必要はなく、
Stripe が POST リクエストとして Rails に送ってくることで状態を同期できます。

Rails → Stripe:通常の API でリクエスト
Stripe → Rails:Webhook で通知

このように Webhook は、Stripe 側の状態変化を受け取り、
DB を更新したり、フラグを変更するための必須の仕組みです。

RailsのStripe Webhookの処理

class Api::StripeWebhooksController < ActionController::API
  # Stripe Webhook endpoint: verify signature and dispatch by event type
  def create
    payload = request.body.read
    signature = request.env["HTTP_STRIPE_SIGNATURE"]
    secret = ENV["STRIPE_WEBHOOK_SECRET"]

    unless secret.present?
      Rails.logger.warn("Stripe webhook secret is not set")
      return head :bad_request
    end

    begin
      event = Stripe::Webhook.construct_event(payload, signature, secret)
    rescue JSON::ParserError, Stripe::SignatureVerificationError => e
      Rails.logger.warn("Stripe webhook error: #{e.message}")
      return head :bad_request
    end

    case event.type
    when "account.updated"
      account = event.data.object
      account_id = account["id"]
      onboarded = account["charges_enabled"] || account["details_submitted"]
      if (shop = Shop.find_by(stripe_connect_account_id: account_id))
        # update_columnsでコールバックなし・高速反映
        shop.update_columns(stripe_onboarded: onboarded)
      else
        Rails.logger.warn(
          "[StripeWebhook] Shop not found for account_id=#{account_id}"
        )
      end

    when "checkout.session.completed"
      # 略
    end

    head :ok
  end
end

stripe_webhooks_controller.rbの中身はこのようになっています。

class Api::StripeWebhooksController < ActionController::API
  def create

コントローラの中身はcreateだけです。

payload = request.body.read

これはStripeがRailsへ送ったrequestの本体です。
この時点ではまだパースしていません。

signature = request.env["HTTP_STRIPE_SIGNATURE"]

StripeからRailsに送信したrequestの署名です。
本物であることを確認するためにつかいます。

secret = ENV["STRIPE_WEBHOOK_SECRET"]

これはStripeのダッシュボードから取ってきます。
送られてきた署名が本物であることを確認するのに使います。

unless secret.present?
  Rails.logger.warn("Stripe webhook secret is not set")
  return head :bad_request
end

secretがなければ、ログに記入して、returnします。

begin
  event = Stripe::Webhook.construct_event(payload, signature, secret)
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
  Rails.logger.warn("Stripe webhook error: #{e.message}")
  return head :bad_request
end

beginの中で書いて、エラーがあった場合はrescueでキャッチします。
Stripe::Webhook.construct_event()に対して、
payload, signature, secretを渡します。
本物かどうか検証して、本物ならばパースしたものをeventにいれます。
失敗した場合は、ログに記入して、returnします。

case event.type
when "account.updated"
  account = event.data.object
  account_id = account["id"]
  onboarded = account["charges_enabled"] || account["details_submitted"]
  if (shop = Shop.find_by(stripe_connect_account_id: account_id))
    # update_columnsでコールバックなし・高速反映
    shop.update_columns(stripe_onboarded: onboarded)
  else
    Rails.logger.warn(
      "[StripeWebhook] Shop not found for account_id=#{account_id}"
    )
  end

case文でevent.typeで場合分けします。
when “account.updated”の場合の処理をします。
オンボーディングが成功するとStripeはRailsに対してこれを送信します。


account = event.data.object
account_id = account["id"]
onboarded = account["charges_enabled"] || account["details_submitted"]

event.data.objectをaccountに代入します。
account[“id”]をaccount_idに代入します。
charges_enabledかdetails_submittedのどちらかがtrueならば、
onboardedをtrueにします。

if (shop = Shop.find_by(stripe_connect_account_id: account_id))
    # update_columnsでコールバックなし・高速反映
    shop.update_columns(stripe_onboarded: onboarded)
  else
    Rails.logger.warn(
      "[StripeWebhook] Shop not found for account_id=#{account_id}"
    )
  end

account_idを使って、DBの該当するshopを見つけます。
もし見つかれば、
shopのstripe_onboardedカラムにtrueを挿入します。
見つからなければ、ログに記入します。

when "checkout.session.completed"
      # 略
    end

    head :ok
  end
end

こちらはStripe CheckoutのWebhookなので、
今回は割愛します。

最後にレスポンスとして、head :okを返します。
これでユーザーがオンボーディング完了した場合、
DBのオンボーディングフラグをtrueにする処理を、
Webhookを通じて作ることができました。

ローカル環境でWebhookを受け取る

Stripe の Webhook は、Stripe のサーバーから
Rails の /api/stripe_webhooks に向けてHTTPリクエストを送る仕組みです。
しかし通常、ローカル環境は外部から直接アクセスできないため、
Stripe の通知をそのまま受け取ることはできません。
これを解決するのが、Stripe CLI の “トンネリング”(フォワード)機能です。

brew install stripe/stripe-cli/stripe

brewを使ってStripe CLIをインストールします。

stripe login

Stripeにログインします。

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

フォワード機能を使います。
listenすると、シークレットが返ってくるので、
これを、STRIPE_WEBHOOK_SECRETとして、envに入れます。
これはターミナルで、起動したままにすると、
その間にローカルでもWebhookを受信できます。

本番環境でWebhookを受け取る

ブラウザからStripeのサイトにログインして、
ダッシュボードに入ります。
プロジェクトがサンドボックスにテストモードとしてあるので、
モードをプロジェクトのものに切り替えます。

下に開発者ボタンがあるので押して、更にWebhookを選びます。
イベントの送信を追加します。

イベントのリッスン先は右側の、
連結アカウントとv2アカウントを選びます。

APIのバージョンはそのまま

イベントは
account.updatedを選びます。
次回のStripe Checkoutのために、
checkout.session.completedも選択しておきます。

Webhookエンドポイントを選びます。

名前をつけて、
エンドポイントのURLを入れて、
説明を記入して、送信先を作成します。

署名シークレットというのがあるので、
これをAWSなどのパネルから入れます。
AWSならばシークレットマネージャーがおすすめです。

設定が完了すると、
本番環境でWebhookを受け取れるようになります。

オンボーディング完了時のリフレッシュ

import { NextResponse, NextRequest } from "next/server";
import requireAuth from "@/lib/requireAuth";
import requireShopOrAdmin from "@/lib/requireShopOrAdmin";

export async function GET(request: NextRequest) {
  await requireAuth();
  await requireShopOrAdmin();

  const res = await fetch(`${process.env.NEXT_URL}/api/shop`, {
    method: "GET",
    headers: {
      cookie: request.headers.get("cookie") ?? "",
    },
    cache: "no-store",
  });

  const redirect = NextResponse.redirect(
    new URL("/dashboard/shop", request.url)
  );
  const setCookie = res.headers.get("set-cookie");
  if (setCookie) redirect.headers.set("set-cookie", setCookie);
  return redirect;
}

オンボーディングが完了したときの処理です。
これをやることで、クッキーを更新します。

return_url: "#{base_url}/dashboard/shop/refresh",

Rails側でこのように指定したので、
オンボーディングが完了するとここに飛びます。

import { NextResponse, NextRequest } from "next/server";
import requireAuth from "@/lib/requireAuth";
import requireShopOrAdmin from "@/lib/requireShopOrAdmin";

next/serverからNextResponseとNextRequestをimportします。
自作のrequireAuthとrequireShopOrAdminもimportします。

export async function GET(request: NextRequest) {
  await requireAuth();
  await requireShopOrAdmin();

ログインして、かつロールを満たす必要があります。

 const res = await fetch(`${process.env.NEXT_URL}/api/shop`, {
    method: "GET",
    headers: {
      cookie: request.headers.get("cookie") ?? "",
    },
    cache: "no-store",
  });

/api/shopにGETを送ります。
headersのcookieを明記しているのは、
今回はサーバーサイドからfetchしているからです。
client componentからのfetchは、
cookieは自動挿入ですが、
サーバーサイドからやる場合は、明記する必要があります。

const redirect = NextResponse.redirect(
  new URL("/dashboard/shop", request.url)
);

NextResponse.redirect()からredirectを生成します。

const setCookie = res.headers.get("set-cookie");

こちらもサーバーサイドからやっているので、
手動でセットし直す必要があります。
headersからset-cookieを取ってきてsetCookieに代入します。

if (setCookie) redirect.headers.set("set-cookie", setCookie);
  return redirect;

setCookieがtrueなら先ほど定義したredirectのheadersにセットします。
こうすることで、
オンボーディングが成功すると、クッキーを自動更新して
出品ボタンから商品の新規出品ができるようになります。

2回目以降のStripeボタンの挙動

1回目はオンボーディングに登録しますが、
2回目以降はログインします。
売上を見たり、ショップオーナーの情報の設定を更新できます。

まとめ

本記事では、Stripe Connect を使って
出品者のオンボーディングを実現する仕組みを解説しました。

  • Rails 側では Stripe Account の作成・取得・状態判定を行い
  • Next.js 側は BFF 経由で Rails を呼び出し
  • Stripe 側で審査や情報更新が行われた場合は Webhook を通して Rails に通知
  • 通知を受けて DB のフラグを更新し、出品機能を解放する

という一連のフローをコードベースで構築しました。
特に Stripe Connect は、
マーケットプレイス構築で避けて通れない複雑な送金処理を、
最小限のコードで安全に扱えるのが大きな強みです。

次回の記事では、
実際の決済処理を Stripe Checkout で実装する方法を詳しく解説します。
カートの内容を元に Rails 側で Checkout Session を生成し、
購入者が Stripe の画面で安全に支払えるようにする仕組みを紹介します。

関連記事

2025年5月の学習の振り返り

2025年5月の学習の振り返り

プログラミング

5月はTerraformを学習し、インフラが完了。アジャイル開発のプロジェクトマネジメントを学習。

Next.js × Rails|SSR+CSRで実装するECサイトの商品一覧ページネーション

Next.js × Rails|SSR+CSRで実装するECサイトの商品一覧ページネーション

プログラミング

SSR+CSRを組み合わせた商品一覧ページのページネーション実装。シンプルで見やすいUI設計。

Next.js × Rails で API通信を共通化するBFF(Proxy)構成を実装する

Next.js × Rails で API通信を共通化するBFF(Proxy)構成を実装する

プログラミング

Next.jsとRails間のAPI通信をBFFで共通化。JWTやCookie制御も含めた実装を詳しく解説。