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_connect, only: [ :create ]

    post "stripe/webhooks/connect", to: "stripe_webhooks_connect#create"
  end
end

stripe_connectのcreateと、
stripe_webhooks_connectのcreateを作ります。

stripe_connect#create は、
Rails から Stripe Connect に対して処理を行うためのエンドポイントです。
接続アカウントの作成や連携など、Rails 側から能動的に呼び出します。

一方、stripe_webhooks_connect#create は、
Stripe から Rails に通知が届く Webhook 用のエンドポイントです。
支払い結果などを、Stripe が非同期で通知してきます。

Rails から Stripe への処理は通常の API 呼び出しで行い、
その結果(支払い完了・失敗など)を Stripe が非同期で通知してくる仕組みが Webhook です。

api/stripe_connects_controller.rbの解説

class Api::StripeConnectsController < 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

      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::StripeConnectsController < 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を指定します。

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

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

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

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

これはオンボーディングが完了しているかのフラグです。
完了してから出品できるようになります。
DBにもオンボーディングのフラグがありますが、
Stripeの方のものを使います。
account.charges_enabledは、審査が終了し、入金可能になっている状態です。
これを使って、オンボーディングの判定をします。

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_connect", {
          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_connect", {
          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 を更新したり、フラグを変更するための必須の仕組みです。

Stripe ConnectのWebhookの処理

class Api::StripeWebhooksConnectController < ActionController::API
  
  def create
    payload = request.raw_post
    signature = request.env["HTTP_STRIPE_SIGNATURE"]
    secret = ENV["STRIPE_CONNECT_WEBHOOK_SECRET"]

    unless secret.present?
      Rails.logger.warn("Stripe connect 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 connect 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"]

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

    head :ok
  end
end

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

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

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

payload = request.raw_post

Stripe Webhook のリクエストボディを加工せずそのままの文字列で取得します。
署名検証に必要なため、params ではなく raw_post を使います。

signature = request.env["HTTP_STRIPE_SIGNATURE"]

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

secret = ENV["STRIPE_CONNECT_WEBHOOK_SECRET"]

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

unless secret.present?
  Rails.logger.warn("Stripe connect 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 connect 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"]

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

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


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

event.data.objectをaccountに代入します。
account[“id”]をaccount_idに代入します。
charges_enabledが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を挿入します。
見つからなければ、ログに記入します。

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

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

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

brew install stripe/stripe-cli/stripe

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

stripe login

Stripeにログインします。

stripe listen --events account.updated \
  --forward-to http://localhost:3000/api/stripe/webhooks/connect

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

サンドボックスでWebhookを受け取る

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

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

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

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

イベントは
account.updatedを選びます。

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

名前を「digital-ichiba-connect-onboarding」とつけて、
エンドポイントのURLを「https://api.aws-training-shu.com/api/stripe/webhooks/connect」と入れて、
説明を記入して、送信先を作成します。

署名シークレットというのがあるので、
これを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年11月の学習の振り返り

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

プログラミング

自作アプリの決済の実装と手動デプロイを実行。vibeコーディング時代の学習法を試みる。

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

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

プログラミング

デザインの基礎とUI/UXとGo言語を学習。自分のメンテナンスをしました。

Next.js × Rails|マルチテナントEC「Digital Ichiba」の全体設計と実装まとめ

Next.js × Rails|マルチテナントEC「Digital Ichiba」の全体設計と実装まとめ

プログラミング

Next.js × Railsで構築したマルチテナントEC「Digital Ichiba」の全体設計と実装内容をまとめた。