Chapter 12

決済処理を実装しよう

FarStep
FarStep
2023.02.19に更新

はじめに

本 Chapter では、決済処理を実装していきます。
前 Chapter で、カート機能を実装しました。カートに追加された商品を Stripe を使って購入する処理を実装します。

モデル・テーブルを作成しよう

Order

最初に Order モデル・orders テーブルの作成を行います。
コンテナが立ち上がっていることが確認できましたら、ecommerce_web コンテナに入ります。

$ docker-compose run --rm web bash
Creating ecommerce_web_run ... done
ruby@884097f5536a:/app$

無事コンテナに入ることができましたら、下記コマンドを実行してください。

$ rails g model Order name postal_code prefecture address1 address2 postage:integer billing_amount:integer status:integer customer:references

顧客と注文テーブルは1対多の関係にあるため、references 型 を使って外部キー制約のついたカラムを作成しています。

上記コマンドを実行するとマイグレーションファイルが生成されるはずです。
db/migrate/xxxxxxxxxxxxxx_create_orders.rb を開いて下記コードを記述してください。
(x には数字が入ります)

db/migrate/xxxxxxxxxxxxxx_create_orders.rb
class CreateOrders < ActiveRecord::Migration[7.0]
  def change
    create_table :orders do |t|
      t.string :name, null: false
      t.string :postal_code, null: false
      t.string :prefecture, null: false
      t.string :address1, null: false
      t.string :address2
      t.integer :postage, null: false
      t.integer :billing_amount, null: false
      t.integer :status, null: false, default: 0
      t.references :customer, null: false, foreign_key: true

      t.timestamps
    end
  end
end

address2 カラム以外には、null: false 制約を付与しました。
address2 カラムは Stripe の決済画面で入力が任意のため null を許可しています。
また、status は、enum で管理しますので、integer 型であること、デフォルト値を設定している点に注意です。

マイグレーションファイルの編集が完了したら、下記コマンドを実行して orders テーブルを作成しましょう。

$ rails db:migrate

db/schema.rb に 下記のように orders テーブルが追加されていれば OK です。

db/schema.rb
create_table "orders", force: :cascade do |t|
  t.string "name", null: false
  t.string "postal_code", null: false
  t.string "prefecture", null: false
  t.string "address1", null: false
  t.string "address2"
  t.integer "postage", null: false
  t.integer "billing_amount", null: false
  t.integer "status", default: 0, null: false
  t.bigint "customer_id", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["customer_id"], name: "index_orders_on_customer_id"
end

続いて、アソシエーションの記述を行います。
app/models/order.rb を開いてください。
マイグレーションファイルを作成する際に、references 型を使ったため、既に下記コードが記述されているはずです。

app/models/order.rb
class Order < ApplicationRecord
  belongs_to :customer
end

ただし、has_many の記述はされていないため、手動で追加しましょう。
app/models/customer.rb を開いて下記コードを追加してください。

app/models/customer.rb
class Customer < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  with_options presence: true do
    validates :name
    validates :status
  end
  enum status: {
    normal: 0,
    withdrawn: 1,
    banned: 2
  }
  has_many :cart_items, dependent: :destroy
+ has_many :orders, dependent: :destroy
end

これで、下記のようなアソシエーションが実現できました。

続いて、order モデルに enum の設定を行いましょう。
app/models/order.rb を開いて、下記コードを記述してください。

app/models/order.rb
class Order < ApplicationRecord
  belongs_to :customer
+ enum status: {
+   waiting_payment: 0,
+   confirm_payment: 1,
+   shipped: 2,
+   out_of_delivery: 3,
+   delivered: 4
+ }
end

注文ステータスとは、

  • waiting_payment(入金待ち)
  • confirm_payment(入金確認)
  • shipped(出荷済み)
  • out_for_delivery(配送中)
  • delivered(配達済み)

という五つの状態のいずれかです。デフォルト値は、0(waiting_payment)と設定しましたね。

OrderDetail

次に OrderDetail モデル・order_details テーブルの作成を行います。
下記コマンドを実行してください。

$ rails g model OrderDetail price:integer quantity:integer order:references product:references

上記コマンドを実行するとマイグレーションファイルが生成されるはずです。
db/migrate/xxxxxxxxxxxxxx_create_order_details.rb を開いて下記コードを記述してください。
(x には数字が入ります)

db/migrate/xxxxxxxxxxxxxx_create_order_details.rb
class CreateOrderDetails < ActiveRecord::Migration[7.0]
  def change
    create_table :order_details do |t|
      t.integer :price, null: false
      t.integer :quantity, null: false
      t.references :order, null: false, foreign_key: true
      t.references :product, null: false, foreign_key: true

      t.timestamps
    end
  end
end

price カラムと quantity カラムに null: false を付与しました。
マイグレーションファイルの編集が完了したら、下記コマンドを実行して order_details テーブルを作成しましょう。

$ rails db:migrate

db/schema.rb に 下記のように order_details テーブルが追加されていれば OK です。

db/schema.rb
create_table "order_details", force: :cascade do |t|
  t.integer "price", null: false
  t.integer "quantity", null: false
  t.bigint "order_id", null: false
  t.bigint "product_id", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
  t.index ["order_id"], name: "index_order_details_on_order_id"
  t.index ["product_id"], name: "index_order_details_on_product_id"
end

続いて、アソシエーションの記述を行います。
app/models/order_detail.rb を開いてください。
マイグレーションファイルを作成する際に、references 型を使ったため、既に下記コードが記述されているはずです。

app/models/order_detail.rb
class OrderDetail < ApplicationRecord
  belongs_to :order
  belongs_to :product
end

ただし、has_many の記述はされていないため、手動で追加しましょう。
app/models/order.rb を開いて下記コードを追加してください。

app/models/order.rb
class Order < ApplicationRecord
  belongs_to :customer
  enum status: {
    waiting_payment: 0,
    confirm_payment: 1,
    shipped: 2,
    out_of_delivery: 3,
    delivered: 4
  }
+ has_many :order_details, dependent: :destroy
end

同様に app/models/product.rb を開いて下記コードを追加してください。

app/models/product.rb
class Product < ApplicationRecord
  with_options presence: true do
    validates :name
    validates :description
    validates :price
    validates :stock
    validates :image
  end
  has_one_attached :image
  scope :price_high_to_low, -> { order(price: :desc) }
  scope :price_low_to_high, -> { order(price: :asc) }
  has_many :cart_items, dependent: :destroy
+ has_many :order_details, dependent: :destroy
end

これで、下記のようなアソシエーションが実現できました。

これで作成しなければならないモデル・テーブルは以上です 🎉
これより先の Chapter では、コントローラとビューのみを作成していきます。

Stripe のセットアップをしよう

続いて、Stripe のセットアップを行います。
アプリケーションの中で Stripe を使うためには、API キーが必要になるため、取得しましょう。

まずは、Stripe に新規登録する必要があります。
https://dashboard.stripe.com/register

新規登録が完了しましたら、ホームにアクセスしてください。
https://dashboard.stripe.com/test/dashboard

上記画面の右下に「公開可能キー」と「シークレットキー」があります。この二つの値をアプリケーションに登録します。
また、テスト環境であることを確認してください。

キーの値は機密情報です。
そのため今回は、機密情報を暗号化し、安全に保護する Credentials という機能を使います。Credentials を使えば、復号化キーを紛失、もしくは盗難にあわない限り、機密情報を保護することができます。

https://guides.rubyonrails.org/security.html#environmental-security

下記コマンドを実行して、Credentials を作成しましょう。

$ EDITOR="vi" bin/rails credentials:edit -e development

EDITOR="vi" はエディタを、-e development は環境を指定しています。上記コマンドを実行すると、以下の二つのファイルが生成されるはずです。

  • config/credentials/development.key(秘密鍵)
  • config/credentials/development.yml.enc(公開鍵)

現在、下記のようにエディタが立ち上がっているはずです。

続いて、「i」キーを押して、インサートモードに入り、下記コードを記述してください。

# aws:
#   access_key_id: 123
#   secret_access_key: 345

stripe:
  publishable_key: pk_test_xxxxx
  secret_key: sk_test_xxxxx

publishable_keyには 公開可能キー を、secret_key には シークレットキー を設定してください。

キーが設定できたら、「esc」を押して、「wq!」で保存しましょう。Credentials を閉じると、下記のようなログが表示されているはずです。

ruby@bb885d66e366:/app$ EDITOR="vi" bin/rails credentials:edit -e development
Adding config/credentials/development.key to store the encryption key: f670674081f46cc683cb9594dca3f10b

Save this in a password manager your team can access.

If you lose the key, no one, including you, can access anything encrypted with it.

      create  config/credentials/development.key

Ignoring config/credentials/development.key so it won't end up in Git history:

      append  .gitignore

File encrypted and saved.
ruby@bb885d66e366:/app$

それでは、キーが設定できたかどうか確認してみましょう。下記コマンドを実行してコンソールにログインしてください。

$ rails c

コンソールに入りましたら、publishable_keysecret_key が設定できているかを確認します。

irb(main):001:0> Rails.application.credentials.dig(:stripe, :publishable_key)
=> "pk_test_xxxxx"
irb(main):002:0> Rails.application.credentials.dig(:stripe, :secret_key)
=> "sk_test_xxxxx"

Credentials を読み込む際には dig メソッドを使いました。
コマンドを実行して、Stripe のキーが返ってくれば正しくキーが設定されています。

あとは、サーバを起動した際に Credentials に設定した Stripe のキーを読み込むだけです。
config/initializers/stripe.rb を作成して下記コードを記述しましょう。

$ touch config/initializers/stripe.rb
config/initializers/stripe.rb
Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
Stripe.api_version = '2022-11-15'

Stripe.api_version には、最新のバージョンを指定してください。
バージョンについては、下記のドキュメントをご覧ください。
https://stripe.com/docs/api/versioning?lang=ruby

記述が完了しましたら、サーバを再起動 してください。
サーバ起動時にエラーが吐かれず、正常にコンテナが立ち上がれば OK です。

Stripe のセットアップは以上です。
これで Stripe の決済処理が使えるようになっています 🎉

決済処理を実装しよう

これより Stripe を使った決済処理を実装していきます。
処理の流れは下記ドキュメントに記載されている通りです。
https://stripe.com/docs/payments/checkout/how-checkout-works?locale=ja-JP

  1. 顧客が商品を購入する準備ができると、Stripe の新しい Checkout セッションを作成する。
  2. Checkout セッションは、顧客を Stripe がオンラインで提供する決済ページにリダイレクトさせる URL を提供する。
  3. 顧客は決済ページに決済情報を入力し、取引を完了させる。(今回は、注文の宛名、住所、クレジットカード情報を入力)
  4. 取引終了後、Webhook は checkout.session.completed イベントを使用して注文のフルフィルメントを実行する。
  5. Webhook のイベントを検知し、注文情報をデータベースに登録する。
  6. 顧客を注文完了ページにリダイレクトさせる。

処理の流れの中で、Webhook という単語が登場しました。
Webhook とは、Stripe からイベントを受信する HTTP エンドポイントです。
Stripe は HTTPS を使用して、これらの通知を JSON ペイロードとしてアプリに送信します。次に、これらの通知を使用して、バックエンドシステムで操作を実行することができるのです。
今回は、checkout.session.completed をトリガーにして、注文情報をデータベースに登録します。

上記の決済処理の流れを覚えておいてください。
この流れ通りにコントローラを作成していきます。

CheckoutsController を作ろう

まずは、Stripe の新しい Checkout セッションを作成するためのコントローラを作成します。
下記コマンドを実行してください。

$ rails g controller customer/checkouts --no-helper --skip-template-engine

CheckoutsController を作成する際に、--skip-template-engine オプションを付与しているのは、決済画面は Stripe が用意するものを使うためです。ビューは不要ということですね。

続いて、ルーティングの設定を行います。
config/routes.rb を開いて下記コードを追加してください。

config/routes.rb
Rails.application.routes.draw do
  devise_for :admins, controllers: {
    sessions: 'admin/sessions'
  }
  devise_for :customers, controllers: {
    sessions: 'customer/sessions',
    registrations: 'customer/registrations'
  }
  root to: 'pages#home'
  namespace :admin do
    resources :products, only: %i[index show new create edit update]
  end
  scope module: :customer do
    resources :products, only: %i[index show]
    resources :cart_items, only: %i[index create destroy] do
      member do
        patch 'increase'
        patch 'decrease'
      end
    end
+   resources :checkouts, only: [:create]
  end

  get '/up/', to: 'up#index', as: :up
  get '/up/databases', to: 'up#databases', as: :up_databases
end

CheckoutsController は、create アクションのみとなります。
生成されたパスを見てみましょう。

$ rails routes | grep checkouts

下記のようなパスが出力されれば OK です。

checkouts POST   /checkouts(.:format)    customer/checkouts#create

では次に、コントローラの各アクションの中身を記述していきます。
app/controllers/customer/checkouts_controller.rb を開いて下記コードを記述してください。

app/controllers/customer/checkouts_controller.rb
class Customer::CheckoutsController < ApplicationController
  before_action :authenticate_customer!

  def create
    line_items = current_customer.line_items_checkout
    session = create_session(line_items)
    # Allow redirection to the host that is different to the current host
    redirect_to session.url, allow_other_host: true
  end

  private

  def create_session(line_items)
    Stripe::Checkout::Session.create(
      client_reference_id: current_customer.id,
      customer_email: current_customer.email,
      mode: 'payment',
      payment_method_types: ['card'],
      line_items:,
      shipping_address_collection: {
        allowed_countries: ['JP']
      },
      shipping_options: [
        {
          shipping_rate_data: {
            type: 'fixed_amount',
            fixed_amount: {
              amount: 500,
              currency: 'jpy'
            },
            display_name: 'Single rate'
          }
        }
      ],
      success_url: root_url,
      cancel_url: "#{root_url}cart_items"
    )
  end
end

Stripe 特有の書き方となりますので、一行一行確認していきます。

まず、create アクション内の一行目ですが、current_customer に対して、line_items_checkout というメソッドを実行しています。現在、このメソッドは定義されていないので、定義しましょう。app/models/customer.rb を開いて下記コードを記述してください。

app/models/customer.rb
class Customer < ApplicationRecord
  # Include default devise modules. Others available are:
  # :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  with_options presence: true do
    validates :name
    validates :status
  end
  enum status: {
    normal: 0,
    withdrawn: 1,
    banned: 2
  }
  has_many :cart_items, dependent: :destroy
  has_many :orders, dependent: :destroy

+ def line_items_checkout
+   cart_items.map do |cart_item|
+     {
+       quantity: cart_item.quantity,
+       price_data: {
+         currency: 'jpy',
+         unit_amount: cart_item.product.price,
+         product_data: {
+           name: cart_item.product.name,
+           metadata: {
+             product_id: cart_item.product_id
+           }
+         }
+       }
+     }
+   end
+ end
end

上記で定義した、line_items_checkout

{
  quantity: cart_item.quantity,
  price_data: {
    currency: 'jpy',
    unit_amount: cart_item.product.price,
    product_data: {
      name: cart_item.product.name,
      metadata: {
        product_id: cart_item.product_id
      }
    }
  }
}

というカート内商品の情報を配列で返すメソッドです。なぜこのような配列を作成しているかというと、Checkout セッションを作成する際に必要だからです。オブジェクトのプロパティは、Stripe 側で指定されています。

https://stripe.com/docs/payments/checkout/migrating-prices#server-side-code-for-inline-items

line_items_checkout メソッドが定義できたところで、CheckoutsController に戻りましょう。
create アクションの二行目で、Checkout セッションを作成しています。
create_session メソッドは、CheckoutsController の private 以下に定義されていますね。

def create_session(line_items)
  Stripe::Checkout::Session.create(
    client_reference_id: current_customer.id,
    customer_email: current_customer.email,
    mode: 'payment',
    payment_method_types: ['card'],
    line_items:,
    shipping_address_collection: {
      allowed_countries: ['JP']
    },
    shipping_options: [
      {
        shipping_rate_data: {
          type: 'fixed_amount',
          fixed_amount: {
            amount: 500,
            currency: 'jpy'
          },
          display_name: '全国一律'
        }
      }
    ],
    success_url: root_url,
    cancel_url: "#{root_url}cart_items"
  )
end

Checkout セッションを作成する際に登場するオブジェクトのプロパティの説明は下記の通りです。

キー名 説明
client_reference_id チェックアウトセッションを参照するための一意の文字列。今回は、顧客のIDとしました。
customer_email 顧客のメールアドレス
mode チェックアウトセッションのモード(paymentsetupsubscriptionがあります)
payment_method_types 受け入れることができる支払い方法の種類のリスト
line_items 顧客が購入した品目。line_items_checkout で作成しましたね。
shipping_address_collection.allowed_countries 配送先住所として入力できる国
shipping_options.shipping_rate_data 配送オプションの配送料作成に渡されるパラメータ
success_url 支払いが成功した後に、リダイレクトされるURL
cancel_url 決済をキャンセルした後に、リダイレクトされるURL

Stripe はドキュメントが非常に丁寧ですので、オブジェクトのプロパティについては下記も参考にしてください。
https://stripe.com/docs/api/checkout/sessions/object

そして、create アクションの三行目では、Stirpe が用意する決済画面へのリンクにリダイレクトしています。
このとき、allow_other_host: true としているのは、現在のホストと異なるホストへのリダイレクトを許可するためです。

これで、CheckoutsController の create アクションに関する説明は終了です。

決済を行ってみよう

それでは、決済を行うためのボタンを用意して、実際にカート内商品を購入してみましょう。
app/views/customer/cart_items/index.html.erb を開いて、請求金額の表示を囲っている div タグの後に、「Checkout ボタン」を追加してください。

app/views/customer/cart_items/index.html.erb
<div class="flex justify-between my-6">
  <span class="font-semibold text-lg uppercase">total</span>
  <span class="font-semibold text-lg"><%= number_to_currency(@total + POSTAGE, unit: "¥", strip_insignificant_zeros: true) %></span>
</div>
<%= button_to checkouts_path, data: { turbo: false }, class:'w-full cursor-pointer focus:outline-none text-white bg-green-700 hover:bg-green-800 focus:ring-4 focus:ring-green-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2' do %>
  Checkout
<% end %>

これで、カート内商品一覧画面に、決済処理を行うためのボタンが表示されるはずです。
http://localhost:8000/cart_items にアクセスして確認しましょう。

無事ボタンが表示されたら、クリックして Stripe の決済画面へ遷移するか試してみてください。
カート内商品の合計金額・送料が正確に反映されていれば成功です。🎉
こうして Stripe の画面がうまく表示できると感動しますよね!

それでは、住所とクレジットカード情報を入力しましょう。
住所は適当で構いません。ただし、クレジットカードはテスト環境で使用可能なものを使ってください。今回は、

  • カード番号:4242 4242 4242 4242
  • 有効期限:現在時刻より未来の年月
  • セキュリティーコード:任意の三桁

を入力します。詳しくは、下記ドキュメントをご覧ください。

https://stripe.com/docs/testing?locale=ja-JP

下記のように必要事項を入力したら、「支払う」ボタンを押してください。
「それ以降の住所」については、空欄でも構いません。

「支払う」ボタンを押下した後に、ルートパスにリダイレクトされれば決済が成功しているはずです。
(決済処理が終了した後にリダイレクトされる画面は、後ほど変更します。)

https://dashboard.stripe.com/test/payments にアクセスして、支払いデータが生成されているかどうか確認してみましょう。

たしかに一件レコードが追加されていますね。クリックして詳細を見てみましょう。
支払い内容の詳細が記載されています。

配送先の住所、どんな商品を何個購入したのか、送料はいくらだったのか等が正確に記載されていますね。Stripe による決済処理成功です 🎉
しかし、注文情報はデータベースに保存されていません。カート内商品も決済が完了したら、削除する必要があります。次節では、Stripe の決済情報をもとにアプリケーションのデータベースに反映させる処理を記述していきます。

WebhooksController を作ろう

Stripe は、イベントが発生すると、Webhook を使用してアプリケーションに通知します。
今回は、Stripe から送信される checkout.session.completed をトリガーにして、注文情報をデータベースに登録します。
そこで、Stripe からイベントを受信する HTTP エンドポイントを作成しましょう。

下記コマンドを実行して、コントローラを生成します。

$ rails g controller customer/webhooks --no-helper --skip-template-engine

CheckoutsController 同様、--skip-template-engine オプションを付与しているのは、HTTP エンドポイントの作成にビューは不要だからです。

続いて、ルーティングの設定を行います。
config/routes.rb を開いて下記コードを追加してください。

config/routes.rb
Rails.application.routes.draw do
  devise_for :admins, controllers: {
    sessions: 'admin/sessions'
  }
  devise_for :customers, controllers: {
    sessions: 'customer/sessions',
    registrations: 'customer/registrations'
  }
  root to: 'pages#home'
  namespace :admin do
    resources :products, only: %i[index show new create edit update]
  end
  scope module: :customer do
    resources :products, only: %i[index show]
    resources :cart_items, only: %i[index create destroy] do
      member do
        patch 'increase'
        patch 'decrease'
      end
    end
    resources :checkouts, only: [:create]
+   resources :webhooks, only: [:create]
  end

  get '/up/', to: 'up#index', as: :up
  get '/up/databases', to: 'up#databases', as: :up_databases
end

WebhooksController は、create アクションのみとなります。
生成されたパスを見てみましょう。

$ rails routes | grep webhooks

下記のようなパスが出力されれば OK です。

webhooks POST   /webhooks(.:format)    customer/webhooks#create

では次に、コントローラの各アクションの中身を記述していきます。
といっても、HTTP エンドポイントの作成については、Stripe が例を用意してくれているので、そちらを参考にしましょう。下記 URL にアクセスしてください。

https://dashboard.stripe.com/webhooks/create?endpoint_location=local

すると、下記のような画面が現れるはずです。

上記画面に表示されている endpoint_secret は後ほど Credentials に登録します。

# This is your Stripe CLI webhook secret for testing your endpoint locally.
endpoint_secret = 'whsec_xxxx'

エンドポイントのサンプル中の post '/webhook' do の中身を少し変更して、app/controllers/customer/webhooks_controller.rb に記述します。

app/controllers/customer/webhooks_controller.rb
class Customer::WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = Rails.application.credentials.dig(:stripe, :endpoint_secret)
    event = nil

    begin
      event = Stripe::Webhook.construct_event(
        payload, sig_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      # Invalid payload
      p e
      status 400
      return
    rescue Stripe::SignatureVerificationError => e
      # Invalid signature
      p e
      status 400
      return
    end

    case event.type
    when 'checkout.session.completed'
      # ... execute a process
    end
  end
end

endpoint_secret を Credentials から取得している点、'checkout.session.completed' イベントを受け取っている点が異なります。

それでは、https://dashboard.stripe.com/webhooks/create?endpoint_location=local で取得した endpoint_secret を Credentials に登録しましょう。下記コマンドを実行してください。

$ EDITOR="vi" bin/rails credentials:edit -e development

エディタが立ち上がったら、「i」キーを押して、インサートモードに入り、下記コードを記述してください。

# aws:
#   access_key_id: 123
#   secret_access_key: 345

stripe:
  publishable_key: pk_test_xxxxx
  secret_key: sk_test_xxxxx
+ endpoint_secret: whsec_xxxxx

キーが設定できたら、「esc」を押して、「wq!」で保存しましょう。
それでは、キーが設定できたかどうか確認してみましょう。下記コマンドを実行してコンソールにログインしてください。

$ rails c

コンソールに入りましたら、endpoint_secret が設定できているかを確認します。

irb(main):001:0> Rails.application.credentials.dig(:stripe, :endpoint_secret)
=> "whsec_xxxxx"

Credentials を読み込む際に dig メソッドを使っています。
コマンドを実行して、endpoint_secret の値が返ってくれば OK です。

それでは、WebhooksController の create アクションの説明に戻りましょう。

begin から始まるブロックは、例外処理を記述しています。
begin の処理でエラーが発生した場合に、rescue を実行していますね。
今回は、json を parse できなかったとき、Stripe の署名が無効のときに 400 エラーを返すようにしています。

そして最後に、checkout.session.completed イベントを受け取った際の処理を記述するということですね。この checkout.session.completed イベントには、Session オブジェクトと呼ばれるものが含まれ、顧客とその支払いに関する詳細を取得することができます。

https://stripe.com/docs/payments/checkout/fulfill-orders

checkout.session.completed イベントを受け取ったということは、Stripe による決済処理が正常に完了したことを意味しますので、checkout.session.completed イベントを受け取った後に、注文情報をデータベースに登録します。

https://stripe.com/docs/api/events/types#event_types-checkout.session.completed

それでは、when 'checkout.session.completed' の中身を記述していきましょう。
まずは、下記コードを記述してください。

case event.type
when 'checkout.session.completed'
  session = event.data.object # sessionの取得
  customer = Customer.find(session.client_reference_id)
  return unless customer # 顧客が存在するかどうか確認
  
  # トランザクション処理開始
  ApplicationRecord.transaction do
    order = create_order(session) # sessionを元にordersテーブルにデータを挿入
  end
  # トランザクション処理終了
  redirect_to session.success_url
end

解説をコメントとして記述しています。
ポイントは、ApplicationRecord.transaction do の部分ですね。
こちらは、トランザクション といって、複数の処理をまとめて大きな1つの処理として扱うための機能です。

https://railsdoc.com/page/transaction

今回は、注文情報をデータベースに格納する際に、orders テーブルと order_details テーブルにデータを挿入します。(さらに、cart_items テーブルからデータを削除します。)
このように、複数のリソースにまたがった変更 をひとまとまりで扱うときに トランザクション が役立ちます。
ApplicationRecord.transaction do で囲むことで、処理の1つで例外が発生したら、複数の処理を巻き戻すことができます。すなわち、「orders テーブルへのデータの挿入に成功したが、order_details テーブルへのデータの挿入に失敗した」という事象を防ぐことができるのです。
後ほど、order_details テーブルにデータを挿入する処理を ApplicationRecord.transaction do 内に追加します。

では、create_order メソッドを定義しましょう。

app/controllers/customer/webhooks_controller.rb
class Customer::WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = Rails.application.credentials.dig(:stripe, :endpoint_secret)
    event = nil

    begin
      event = Stripe::Webhook.construct_event(
        payload, sig_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      # Invalid payload
      p e
      status 400
      return
    rescue Stripe::SignatureVerificationError => e
      # Invalid signature
      p e
      status 400
      return
    end

    case event.type
    when 'checkout.session.completed'
      session = event.data.object # sessionの取得
      customer = Customer.find(session.client_reference_id)
      return unless customer # 顧客が存在するかどうか確認

      ApplicationRecord.transaction do # トランザクション処理開始
        order = create_order(session) # sessionを元にordersテーブルにデータを挿入
      end # トランザクション処理終了
      redirect_to session.success_url
    end
  end

+ private

+ def create_order(session)
+   Order.create!({
+                   customer_id: session.client_reference_id,
+                   name: session.shipping.name,
+                   postal_code: session.shipping.address.postal_code,
+                   prefecture: session.shipping.address.state,
+                   address1: session.shipping.address.line1,
+                   address2: session.shipping.address.line2,
+                   postage: session.shipping_options[0].shipping_amount,
+                   billing_amount: session.amount_total,
+                   status: 'confirm_payment'
+                 })
+ end
end

create_order メソッドは、引数として受け取った session を元に各カラムにデータを格納して、ActiveRecord メソッド create! を実行しています。

create ではなく、create! としているのは、レコードの保存に失敗した場合に、例外 を発生させる必要があるからです。例外 を発生させることで、トランザクションにより処理を巻き戻すこと(rollback)ができます。

event.data.object で取得できる session オブジェクトの中身は下記の通りです。

{
  "id": "cs_test_b18XGkF4PObIlcROc2P3x49hLUUAT1TTM29fIioKx7xFQUk9Pf2vM9MXTs",
  "object": "checkout.session",
  "after_expiration": null,
  "allow_promotion_codes": null,
  "amount_subtotal": 47100,
  "amount_total": 47600,
  "automatic_tax": {"enabled":false,"status":null},
  "billing_address_collection": null,
  "cancel_url": "http://localhost:8000//cart_items",
  "client_reference_id": "1",
  "consent": null,
  "consent_collection": null,
  "created": 1669802452,
  "currency": "jpy",
  "custom_text": {"shipping_address":null,"submit":null},
  "customer": null,
  "customer_creation": "if_required",
  "customer_details": {"address":{"city":null,"country":"JP","line1":"龍ケ崎市","line2":null,"postal_code":"301-0000","state":"茨城県"},"email":"yamada.taro@gmail.com","name":"山田太郎","phone":null,"tax_exempt":"none","tax_ids":[]},
  "customer_email": "yamada.taro@gmail.com",
  "expires_at": 1669888852,
  "livemode": false,
  "locale": null,
  "metadata": {},
  "mode": "payment",
  "payment_intent": "pi_3M9n6IF4dCXP6zuk1jIznnJd",
  "payment_link": null,
  "payment_method_collection": "always",
  "payment_method_options": {},
  "payment_method_types": [
    "card"
  ],
  "payment_status": "paid",
  "phone_number_collection": {"enabled":false},
  "recovered_from": null,
  "setup_intent": null,
  "shipping": {"address":{"city":"","country":"JP","line1":"港区芝公園","line2":null,"postal_code":"105-0011","state":"東京都"},"name":"山田太郎"},
  "shipping_address_collection": {"allowed_countries":["JP"]},
  "shipping_options": [
    {"shipping_amount":500,"shipping_rate":"shr_1M9n60F4dCXP6zukH1cXKtjo"}
  ],
  "shipping_rate": "shr_1M9n60F4dCXP6zukH1cXKtjo",
  "status": "complete",
  "submit_type": null,
  "subscription": null,
  "success_url": "http://localhost:8000/",
  "total_details": {"amount_discount":0,"amount_shipping":500,"amount_tax":0},
  "url": null
}

支払いの詳細が含まれています。
session オブジェクトには、orders テーブルなのレコードを作成するために必要なデータが全て揃っていますね。
また、'checkout.session.completed' イベントを受信した時点で、決済は完了していますので、status カラムには、'confirm_payment' を格納しています。

続いて、どの商品を何個購入したのかを記録するための order_details を新たに作成しましょう。
そのためには line_items の情報が必要になります。
Checkout セッションを作成する際に、line_items というオブジェクトの配列を作成しましたね。
session オブジェクトからそれらを取り出し、order_details を作成することにします。

どのように取り出すかというと、下記のようなコードを記述します。

Stripe::Checkout::Session.retrieve({ id: session.id, expand: ['line_items'] })

expand に、プロパティ名を配列で渡すと、指定したオブジェクトを取得することができます。
今回は、'line_items'expand に渡すことで、line_items オブジェクトが取得できるというわけです。

expand については下記ドキュメントも参考にしてください。
https://stripe.com/docs/expand

それでは、app/controllers/customer/webhooks_controller.rb に下記コードを追記しましょう。

app/controllers/customer/webhooks_controller.rb
class Customer::WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = Rails.application.credentials.dig(:stripe, :endpoint_secret)
    event = nil

    begin
      event = Stripe::Webhook.construct_event(
        payload, sig_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      # Invalid payload
      p e
      status 400
      return
    rescue Stripe::SignatureVerificationError => e
      # Invalid signature
      p e
      status 400
      return
    end

    case event.type
    when 'checkout.session.completed'
      session = event.data.object # sessionの取得
      customer = Customer.find(session.client_reference_id)
      return unless customer # 顧客が存在するかどうか確認
      
      # トランザクション処理開始
      ApplicationRecord.transaction do
        order = create_order(session) # sessionを元にordersテーブルにデータを挿入
+       session_with_expand = Stripe::Checkout::Session.retrieve({ id: session.id, expand: ['line_items'] })
+       session_with_expand.line_items.data.each do |line_item|
+         create_order_details(order, line_item) # 取り出したline_itemをorder_detailsテーブルに登録
+       end
      end
      # トランザクション処理終了
      redirect_to session.success_url
    end
  end

  private

  def create_order(session)
    Order.create!({
                    customer_id: session.client_reference_id,
                    name: session.shipping.name,
                    postal_code: session.shipping.address.postal_code,
                    prefecture: session.shipping.address.state,
                    address1: session.shipping.address.line1,
                    address2: session.shipping.address.line2,
                    postage: session.shipping_options[0].shipping_amount,
                    billing_amount: session.amount_total,
                    status: 'confirm_payment'
                  })
  end
end

session_with_expand には、下記のような値を持つ line_items というキーが追加されます。
data というキーの値は配列になっていますので、それを each メソッドによって一つずつ取り出しています。

{
  "object": "list",
  "data": [
    {
      "id": "li_1M9p2sF4dCXP6zuk7x6TGmkl",
      略...,
      "quantity": 1
    },
    {
      "id": "li_1M9p2sF4dCXP6zukHrwyhmhK",
      略...,
      "quantity": 2
    }
  ],
  "has_more": false,
  "url": "/v1/checkout/sessions/cs_test_b1w0gxjgkEcfZ7HVdoZ8etvGkSmagne7GhthCskEaZBSa9ZHDNz4nsK1kb/line_items"
}

それでは、最後に create_order_details メソッドを定義しましょう。

app/controllers/customer/webhooks_controller.rb
class Customer::WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = Rails.application.credentials.dig(:stripe, :endpoint_secret)
    event = nil

    begin
      event = Stripe::Webhook.construct_event(
        payload, sig_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      # Invalid payload
      p e
      status 400
      return
    rescue Stripe::SignatureVerificationError => e
      # Invalid signature
      p e
      status 400
      return
    end

    case event.type
    when 'checkout.session.completed'
      session = event.data.object # sessionの取得
      customer = Customer.find(session.client_reference_id)
      return unless customer # 顧客が存在するかどうか確認
      
      # トランザクション処理開始
      ApplicationRecord.transaction do
        order = create_order(session) # sessionを元にordersテーブルにデータを挿入
        session_with_expand = Stripe::Checkout::Session.retrieve({ id: session.id, expand: ['line_items'] })
        session_with_expand.line_items.data.each do |line_item|
          create_order_details(order, line_item) # 取り出したline_itemをorder_detailsテーブルに登録
        end
      end
      # トランザクション処理終了
      redirect_to session.success_url
    end
  end

  private

  def create_order(session)
    Order.create!({
                    customer_id: session.client_reference_id,
                    name: session.shipping.name,
                    postal_code: session.shipping.address.postal_code,
                    prefecture: session.shipping.address.state,
                    address1: session.shipping.address.line1,
                    address2: session.shipping.address.line2,
                    postage: session.shipping_options[0].shipping_amount,
                    billing_amount: session.amount_total,
                    status: 'confirm_payment'
                  })
  end

+ def create_order_details(order, line_item)
+   product = Stripe::Product.retrieve(line_item.price.product)
+   purchased_product = Product.find(product.metadata.product_id)
+   raise ActiveRecord::RecordNotFound if purchased_product.nil?
+
+   order_detail = order.order_details.create!({
+                                                product_id: purchased_product.id,
+                                                price: line_item.price.unit_amount,
+                                                quantity: line_item.quantity
+                                              })
+ end
end

このメソッドが最後の関門です。一行一行確認していきましょう 🚀
まず、一行目の Stripe::Product.retrieve(line_item.price.product) は、Stripe に登録された商品を取得しています。

Stripe の Checkout サマリーをみると、購入された商品が表示されていますね。

商品をクリックすると、詳細画面に遷移します。

少し下にスクロールすると、メタデータ という項目があるはずです。

このメタデータに登録されている product_id を取得するために、Stripe::Product.retrieve(line_item.price.product) という記述を行なっています。
下記のように記述することで、product テーブルから購入された商品のデータを取得することができるのです。

product = Stripe::Product.retrieve(line_item.price.product)
purchased_product = Product.find(product.metadata.product_id)

購入された商品を取得して、purchased_product という変数に格納しました。あとは、

  • order
  • purchased_product
  • line_item

を使って、order_details を作成するだけです。

order_detail = order.order_details.create!({
                                             product_id: purchased_product.id,
                                             price: line_item.price.unit_amount,
                                             quantity: line_item.quantity
                                           })

上記のように、order.order_details.create! とすることで、直近で生成された order に紐づいた order_details を作成することができます。つまり、order_id カラムに orderid が自動的に格納されるということです。

これで、order_details が作成できました。注文情報に関する処理は全て終了したのですが、やることがあと二つ残っています。
一つは、購入された商品の在庫数の更新です。購入された数だけ、商品の在庫数を減らす必要があります。
もう一つは、顧客のカート内商品を空にする処理です。購入が完了したらカート内商品は全て削除しなくてはなりません。

下記コードを追加してください。

app/controllers/customer/webhooks_controller.rb
class Customer::WebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.body.read
    sig_header = request.env['HTTP_STRIPE_SIGNATURE']
    endpoint_secret = Rails.application.credentials.dig(:stripe, :endpoint_secret)
    event = nil

    begin
      event = Stripe::Webhook.construct_event(
        payload, sig_header, endpoint_secret
      )
    rescue JSON::ParserError => e
      # Invalid payload
      p e
      status 400
      return
    rescue Stripe::SignatureVerificationError => e
      # Invalid signature
      p e
      status 400
      return
    end

    case event.type
    when 'checkout.session.completed'
      session = event.data.object # sessionの取得
      customer = Customer.find(session.client_reference_id)
      return unless customer # 顧客が存在するかどうか確認
      
      # トランザクション処理開始
      ApplicationRecord.transaction do
        order = create_order(session) # sessionを元にordersテーブルにデータを挿入
        session_with_expand = Stripe::Checkout::Session.retrieve({ id: session.id, expand: ['line_items'] })
        session_with_expand.line_items.data.each do |line_item|
          create_order_details(order, line_item) # 取り出したline_itemをorder_detailsテーブルに登録
        end
      end
      # トランザクション処理終了
+     customer.cart_items.destroy_all # 顧客のカート内商品を全て削除
      redirect_to session.success_url
    end
  end

  private

  def create_order(session)
    Order.create!({
                    customer_id: session.client_reference_id,
                    name: session.shipping.name,
                    postal_code: session.shipping.address.postal_code,
                    prefecture: session.shipping.address.state,
                    address1: session.shipping.address.line1,
                    address2: session.shipping.address.line2,
                    postage: session.shipping_options[0].shipping_amount,
                    billing_amount: session.amount_total,
                    status: 'confirm_payment'
                  })
  end

  def create_order_details(order, line_item)
    product = Stripe::Product.retrieve(line_item.price.product)
    purchased_product = Product.find(product.metadata.product_id)
    raise ActiveRecord::RecordNotFound if purchased_product.nil?
 
    order_detail = order.order_details.create!({
                                                 product_id: purchased_product.id,
                                                 price: line_item.price.unit_amount,
                                                 quantity: line_item.quantity
                                               })
+   purchased_product.update!(stock: (purchased_product.stock - order_detail.quantity)) # 購入された商品の在庫数の更新
  end
end

下記の二行を適切な箇所に追加しました。

customer.cart_items.destroy_all
purchased_product.update!(stock: (purchased_product.stock - order_detail.quantity))

カート内商品の削除は、トランザクションが成功したとき、商品の在庫数の更新は order_details を新たに作成したときに実行しています。

これで、決済処理のロジックを全て実装し終わりました 🎉
次節で動作確認を行います。

決済処理の動作確認をしよう

それでは、決済処理の動作確認をしましょう。
その前に、Stripe のイベントを先ほど作成した Webhook に送信する設定を行います。
設定の手順は下記の通りです。

  1. Stripe CLI をダウンロードする
  2. Stripe アカウントにログインする
  3. Stripe のイベントを先ほど作成した Webhook に送信する設定を行う

最初に、下記ドキュメントを参照しながら、Stripe CLI をインストールしてください。
https://stripe.com/docs/stripe-cli

その後、下記コマンドを実行して Stripe アカウントにログインします。
このコマンドは Docker コンテナではなく、ローカル で実行してください。

$ stripe login

上記コマンドを実行して Enter を押すと、下記画面に遷移するはずです。

アカウントのパスワードを入力すると、ログインが完了します。
下記のようなログが出力されれば OK です。

Your pairing code is: xxxxx-xxxxx-xxxxx-xxxxx
This pairing code verifies your authentication with Stripe.
Press Enter to open the browser or visit https://dashboard.stripe.com/stripecli/confirm_auth?t=xxxxx (^C to quit)
> Done! The Stripe CLI is configured for FarStep with account id acct_xxxxx

Please note: this key will expire after 90 days, at which point you'll need to re-authenticate.

ログインが完了したら、下記コマンドを実行して、Stripe のイベントを先ほど作成した Webhook に送信する設定を行ってください。

$ stripe listen --forward-to localhost:8000/webhooks

上記コマンドを実行すると下記のようなログが出力されます。

A newer version of the Stripe CLI is available, please update to: v1.13.5
> Ready! You are using Stripe API Version [2020-08-27]. Your webhook signing secret is whsec_xxxxx (^C to quit)

これで、Stripe は WebhooksController の create アクションにイベントを送信するようになります。

この設定の仕方は下記ドキュメントにも記載されていますので参考にしてください。
https://dashboard.stripe.com/test/webhooks/create?endpoint_location=local

では、実際に商品を購入してみましょう。
決済が完了した後、エラーが吐かれず、カート内商品が全て削除されれば OK です。

また、stripe listen --forward-to localhost:8000/webhooks を実行したターミナルをご覧ください。Stripe から下記のようなイベントが送信されているはずです。

2022-12-01 02:27:27   --> charge.succeeded [evt_3M9u48F4dCXP6zuk0e9WiDkx]
2022-12-01 02:27:27  <--  [204] POST http://localhost:8000/webhooks [evt_3M9u48F4dCXP6zuk0e9WiDkx]
2022-12-01 02:27:27   --> payment_intent.succeeded [evt_3M9u48F4dCXP6zuk05jwARSK]
2022-12-01 02:27:27  <--  [204] POST http://localhost:8000/webhooks [evt_3M9u48F4dCXP6zuk05jwARSK]
2022-12-01 02:27:27   --> payment_intent.created [evt_3M9u48F4dCXP6zuk0FgAJHV2]
2022-12-01 02:27:27  <--  [204] POST http://localhost:8000/webhooks [evt_3M9u48F4dCXP6zuk0FgAJHV2]
2022-12-01 02:27:27   --> checkout.session.completed [evt_1M9u4BF4dCXP6zukZCNmglz2]
2022-12-01 02:27:28  <--  [302] POST http://localhost:8000/webhooks [evt_1M9u4BF4dCXP6zukZCNmglz2]

最後に、checkout.session.completed イベントを受信していますね。そして、

  • INSERT INTO "orders"
  • INSERT INTO "order_details"
  • UPDATE "products"
  • DELETE FROM "cart_items"

という SQL 文が発行されているか確認してください。
カートにいれた商品の個数によって、発行される SQL 文の数は変わります。

特にエラーが吐かれていなければ決済処理は正常に動作しています 🎉
エラーが出ている場合は、エラー文をよく読んで原因となるコードを探してみてください。

もしも、下記のようなエラーが吐かれた場合は、Credentials の endpoint_secret が正しく読み込まれていない可能性が高いです。

ArgumentError (secret should be a string):

サーバを再起動するとこのエラーは解消されるはずです。

最後に、RuboCop を実行し、必要であればコードを修正しましょう。

$ rubocop

今回は、RuboCop の静的解析で指摘事項が七つありました。

Inspecting 57 files
...........C.......C..........C..........................

Offenses:

app/controllers/customer/checkouts_controller.rb:13:3: C: Metrics/MethodLength: Method has too many lines. [24/10]
  def create_session(line_items) ...
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
app/controllers/customer/webhooks_controller.rb:4:3: C: Metrics/AbcSize: Assignment Branch Condition size for create is too high. [<12, 31, 4> 33.48/17]
  def create ...
  ^^^^^^^^^^
app/controllers/customer/webhooks_controller.rb:4:3: C: Metrics/MethodLength: Method has too many lines. [32/10]
  def create ...
  ^^^^^^^^^^
app/controllers/customer/webhooks_controller.rb:48:3: C: Metrics/AbcSize: Assignment Branch Condition size for create_order is too high. [<0, 20, 0> 20/17]
  def create_order(session) ...
  ^^^^^^^^^^^^^^^^^^^^^^^^^
app/controllers/customer/webhooks_controller.rb:48:3: C: Metrics/MethodLength: Method has too many lines. [11/10]
  def create_order(session) ...
  ^^^^^^^^^^^^^^^^^^^^^^^^^
app/controllers/customer/webhooks_controller.rb:62:3: C: Metrics/AbcSize: Assignment Branch Condition size for create_order_details is too high. [<3, 18, 1> 18.28/17]
  def create_order_details(order, line_item) ...
  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
app/models/customer.rb:18:3: C: Metrics/MethodLength: Method has too many lines. [15/10]
  def line_items_checkout ...
  ^^^^^^^^^^^^^^^^^^^^^^^

57 files inspected, 7 offenses detected

しかも、どれも自動修正はできないようです。指摘事項の種類は下記の二種類です。

  • Metrics/MethodLength: Method has too many lines.
  • Metrics/AbcSize: Assignment Branch Condition size for create is too high.

一つ目は、メソッドの行数が長いと指摘されています。RuboCop で定められているメソッドの行数の制限はデフォルトで 10 行です(コメントは対象外)。10 行はあまりにも少ないので、この制限を緩和しましょう。

.rubocop.yml を開いて、一番下に下記コードを記述してください。

.rubocop.yml
Metrics/MethodLength:
  Max: 40

今回は、メソッドの行数を 40 行まで許容することにしました。

さて、二つ目は、メソッドの複雑度が高いと指摘されています。RuboCop で定められているメソッド複雑度の指標の制限はデフォルトで 17 行です。17 という値はあまりにも厳しいため、この制限を緩和しましょう。

.rubocop.yml を開いて、一番下に下記コードを記述してください。

.rubocop.yml
Metrics/AbcSize:
  Max: 40

今回は、メソッドの複雑度を 40 まで許容することにしました。

Rubocop の制限値を編集したところで、再度静的解析を行ってみましょう。

$ rubocop

今度は、RuboCop の静的解析を全て PASS したようです。

Inspecting 57 files
.........................................................

57 files inspected, no offenses detected

今回は、WebhooksController の処理の書き方を Stripe の公式ドキュメントを参考にしたため、制限を緩めるという方法をとりました。しかし、Rubocop はあくまで、システムの品質を向上させるためのツールです。Offenses が出ないようにすることが目的ではありません。Rubocop の制限値はアプリケーションに合わせて適切な値に編集してください。

それでは、コミットしておきましょう。

$ git add . && git commit -m "Implementation of payment processing"

おわりに

お疲れ様でした。
本 Chapter では、Stripe を使った決済処理を実装しました。
Stripe はドキュメントが非常に充実していますので、何かカスタマイズしたい場合は、是非ドキュメントを参照してください。
次の Chapter では、注文情報を閲覧する機能を実装していきます。