Railsで決済機能を実装する際、決済サービスの候補としてあがるのはだいたいStripePay.jpだと思う。
業務で定期講読する商品を扱うことになりPay.jpを使って実装を行なったが、Payjpの定期購入について解説した記事が少なかったので解説してみたいと思う。
本記事では前段として、単発購入機能を作成することでPayjpの導入にしたいと思う。

ドキュメント

決済の流れ

2018年6月に施行された改正割賦販売法により、事業会社は顧客のカード情報を自社で保有する機器・ネットワーク内で保存、処理、通過することが出来なくなった。したがって、顧客のカード情報をそのまま自社のサーバには送信させず、Pay.jpが用意しているチェックアウトを用いてPay.jp側に送信し、そのレスポンスをもってカード情報のトークンをサーバに送るという流れになる。 payjp

導入

payjp-rubyという、Pay.jpが用意してくれている便利なgemがあるのでそれをインストールする。

# Gemfile

gem 'payjp'
$ bundle install

フロントエンド

チェックアウトという、デザインされた決済フォーム、カード情報のバリデーション、カード情報のトークン化を行うフォームを生成するライブラリを用意してくれているので、これをそのまま使う。

<%# app/views/charges/new.html.erb %>

<script
  type="text/javascript"
  src="https://checkout.pay.jp/"
  class="payjp-button"
  data-key="#{ENV['PAYJP_PUBLIC_KEY']}"
  data-submit-text="購入する"
  data-text="カードを入力"
>
</script>

PAYJP_PUBLIC_KEYはPay.jpの管理画面で取得出来る公開鍵のこと。秘密鍵と合わせて取得しておくように。

サーバサイド

上記で入力されたカード情報はトークン化されサーバサイドへ送られてくる。params[:payjp_token]でトークンを取得出来るので、単発決済の場合はこれをそのまま用いることで決済を完了させられる。
また、決済履歴を残しておく必要があるので、PaymentHistoryというモデルを用意しておく。

# app/models/payment_history

# == Schema Information
#
# Table name: orders
#
#  id                                  :uuid             not null, primary key
#  amount                              :integer          default(0), not null
#  error_detail                        :string
#  error_message                       :string
#  status(決済ステータス)                :integer          default("before_payment"), not null
#  created_at                          :datetime         not null
#  updated_at                          :datetime         not null
#  charge_id(Payjp決済ID)               :string           default(""), not null
#  user_id                             :uuid             not null
#
# Indexes
#
#  index_orders_on_user_id  (user_id)

class PaymentHistory
  belongs_to :user

  enum status: {
    before_payment: 0, # 未決済
    completed: 1, # 決済完了
    failed: 2, # 決済失敗
  }
end

コントローラーに決済処理を書いていく。payjpgemを入れてあるおかげでPay.jp用のクライアントを自分で用意する必要はない。
ここでは決済だけを使うので、Payjp::Chargeを使う。返り値はレスポンスになるので、変数に格納しておく。
決済金額や決済IDはメソッドで取得出来る。

# app/controllers/charges_controller.rb

class PaymentHistoriesController < ApplicationController

  def create
    payment_history = current_user.payment_histories.create(status: :before_payment)
    response = Payjp::Charge.create(
      amount: params[:amount], # 税込み金額を決済させる
      card: params[:payjp_token], # カードにトークンを指定させることで単発決済は要件を満たせる
      currency: 'jpy' # 通貨を指定する必要があるが、現在は日本円のみ対応
    )
    payment_history.update!(
      status: :completed,
      amount: response.amount,
      charge_id: response.id
    )
    render json: { message: '決済完了' }, status: 200
  end
end

また、当然外部サービスとの連携なのでエラーハンドリングを行い、ログを残す必要がある。   Pay.jpではエラーは全てPayjp::PayjpErrorで返却されるようになっているので、最初にこれをrescueし、その次にStandardErrorrescueする。
Pay.jpのエラーコードはe.json_body[:error]で取得出来る。

# app/controllers/charges_controller.rb

class PaymentHistoriesController < ApplicationController

  def create
    payment_history = current_user.payment_histories.create(status: :before_payment)
    response = Payjp::Charge.create(
      amount: params[:amount], # 税込み金額を決済させる
      card: params[:payjp_token], # カードにトークンを指定させることで単発決済は要件を満たせる
      currency: 'jpy' # 通貨を指定する必要があるが、現在は日本円のみ対応
    )
    payment_history.update!(
      status: :completed,
      amount: response.amount,
      charge_id: response.id
    )
    render json: { message: '決済完了' }, status: 200
  rescue Payjp::PayjpError => e
    err = e.json_body[:error]
    payment_history.update!(
      status: :failed,
      error_message: err[:code]
    )
    render json: { message: '決済失敗' }, status: 400
  rescue StandardError => e
    payment_history.update!(
      status: :failed,
      error_message: 'failed_payment',
      error_detail: '何らかの理由で決済に失敗しました'
    )
    render json: { message: '決済失敗' }, status: 500
  end
end

当然、エラーコードだけだと何のエラーなのか判別出来ないので、Pay.jpのドキュメントを参照し、エラーコードに対応したメッセージを格納出来るようにする。

# app/controllers/charges_controller.rb

class ChargesController < ApplicationController

  PAYJP_ERROR_CODE = {
    'invalid_number' => 'カード番号が不正です',
    'invalid_cvc' => 'CVCが不正です',
    'invalid_expiration_date' => '有効期限年、または月が不正です',
    'incorrect_card_data' => 'カード番号、有効期限、CVCのいずれかが不正です',
    'invalid_expiry_month' => '有効期限月が不正です',
    'invalid_expiry_year' => '有効期限年が不正です',
    'expired_card' => '有効期限切れです',
    'card_declined' => 'カード会社によって拒否されたカードです',
    'processing_error' => '決済ネットワーク上でエラーが発生しました',
    'missing_card' => '顧客がカードを保持していない',
    'unacceptable_brand' => '対象のカードブランドが許可されていません'
  }.freeze

  def create
    payment_history = current_user.payment_histories.create(status: :before_payment)
    response = Payjp::Charge.create(
      amount: params[:amount], # 税込み金額を決済させる
      card: params[:payjp_token], # カードにトークンを指定させることで単発決済は要件を満たせる
      currency: 'jpy' # 通貨を指定する必要があるが、現在は日本円のみ対応
    )
    payment_history.update!(
      status: :completed,
      amount: response.amount,
      charge_id: response.id
    )
    render json: { message: '決済完了' }, status: 200
  rescue Payjp::PayjpError => e
    err = e.json_body[:error]
    payment_history.update!(
      status: :failed,
      error_message: err[:code],
      error_detail: PAYJP_ERROR_CODE[err[:code]]
    )
    render json: { message: PAYJP_ERROR_CODE[err[:code]] }, status: 400
  rescue StandardError => e
    payment_history.update!(
      status: :failed,
      error_message: 'failed_payment',
      error_detail: '何らかの理由で決済に失敗しました'
    )
    render json: { message: '何らかの理由で決済に失敗しました' }, status: 500
  end
end

まとめ

以上で単発決済機能を実装することが出来た。
上記のコントローラーはかなりファットになってしまっているので、自分で実装する際はスリムにするように心がけて欲しい。

今回でPay.jpについて簡単な導入が出来たので、次回は定期決済機能を実装していこうと思う。