Rails7+Stripeで決済機能を実装してみた

# Rails7+Stripeで決済機能を実装してみた
# バージョン
* **Ruby**: 3.1.4
* **Rails**: 7.1.5
* **データベース**: MySQL (mysql2 ~> 0.5)
# 開発したアプリの概要
![screencapture-localhost-3001-2025-03-31-10_42_47.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2086265/cc4f0be9-2baf-43ed-9c94-09fcdba6189f.png)
今回は決済機能を作ってみたいと思い、個人開発を行ったのでアウトプットしていきます
作成したアプリは僕が現役神主であることから『御祈祷予約アプリ』です
以下の流れで御祈祷予約を行うようにしています
* ログイン(devise)
↓
* トップ画面
↓
* 新規御祈祷予約画面
↓
* 予約内容確認画面
↓
* Stripe決済画面
↓
* 予約一覧画面

# 決済処理までの流れ
stripeのライブラリをインストールしておきます。
```Gemfile
gem 'stripe'
```
### 1. ログイン後、トップ画面の『新規予約ボタン』を押下
```app/views/home/index.html.slim
.container
  h1.page-title 神社御祈祷予約システム

  .action-cards
    .card
      h2.card-title 新規御祈祷予約
      p.card-text ご希望の御祈祷の予約を承ります。
      = link_to "新規予約", new_reservation_path, class: "button button-primary"
```

### 2.以下のメソッドが呼び出されて新規御祈祷予約画面が表示される
```app/controllers/reservations_controller.rb
  def new
    @reservation = current_user.reservations.build
    # キャンセルされた場合の通知
    flash.now[:alert] = "決済がキャンセルされました。再度お試しください。" if params[:canceled]
  end
```
![screencapture-localhost-3001-reservations-new-2025-04-05-12_02_45.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2086265/8e9c8f47-d295-43d0-9b5b-ac37c1619cbb.png)

```app/views/reservations/new.html.slim
.reservation-container
  h2 新規御祈祷予約

  = form_with(model: @reservation, local: true) do |f|
    - if @reservation.errors.any?
      .error-messages
        ul
          - @reservation.errors.full_messages.each do |message|
            li = message

    .form-group
      = f.label :prayer_type_id, "御祈祷の種類"
      = f.collection_select :prayer_type_id, PrayerType.active, :id, :name, { prompt: "選択してください" }, class: "form-select"


    .form-actions
      = f.submit "予約内容を確認する", class: "button button-primary"

= content_for :scripts do
  javascript:
    window.stripeKey = "#{Rails.configuration.stripe[:publishable_key]}";
```

### 3.新規御祈祷予約画面の項目を埋めていき『予約内容を確認する』ボタン押下で確認画面に遷移
```app/controllers/reservations_controller.rb
  def create
    # フォームから送信された予約情報を保存
    @reservation = current_user.reservations.build(reservation_params)
    
    # フォームの検証
    if @reservation.valid?
      # 有効な場合は決済ページに遷移するためのフォームを表示
      render :confirm
    else
      render :new, status: :unprocessable_entity
    end
  end
```
![screencapture-localhost-3001-reservations-2025-04-05-13_02_43.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2086265/fbeb72e4-1a7e-437f-ab01-54f62452870a.png)

```app/views/reservations/confirm.html.slim
.reservation-container
  h2 予約内容の確認

  .reservation-details
    p
      strong 御祈祷の種類:
      = @reservation.prayer_type.name
    
    p
      strong 備考:
      = @reservation.note

  .payment-actions
    = form_tag checkouts_path, method: :post do
      = hidden_field_tag :prayer_type_id, @reservation.prayer_type_id
      = hidden_field_tag :reserved_date, @reservation.reserved_date
      = hidden_field_tag :time_slot_id, @reservation.time_slot_id
      = hidden_field_tag :number_of_people, @reservation.number_of_people
      = hidden_field_tag :note, @reservation.note
      = submit_tag '決済に進む', class: 'button button-primary', data: { turbo: false, turbo_confirm: '決済画面に移動しますか？' }
    
    = link_to '修正する', new_reservation_path(reservation: { prayer_type_id: @reservation.prayer_type_id, reserved_date: @reservation.reserved_date, time_slot_id: @reservation.time_slot_id, number_of_people: @reservation.number_of_people, note: @reservation.note }), class: 'button button-secondary'
```

### 4.決済に進むボタン押下で以下のコントローラーにリクエストが送られてStripeの決済画面に遷移します
```app/controllers/checkouts_controller.rb
class CheckoutsController < ApplicationController
  before_action :authenticate_user!

  def create
    # 予約情報をセッションに保存
    session[:reservation_params] = {
      prayer_type_id: params[:prayer_type_id],
      reserved_date: params[:reserved_date],
      time_slot_id: params[:time_slot_id],
      number_of_people: params[:number_of_people],
      note: params[:note]
    }
    
    # プレビュー用に一時オブジェクトを作成
    prayer_type = PrayerType.find(params[:prayer_type_id])
    time_slot = TimeSlot.find(params[:time_slot_id])
    
    # チェックアウトセッションの作成
    checkout_session = create_session(prayer_type, time_slot, params)
    
    # Stripeの決済ページにリダイレクト
    # 作成されたStripeのチェックアウトセッションURLへユーザーをリダイレクト
    # allow_other_host: trueは外部ドメイン（Stripe）へのリダイレクトを許可するパラメータ
    redirect_to checkout_session.url, allow_other_host: true
  end

  private

  def create_session(prayer_type, time_slot, params)
    # Stripeセッションを作成
    # セッションはStripeサーバー上で安全に保管され、決済が完了するまで有効です
    # 決済情報（金額、商品説明など）と予約情報をStripe側で関連付けて管理できます
    # Stripe::Checkout::Session.createメソッド
    Stripe::Checkout::Session.create({
      # 顧客情報：ユーザーIDとメールアドレス
      client_reference_id: current_user.id,
      customer_email: current_user.email,
      # 決済モード：支払いのみ
      mode: 'payment',
      # 支払い方法：クレジットカードのみ
      payment_method_types: ['card'],
      # 商品情報：1件の商品
      line_items: [{
        quantity: 1,
        price_data: {
          currency: 'jpy',
          unit_amount: prayer_type.price,
          product_data: {
            name: prayer_type.name,
            description: "#{params[:reserved_date]} #{time_slot.formatted_time}",
            metadata: {
              prayer_type_id: prayer_type.id,
              time_slot_id: time_slot.id
            }
          }
        }
      }],
      # メタデータ作成
      metadata: {
        prayer_type_id: prayer_type.id,
        reserved_date: params[:reserved_date],
        time_slot_id: time_slot.id,
        number_of_people: params[:number_of_people],
        note: params[:note],
        user_id: current_user.id
      },
      # 決済成功時のリダイレクト先
      success_url: "#{root_url}reservations?success=true",
      # 決済キャンセル時のリダイレクト先
      cancel_url: "#{root_url}reservations/new?canceled=true"
    })
  end
end 
```
![スクリーンショット 2025-04-05 13.36.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2086265/1d857c42-28ca-43db-8590-bfaf4631282d.png)

### 5. 決済完了後の処理
決済処理が完了すると以下のURLにリダイレクトされます。
`success_url: "#{root_url}reservations?success=true",`

また今回はStripeのWebhookを設定しました。

決済処理が完了するとStripeから以下のイベントが`localhost:3001/webhooks/stripe`に自動で送られてくるように設定しました。

![スクリーンショット 2025-04-05 14.09.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2086265/ccb94214-1e68-4488-b14e-77173c4264ce.png)

1. 決済の課金処理が成功 (`charge.succeeded`)
2. 支払い意図が成功 (`payment_intent.succeeded`)
3. チェックアウトセッションが完了 (`checkout.session.completed`)
4. 新しい支払い意図が作成 (`payment_intent.created`)

`localhost:3001/webhooks/stripe`に上記のイベントが送られてくると、以下のコントローラーで予約完了の新しいレコードが作成されるようにしました。

```app/controllers/webhooks_controller.rb
class WebhooksController < ApplicationController
  # CSRFトークン検証をスキップ（外部サービスからのリクエストのため）
  skip_before_action :verify_authenticity_token

  def stripe
    # リクエストボディを読み込む
    request_body = request.body.read
    # Stripeの署名ヘッダーを取得
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    # Stripeのエンドポイントシークレットを取得
    endpoint_secret = ENV['STRIPE_WEBHOOK_SECRET']
    
    begin
      # Stripeのイベントを解析
      # Stripe::Webhook.construct_eventメソッド
      event = Stripe::Webhook.construct_event(
        request_body, sig_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      # JSON解析エラー時の処理
      render json: {error: e.message}, status: 400
      return
    rescue Stripe::SignatureVerificationError => e
      # 署名検証エラー時の処理
      render json: {error: e.message}, status: 400
      return
    end

    # イベント処理
    if event.type == 'checkout.session.completed'
      # 決済が完了した場合の処理
      handle_checkout_session_completed(event.data.object)
    end

    # リクエストを受け取ったことを確認
    render json: {received: true}
  end

  private

  # 決済が完了した場合の処理
  def handle_checkout_session_completed(session)
    # セッションからメタデータを取得
    metadata = session.metadata
    
    # 予約レコードを作成
    reservation = Reservation.create!(
      user_id: metadata.user_id,
      prayer_type_id: metadata.prayer_type_id,
      reserved_date: metadata.reserved_date,
      time_slot_id: metadata.time_slot_id,
      number_of_people: metadata.number_of_people,
      note: metadata.note,
      status: 'confirmed',
      payment_intent_id: session.payment_intent
    )
  end
end 
```
上記の処理を含め決済処理が完了したら、予約一覧画面にリダイレクトされ、予約のレコードが作成されます。

![スクリーンショット 2025-04-05 14.41.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2086265/e6a20f0a-548d-4fe2-8940-9d66d333d499.png)

# まとめ
今回はstripeを用いた決済とstripeのwebhookを用いて決済処理を実装していきました。
stripeのライブラリを用いることで決済画面を作成することなく実装することができました。
stripe便利なのでぜひ使ってみてください！！
重要なのはstripeのメソッドを使うことです。

```app/controllers/checkouts_controller.rb
def create_checkout_session(prayer_type, time_slot, params)
    # このメソッド
    Stripe::Checkout::Session.create({
      # 顧客情報：ユーザーIDとメールアドレス
      client_reference_id: current_user.id,
      customer_email: current_user.email,
      # 決済モード：支払いのみ
      mode: 'payment',
      # 支払い方法：クレジットカードのみ
      payment_method_types: ['card'],
      # 商品情報：1件の商品
      line_items: [{
        quantity: 1,
        price_data: {
          currency: 'jpy',
          unit_amount: prayer_type.price,
          product_data: {
            name: prayer_type.name,
            description: "#{params[:reserved_date]} #{time_slot.formatted_time}",
            metadata: {
              prayer_type_id: prayer_type.id,
              time_slot_id: time_slot.id
            }
          }
        }
      }],

      # 予約情報のメタデータ作成
      metadata: {
        prayer_type_id: prayer_type.id,
        reserved_date: params[:reserved_date],
        time_slot_id: time_slot.id,
        number_of_people: params[:number_of_people],
        note: params[:note],
        user_id: current_user.id
      },
      # 決済成功時のリダイレクト先
      success_url: "#{root_url}reservations?success=true",
      # 決済キャンセル時のリダイレクト先
      cancel_url: "#{root_url}reservations/new?canceled=true"
    })
  end
  ```

  ```app/controllers/webhooks_controller.rb
  # Stripeのイベントを解析
      event = Stripe::Webhook.construct_event(
        request_body, sig_header, endpoint_secret
      )
  ```