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_checkout, only: [ :create ]
    post "stripe/webhooks/checkout", to: "stripe_webhooks_checkout#create"
  end
end

ルーティングを設定します。
今回使うのはstripe_checkoutのcreateと、
Webhookを受け取るstripe_webhooks_checkoutのcreateです。

stripe_checkoutは、
Checkout Session を作成し、
フロントエンドに決済ページのURLを返します。
フロントエンドはそのURLへリダイレクトすることで、
Stripe Checkoutの決済画面へ遷移します。

stripe_webhooks_checkoutは
Stripe が決済完了後に送信する Webhook を、
Rails 側で受け取るためのエンドポイントです。

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 = ENV["SHIPPING_CENTS"].to_i
    return render json: { error: "SHIPPING_CENTS is not set" }, status: :unprocessable_entity unless shipping_cents.positive?

    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",

      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
    permitted =
      if params[:stripe_checkout].present?
        params.require(:stripe_checkout).permit(cart: [ :product_id, :quantity ])
      else
        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にします。

def stripe_checkout_params
  permitted =
    if params[:stripe_checkout].present?
      params.require(:stripe_checkout).permit(cart: [ :product_id, :quantity ])
    else
      params.permit(cart: [ :product_id, :quantity ])
    end

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

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

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

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

これは最後の行なので、returnが省略されてて、ハッシュを返します。
何をやっているのかというと、
受け取った値をそのまま使わず、配列化・正規化して必要なキーだけ使うようにしています。

permitted[:cart] にはフロントから送られた cart の値が入りますが、
nil の場合でも安全に扱えるように Array() で必ず配列に変換しています。
その後、各要素をハッシュとして正規化し、
必要なキー(product_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 が 0 以下の場合は、
その行は無視して次の item に進みます(エラーにはしない設計です)。
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
product_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

これはプラットフォームの手数料です。
例えば PLATFORM_FEE_PERCENT を “4” にすると 、
fee_percent=4.0 になり、計算時に /100 して 4%(0.04)として扱います。

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

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",

  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にキャンセルされた場合のリダイレクト先を設定します。

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_idと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_customer_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

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_checkout", {
      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_checkoutにPOSTでfetchしています。
cartに商品のidと個数を格納して、
バックエンドに送信しています。
BFFのProxyの詳細はこちら

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

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

Webhookとは

Webhook は、Stripe が処理したイベントを Rails に自動で通知してくれる仕組みです。
Stripe Checkout では、
支払いが成功した時などに、イベントがStripe 内で発生します。
サーバー側(Rails)はこれを自分で取りに行く必要はなく、
Stripe が POST リクエストとして Rails に送ってくることで状態を同期できます。

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

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

Stripe CheckoutのWebhookの処理

class Api::StripeWebhooksCheckoutController < ActionController::API

  def create
    payload = request.raw_post
    signature = request.env["HTTP_STRIPE_SIGNATURE"]
    secret = ENV["STRIPE_CHECKOUT_WEBHOOK_SECRET"].presence

    unless secret.present?
      Rails.logger.warn("Stripe checkout 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 checkout webhook error: #{e.message}")
      return head :bad_request
    end

    return head :ok unless event.type == "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("[StripeCheckoutWebhook] 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("[StripeCheckoutWebhook] 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("[StripeCheckoutWebhook] create order failed: #{e.class} #{e.message}")
      return head :ok
    end

    head :ok
  end
end

stripe_webhooks_checkout_controller.rbの中身です。
詳しく見ていきます。

payload = request.raw_post

Railsが加工する前のHTTPリクエスト本文(生のJSON)をそのまま取得する処理です。
StripeのWebhookでは、この生データを使わないと署名検証が失敗するため必須になります。

signature = request.env["HTTP_STRIPE_SIGNATURE"]

StripeからRailsに送信したリクエストの署名です。
本物であることを確認するために使います。

secret = ENV["STRIPE_CHECKOUT_WEBHOOK_SECRET"]

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

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

シークレットがない場合はログに出力して、returnします。

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

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

return head :ok unless event.type == "checkout.session.completed"

event.typeがcheckout.session.completedの時だけ、
処理が続きます。
そうではない場合、returnして終了します。

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("[StripeCheckoutWebhook] 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("[StripeCheckoutWebhook] 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

配送先住所は order_addresses テーブル(OrderAddress)に保存します。

  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("[StripeCheckoutWebhook] create order failed: #{e.class} #{e.message}")
  return head :ok
end

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

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

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

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

brew install stripe/stripe-cli/stripe

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

stripe login

Stripeにログインします。

stripe listen --events checkout.session.completed \
  --forward-to http://localhost:3000/api/stripe/webhooks/checkout

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

デモ用のクレジットカードの、4242 4242 4242 4242で商品を購入してみて、
ショップ側も、カスタマー側も、
ダッシュボードに購買履歴が出ていれば成功です。

テストモードでWebhookを受け取る

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

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

イベントのリッスン先は左側のお客様のアカウントを選びます。

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

イベントは
checkout.session.completedを選びます。

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

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

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

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

Stripe ConnectとStripe Checkoutを同時に扱う場合は、
イベントの送信先は2つ作って、役割を分担します。

決済成功ページ

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年4月の学習の振り返り

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

プログラミング

4月は、AWS、自動デプロイ、Terraformなどのスキルを学習。インフラの理解が深まる。

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

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

プログラミング

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

Next.js × Rails|ECS/FargateをGitHub Actionsで自動デプロイ

Next.js × Rails|ECS/FargateをGitHub Actionsで自動デプロイ

プログラミング

自作アプリを GitHub Actions + OIDC で ECS/Fargate に CI/CD 自動デプロイする方法を解説。