Shu Digital Works

Articles

投稿記事

Top > Articles > プログラミング > Next.js × Rails|Stripe Checkoutで決済機能を実装する

Next.js × Rails|Stripe Checkoutで決済機能を実装する

プログラミング EC Next.js Rails Stripe 決済
公開日 更新日
Next.js × Rails|Stripe Checkoutで決済機能を実装する

前回と前々回で、カートとStripe Connectの実装を記事にしました。
商品をカートにいられるようになり、
ショップも登録できるようになりました。
次に決済が実装されれば、買い物できるようになります。
今回はその肝心な決済の実装について説明したいと思います。
決済はStripe 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 Checkoutとは

Stripe Checkout は、Stripe が用意している 完成済みの決済ページ です。
開発者が決済画面を自作しなくても、
カード・Apple Pay などの支払いがそのまま利用できます。
商品情報や金額を Stripe に渡すだけで、
安全にホストされたチェックアウトページ が自動で生成されるため、
最小限のコードで決済フローを導入できるのが特徴です。

RailsのStripeの導入

gem "stripe", "~> 17.0"

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

Rails.application.routes.draw do

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

ルーティングを設定します。
今回使うのではstripe_checkoutsのcreateと、
Webhookを受け取るwebhooksのcreateです。
stripe_checkoutsは決済を処理するものです。
stripe_webhooksはStripeが処理を終えた後に、
RailsにWebhookを送らせるので、
それを受け取ったとの処理をするものです。

api/stripe_checkouts_controller.rbの解説

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

  def create
    sp = stripe_checkout_params
    cart = sp[:cart]
    return render json: { error: "Cart is required" }, status: :unprocessable_entity unless cart.present?

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

    # cart: [{ product_id, quantity }] を想定(シンプル)
    product_ids = cart.map { |i| i[:product_id] }.compact.uniq
    return render json: { error: "No product ids" }, status: :unprocessable_entity if product_ids.empty?

    products_by_id = Product.where(id: product_ids).index_by(&:id)
    missing_ids = product_ids - products_by_id.keys
    return render json: { error: "Invalid product ids: #{missing_ids.join(",")}" }, status: :unprocessable_entity if missing_ids.any?

    # すべて同一ショップの商品に限定(ショップIDは商品から導出)
    shop_ids = products_by_id.values.map(&:shop_id).uniq
    return render json: { error: "Products must belong to the same shop" }, status: :unprocessable_entity unless shop_ids.size == 1
    shop = Shop.find(shop_ids.first)

    # 価格は必ずサーバー側で算出(税込)。通貨はJPY前提。
    line_items = []
    total_cents = 0
    cart.each do |item|
      pid = item[:product_id]
      quantity = item[:quantity].to_i
      next if quantity <= 0
      product = products_by_id[pid]
      return render json: { error: "Product not found" }, status: :unprocessable_entity if product.nil?
      unit_amount = product.price_including_tax_cents
      total_cents += unit_amount * quantity
      line_items << {
        price_data: {
          currency: "jpy",
          unit_amount: unit_amount,
          product_data: {
            name: product.name,
            metadata: { product_id: product.id }
          }
        },
        quantity: quantity
      }
    end
    return render json: { error: "No valid line items" }, status: :unprocessable_entity if line_items.empty?

    # 送料と手数料(任意)
    shipping_cents = 500
    fee_percent = (ENV["PLATFORM_FEE_PERCENT"] || "0").to_f
    application_fee_amount = ((total_cents + shipping_cents) * (fee_percent / 100.0)).floor

    session_params = {
      mode: "payment",
      line_items: line_items,
      success_url: "#{base_url}/cart/success?session_id={CHECKOUT_SESSION_ID}",
      cancel_url:  "#{base_url}/cart?canceled=1",
      allow_promotion_codes: true,

      billing_address_collection: "required",
      shipping_address_collection: { allowed_countries: [ "JP" ] },
      phone_number_collection: { enabled: true },
      shipping_options: [
        {
          shipping_rate_data: {
            display_name: "通常配送",
            type: "fixed_amount",
            fixed_amount: { amount: shipping_cents, currency: "jpy" }
          }
        }
      ]
    }
    # 注文確定用のメタデータ(Webhookで参照)
    session_params[:metadata] = {
      user_id: current_user.id.to_s,
      shop_id: shop.id.to_s
    }
    # 顧客IDがあれば設定(任意)
    if current_user.respond_to?(:stripe_customer_id) && current_user.stripe_customer_id.present?
      session_params[:customer] = current_user.stripe_customer_id
    end
    # Connect 送金(任意)
    if shop.stripe_connect_account_id.present?
      session_params[:payment_intent_data] = {
        application_fee_amount: application_fee_amount,
        transfer_data: { destination: shop.stripe_connect_account_id }
      }
    end

    checkout = Stripe::Checkout::Session.create(session_params)
    render json: { url: checkout.url }, status: :ok
  end


  private

  def stripe_checkout_params
    if params[:stripe_checkout].present?
      permitted = params.require(:stripe_checkout).permit(cart: [ :product_id, :quantity ])
    else
      permitted = params.permit(cart: [ :product_id, :quantity ])
    end
    { cart: Array(permitted[:cart]).map { |h| h.to_h.symbolize_keys.slice(:product_id, :quantity) } }
  end
end

解説します。

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

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

  def create
    sp = stripe_checkout_params

このコントローラはcreateだけです。
stripe_checkout_paramsは、ストロングパラメーターで
通過したものをspに代入します。

 private

  def stripe_checkout_params
    if params[:stripe_checkout].present?
      permitted = params.require(:stripe_checkout).permit(cart: [ :product_id, :quantity ])
    else
      permitted = params.permit(cart: [ :product_id, :quantity ])
    end
    { cart: Array(permitted[:cart]).map { |h| h.to_h.symbolize_keys.slice(:product_id, :quantity) } }
  end
end

下の方にprivateがあって、
その中にstripe_checkout_paramsを定義しています。

フロントから送られた情報を、stripe_checkoutでネストされてもされてなくても、
permitted受け取れるようにします。

{ cart: Array(permitted[:cart]).map { |h| h.to_h.symbolize_keys.slice(:product_id, :quantity) } }

これは最後の行なので、returnが省略されてて、ハッシュがを返します。
何をやっているのかというと、ストロングパラメーターを完全に信用せずに、
セキュリティを強化している処理です。

permitedd[:cart]にフロントから来たハッシュが入りますが、
空の場合、1行の場合、複数行の場合の形式が違うので、
Array()で一度配列で囲みます。
さらに.mapを使って要素をhとして、
to_hハッシュに戻した後、
symbolize_keysを使って、キーをシンボル化した後、
slice()で:roduct_idと:quantityだけを取り出しています。

このようにすることで、
不正な値を受け取らないようにしています。


cart = sp[:cart]
return render json: { error: "Cart is required" }, status: :unprocessable_entity unless cart.present?

sp[:cart]の値をcartに代入します。
cartがなければ、returnをします。

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

環境変数からNEXT_URLを取ってきます
取れれてない場合はreturnをします。

product_ids = cart.map { |i| i[:product_id] }.compact.uniq
return render json: { error: "No product ids" }, status: :unprocessable_entity if product_ids.empty?

渡ってきたidを抽出します。
compactはnilを除外して、
uniqは重複を除外します。
idsが空なら、returnをします。

products_by_id = Product.where(id: product_ids).index_by(&:id)
missing_ids = product_ids - products_by_id.keys
return render json: { error: "Invalid product ids: #{missing_ids.join(",")}" }, status: :unprocessable_entity if missing_ids.any?

product_idsを元に、Productのインスタンスを取得して、
index_by(&: id)とすることで、
idをキーとして、Productのインスタンスをバリューとしたハッシュにします。

配列同士で引き算して、フロントから来たけど、DBに存在しないidがあるか探します。
もしあった場合returnします。

shop_ids = products_by_id.values.map(&:shop_id).uniq
return render json: { error: "Products must belong to the same shop" }, status: :unprocessable_entity unless shop_ids.size == 1
shop = Shop.find(shop_ids.first)

仕様上、同時に一つのショップでしか決済できないので、
shop_idが複数取れた場合はreturnします。
Shop.findでshopのインスタンスを取ってきます。

line_items = []
total_cents = 0

line_itemsとtotal_centsを初期化します。
line_itemsは、Stripeに商品情報を渡すための配列です。
total_centsは、合計金額です。

cart.each do |item|

cartの中身をitemとしてループします。

pid = item[:product_id]
quantity = item[:quantity].to_i
next if quantity <= 0
product = products_by_id[pid]
return render json: { error: "Product not found" }, status: :unprocessable_entity if product.nil?

itemのidをpidに入れます
itemのquantityを整数にして、quantityに入れます。
quantityが正常ではない場合、次の行を飛ばします
products_by_idとpidを使って商品のインスタンスを取ってきてproductに入れます。
productがnilの場合returnします。

unit_amount = product.price_including_tax_cents
total_cents += unit_amount * quantity

税込み価格を取得してunit_amountに入れます。
税込価格と個数をかけて、
その商品の小計を足していきます。

line_items << {
  price_data: {
    currency: "jpy",
    unit_amount: unit_amount,
    product_data: {
      name: product.name,
      metadata: { product_id: product.id }
    }
  },
  quantity: quantity
}

line_itemsの後ろからハッシュとして追加します。
price_dataで入れ子になってて
currency: “jpy”で日本円、
unit_amountはunit_amount
prodact_dataの中身として、
name: product.nameと
metadataとしてproduct_idを渡します。
quantityも渡します。

end
return render json: { error: "No valid line items" }, status: :unprocessable_entity if line_items.empty?

eachをendで閉じて、
もしline_itemsが空ならばreturnをします。

shipping_cents = ENV["SHIPPING_CENTS"].to_i
return render json: { error: "SHIPPING_CENTS is not set" }, status: :unprocessable_entity unless shipping_cents.positive?

今回は送料はとりあえず500円と設定して、
環境変数から渡すようにしています。
数値が不正の場合は、returnをします。

fee_percent = (ENV["PLATFORM_FEE_PERCENT"] || "0").to_f
application_fee_amount = ((total_cents + shipping_cents) * (fee_percent / 100.0)).floor

プラットフォーム手数料を環境変数から取ってきてきます。
4%と設定したので、0.04と入れています。
to_fで浮動小数として扱います。

小計を合計したもの+送料を元にプラットフォーム手数料を計算しています。

session_params = {
  mode: "payment",
  line_items: line_items,
  success_url: "#{base_url}/cart/success?session_id={CHECKOUT_SESSION_ID}",
  cancel_url:  "#{base_url}/cart?canceled=1",
  allow_promotion_codes: true,

  billing_address_collection: "required",
  shipping_address_collection: { allowed_countries: ["JP"] },
  phone_number_collection: { enabled: true },

  shipping_options: [
    {
      shipping_rate_data: {
        display_name: "通常配送",
        type: "fixed_amount",
        fixed_amount: {
          amount:   shipping_cents,
          currency: "jpy"
        }
      }
    }
  ]
}

Stripe Checkoutのセッションに渡すためのparamsを作ります。

modeはpaymentです。
line_itemsに先ほどのline_itemsを渡します。

success_urlに決済が成功した場合のリダイレクト先、
cancel_urlにキャンセルされた場合のリダイレクト先を設定します。

allow_promotion_codes: true,

今回プロモーションコードないのですが、一応trueで入れています。

billing_address_collection: "required",

請求先住所を入力させます。

shipping_address_collection: { allowed_countries: [ "JP" ] },

配送先住所を入力させます。
日本に限定します。

phone_number_collection: { enabled: true },

電話番号を入力させます。

shipping_options: [
  {
    shipping_rate_data: {
      display_name: "通常配送",
      type: "fixed_amount",
      fixed_amount: {
        amount:   shipping_cents,
        currency: "jpy"
      }
    }
  }
]

オプションでは、通常配送としてshipping_centsに500円が入っているので
それが追加されます。

session_params[:metadata] = {
  user_id: current_user.id.to_s,
  shop_id: shop.id.to_s
}

メタデータとしてuser_itとshop_idを渡します。
これはWebhookが帰ってきたときに、
DBに挿入するときに使います。

if current_user.respond_to?(:stripe_customer_id) && current_user.stripe_customer_id.present?
  session_params[:customer] = current_user.stripe_customer_id
end

current_userが既にstripe_custromer_idを持っている場合、
session_params[:customer]に代入します。

if shop.stripe_connect_account_id.present?
  session_params[:payment_intent_data] = {
    application_fee_amount: application_fee_amount,
    transfer_data: { destination: shop.stripe_connect_account_id }
  }
end

プラットフォームが集めたお金を、
手数料取った後にショップのidに送る用ための設定をしています。

checkout = Stripe::Checkout::Session.create(session_params)
  render json: { url: checkout.url }, status: :ok
end

Stripe::Checkout::Session.create()にsession_paramsを渡して、
返り値をcheckoutに入れます。
フロントにcheckout.urlを返します。

stripe_checkouts_controllerの説明は以上です。

Next.jsの決済ボタン

"use client";

import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import CartItemType from "@/types/cartItem";

export default function CheckoutButton() {
  const handlePayment = async () => {
    const sessionRes = await fetch("/api/auth/session", { cache: "no-store" });
    const session = sessionRes.ok ? await sessionRes.json().catch(() => null) : null;
    if (!session?.user?.email) {
      toast.error("先にログインしてください");
      return;
    }
    const cartJson = localStorage.getItem("cart");
    const cart = cartJson ? JSON.parse(cartJson) : [];
    if (cart.length === 0) {
      toast.error("カートに商品がありません");
      return;
    }
    const cartShopId = localStorage.getItem("cartShopId");
    if (!cartShopId) {
      toast.error("カートのショップがありません");
      return;
    }
    const checkoutRes = await fetch("/api/stripe_checkouts", {
      method: "POST",
      body: JSON.stringify({
        cart: cart.map((item: CartItemType) => ({
          product_id: item.productId,
          quantity: item.quantity,
        })),
      }),
    });
    if (!checkoutRes.ok) {
      toast.error("決済に失敗しました");
      return;
    }
    const checkout = await checkoutRes.json();
    window.location.href = checkout.url;
  };
  return (
    <Button onClick={handlePayment}>
      <span className="font-bold">決済する</span>
    </Button>
  );
}

src/app/cart/CheckoutButton.tsxです。
cartには、商品のidと個数が格納されています。
cartの詳細はこちら
このページに詳細はcartの記事で詳しく解説しているので、割愛します。

BFFのProxyを通して、
/api/stripe_checkoutsにPOSTでfetchしています。
cartに商品のidと個数を格納して、
バックエンドに送信しています。
BFFのProxyの詳細はこちら

決済ボタンを押すと
handlePaymentが動くので、
カートの情報をRailsに送って、
Railsからcheckoutのurlが返ってくるので、
window.location.hrefを使って、リダイレクトします。

そうするとStripeの決済画面がでてきます。

Webhookとは

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

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

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

RailsのWebhookの処理

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

    case event.type
    when "account.updated"
      # 略
      end

    when "checkout.session.completed"
      session_obj = event.data.object
      session_id = session_obj["id"]
      begin
        session = Stripe::Checkout::Session.retrieve(
          {
            id: session_id,
            expand: [ "line_items.data.price.product" ]
          }
        )
      rescue => e
        Rails.logger.error("[StripeWebhook] Failed to retrieve session #{session_id}: #{e.message}")
        return head :ok
      end

      metadata = session.metadata || {}
      user = User.find_by(id: metadata["user_id"]) || User.find_by(email: session.customer_details&.email)

      line_items = session.line_items&.data || []
      product_ids = line_items.map { |li| li.price&.product&.metadata&.[]("product_id")&.to_i }.compact
      products_by_id = Product.where(id: product_ids).index_by(&:id)

      shop_id_from_products = products_by_id.values.first&.shop_id
      shop = Shop.find_by(id: (metadata["shop_id"] || shop_id_from_products))

      if user.nil? || shop.nil? || products_by_id.empty?
        Rails.logger.warn("[StripeWebhook] Missing user/shop/products for session #{session_id}")
        return head :ok
      end

      # Stripe計算値を優先利用(Checkout Sessionのサマリ)
      subtotal_cents = session["amount_subtotal"].to_i
      shipping_cents = session["total_details"]&.[]("amount_shipping").to_i
      tax_cents = session["total_details"]&.[]("amount_tax").to_i
      total_cents = session["amount_total"].to_i
      # フォールバック(送料が未設定の場合はENV、なければ500)
      if shipping_cents <= 0
        env_shipping = ENV["SHIPPING_CENTS"].to_i
        shipping_cents = env_shipping.positive? ? env_shipping : 500
        # 総額が未設定なら再計算
        total_cents = subtotal_cents + tax_cents + shipping_cents if total_cents <= 0
      end

      begin
        ActiveRecord::Base.transaction do
          order = Order.create!(
            user_id: user.id,
            shop_id: shop.id,
            status: 10, # paid
            subtotal_cents: subtotal_cents,
            tax_cents: tax_cents,
            shipping_cents: shipping_cents,
            total_cents: total_cents,
            placed_at: Time.current
          )

          shipping = session["shipping_details"] || session_obj["shipping_details"]
          if shipping && shipping["address"]
            addr = shipping["address"]
            OrderAddress.create!(
              order_id: order.id,
              full_name: shipping["name"].to_s.presence || user.name.to_s,
              phone: shipping["phone"].to_s.gsub(/-/, ""),
              postal_code: addr["postal_code"].to_s,
              country_code: addr["country"].to_s.presence || "JP",
              state: addr["state"].to_s,
              city: addr["city"].to_s,
              line1: addr["line1"].to_s,
              line2: addr["line2"].to_s
            )
          end

          line_items.each do |li|
            pid = li.price&.product&.metadata&.[]("product_id")&.to_i
            qty = li.quantity.to_i
            product = products_by_id[pid]
            next unless product && qty > 0

            OrderItem.create!(
              order_id: order.id,
              product_id: product.id,
              title_snapshot: product.name,
              unit_price_cents_snapshot: product.price_excluding_tax_cents,
              quantity: qty
            )
          end
        end
      rescue => e
        Rails.logger.error("[StripeWebhook] create order failed: #{e.class} #{e.message}")
        return head :ok
      end
    end

    head :ok
  end
end

stripe_webhooks_controller.rbの中身です。
ボリュームが結構あるので、
when “checkout.session.completed”の中だけ説明します。

全体の説明と、Stripe Conncetで使ったaccount.updatedの説明は、
前回のStripe Connectの記事で詳しく説明しているので、
そちらをごらんください。

when "checkout.session.completed"

Stripeがcheckout.session.competedのときに
Webhookを送ってくるので、
これを受け取った場合の処理をします。

session_obj = event.data.object
session_id = session_obj["id"]

webhookで送られたeventから、idを取得しています。
このidを使って、sessionを識別しています。


begin
  session = Stripe::Checkout::Session.retrieve(
    id: session_id,
    expand: ["line_items.data.price.product"]
  )
rescue => e
  Rails.logger.error(
    "[StripeWebhook] Failed to retrieve session #{session_id}: #{e.message}"
  )
  return head :ok
end

先ほどの session_id を Stripe::Checkout::Session.retrieve に渡すことで、
Stripe のサーバーから該当する Checkout Session の詳細情報が返ってきます。Webhook 経由のイベントデータは「サマリー情報」のみなので、
必要な line_items や product 情報を expand でまとめて取得しています。

例外が発生した場合は、ログに書き込みます。

metadata = session.metadata || {}
user = User.find_by(id: metadata["user_id"]) || User.find_by(email: session.customer_details&.email)

自分で、stripe_checkouts_controllerで設定したmetadataを取り出します。
DBからユーザーを特定して、userに入れます。

line_items = session.line_items&.data || []
product_ids = line_items.map { |li| li.price&.product&.metadata&.[]("product_id")&.to_i }.compact
products_by_id = Product.where(id: product_ids).index_by(&:id)

sessionから決済した商品の情報をproduct_idsとして抽出します。
DBからそれらの商品のインスタンスを取得して、
idとインスタンスで対応したインデックスを作成して、
products_by_idに入れます。

 shop_id_from_products = products_by_id.values.first&.shop_id
 shop = Shop.find_by(id: (metadata["shop_id"] || shop_id_from_products))

商品から、ショップのidを取得します。
DBからそのショップのインスタンスを取得して、shopに代入します。

if user.nil? || shop.nil? || products_by_id.empty?
  Rails.logger.warn("[StripeWebhook] Missing user/shop/products for session #{session_id}")
  return head :ok
end

userが無効な場合、
shopが無効な場合、
商品がない場合
ログに記入して、returnします。

# Stripe計算値を優先利用(Checkout Sessionのサマリ)
subtotal_cents = session["amount_subtotal"].to_i
shipping_cents = session["total_details"]&.[]("amount_shipping").to_i
tax_cents = session["total_details"]&.[]("amount_tax").to_i
total_cents = session["amount_total"].to_i

決済した金額は、
sessionで帰ってきたものを使います。

if shipping_cents <= 0
  env_shipping = ENV["SHIPPING_CENTS"].to_i
  shipping_cents = env_shipping.positive? ? env_shipping : 500
  # 総額が未設定なら再計算
  total_cents = subtotal_cents + tax_cents + shipping_cents if total_cents <= 0
end

送料が不正な場合
再取得、再計算させます。

begin
  ActiveRecord::Base.transaction do
    order = Order.create!(
      user_id: user.id,
      shop_id: shop.id,
      status: 10, # paid
      subtotal_cents: subtotal_cents,
      tax_cents: tax_cents,
      shipping_cents: shipping_cents,
      total_cents: total_cents,
      placed_at: Time.current
    )

beginで例外が発生した場合、rescueするようにします。
トランザクションを使います。
ordersという注文を記入するテーブルを作っているので、
注文情報を記入します。

shipping = session["shipping_details"] || session_obj["shipping_details"]
if shipping && shipping["address"]
  addr = shipping["address"]
  OrderAddress.create!(
    order_id: order.id,
    full_name: shipping["name"].to_s.presence || user.name.to_s,
    phone: shipping["phone"].to_s.gsub(/-/, ""),
    postal_code: addr["postal_code"].to_s,
    country_code: addr["country"].to_s.presence || "JP",
    state: addr["state"].to_s,
    city: addr["city"].to_s,
    line1: addr["line1"].to_s,
    line2: addr["line2"].to_s
  )
end

ordersテーブルにorder_addressという住所を入力するテーブルがあるので、
それに住所を挿入します。

  line_items.each do |li|
    pid = li.price&.product&.metadata&.[]("product_id")&.to_i
    qty = li.quantity.to_i
    product = products_by_id[pid]
    next unless product && qty > 0

    OrderItem.create!(
      order_id: order.id,
      product_id: product.id,
      title_snapshot: product.name,
      unit_price_cents_snapshot: product.price_excluding_tax_cents,
      quantity: qty
    )
  end
end

決済したアイテムも同様に、
order_itemsに挿入します。

rescue => e
  Rails.logger.error("[StripeWebhook] create order failed: #{e.class} #{e.message}")
  return head :ok
end

beginの中で例外が発生した場合、ログに書き込みます。

これでWebhookのコードは完了です。

Webhookを受け取る

ローカルでWebhookを受け取れるようにする場合は、
Stripe CLIを使います。
本番環境でWebhookを受け取れるようにする場合は、
Stripeにブラウザでログインして、イベントを作ります。
詳細は、前回のStirpe Connectで説明したので、
そちらを御覧ください。

決済成功ページ

success_url: "#{base_url}/cart/success?session_id={CHECKOUT_SESSION_ID}",

stripe_checkouts_controller.rbの中で、この用に記述しました。

こちらはWebhookとは別に、
Stripe Checkoutの決済が成功すると、
指定したURLにリダイレクトします。

"use client";

import { useEffect } from "react";
import PageTitle from "@/components/atoms/PageTitle";
import Link from "next/link";
import { Button } from "@/components/ui/button";

export default function CartSuccessPage() {
  useEffect(() => {
    try {
      localStorage.removeItem("cart");
      localStorage.removeItem("cartShopId");
    } catch {}
  }, []);

  return (
    <div className="py-8 inner">
      <PageTitle title="ご購入ありがとうございました" />
      <p className="mt-6 text-center">
        ご注文が完了しました。ご利用ありがとうございます。
      </p>
      <div className="mt-8 flex justify-center">
        <Button asChild className="font-bold">
          <Link href="/">トップへ戻る</Link>
        </Button>
      </div>
    </div>
  );
}

カートの中を空にして、
ユーザーに決済が完了したことを知らせ、
感謝を述べます。

まとめ

前々回で「カート」、前回で「Stripe Connect」を実装し、
今回の Stripe Checkout で 購入フローがついに完成 しました。

  • ユーザーが商品をカートに入れ
  • 決済画面へ進み
  • Stripe が支払いを処理し
  • Webhook 経由で Rails が注文を登録し
  • 売上はショップへ送金される

という EC サイトの基本の流れがすべて連結 した形です。
これでユーザーが実際に商品を購入できる基盤が整いました。
あとは必要に応じて、注文履歴・配送ステータス・メール通知などを追加できます。
三部作の最後として、今回でひとまず決済機能は完成です。

関連記事

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

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

プログラミング

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

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

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

プログラミング

3月は主に、AWSの基礎を学びました。自分である程度AWSを操作できるようになりました。

Githubに草を生やしそこねたときの救済措置

Githubに草を生やしそこねたときの救済措置

プログラミング

失敗しても大丈夫!救済措置があります。草を生やしまくって、モチベアップしよう!