1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【Rails】【Pay.jp】クレジットカードを登録してもしなくても商品を購入できる実装

Last updated at Posted at 2021-06-30

 クレジットカードを登録してもしなくても商品を購入できる実装をしてみました。挙動は以下の通りです。

  • カード登録画面へ遷移
    Image from Gyazo

  • 登録されたカード情報が表示される
    Image from Gyazo

  1. name属性を区別して実装する
  2. 登録したカード情報を表示させる
  3. カード登録後はjavascriptを読み込ませない
  4. コントローラー内で条件分岐をさせる
  5. バリデーションがかかるような記述を加える

 では以上の流れで説明していきます。クレジットカード決済機能導入後に新たに登録画面を設けた形になります。可読性が高いコードではないため、ご意見いただけるとありがたいです。

 

1. name属性を区別して実装する

 挙動からわかるように、購入画面とは別に新たにクレジットカード登録画面を設けています。つまり、登録画面でカード情報を入力して購入する場合と、購入画面でそのまま入力して購入する場合があるということです。
 ここで最初に生じた課題は、取得する「name属性」が、登録画面と購入画面でそれぞれ異なるということです。というのも、購入画面ではカード情報以外の住所や連絡先などの情報も同時に送信するため、Formオブジェクトを用いているからです。

  • もともと作っていたapp/javascript/card.jsの1部
      const card = {
        number: formData.get("order_address[number]"),
        cvc: formData.get("order_address[cvc]"),
        exp_month: formData.get("order_address[exp_month]"),
        exp_year: `20${formData.get("order_address[exp_year]")}`,
      };
  • 登録画面で取得したいname属性
      const card = {
        number: formData.get("number"),
        cvc: formData.get("cvc"),
        exp_month: formData.get("exp_month"),
        exp_year: `20${formData.get("exp_year")}`,
      };

 この時にまず2つの方法が浮かびました。1つは登録画面のviewで、購入画面と同じname属性を追記することであり、もう1つはif文で条件分岐をすることです。しかしどちらもうまくいきませんでした。結果的にJavaScriptファイルを2つ作ることで解決しました。

  • app/javascript/card.js(購入画面)
const pay = () => {
  Payjp.setPublicKey(process.env.PAYJP_PUBLIC_KEY);
  const form = document.getElementById("charge-form");
  form.addEventListener("submit", (e) => {
    e.preventDefault();

    const formResult = document.getElementById("charge-form");
    const formData = new FormData(formResult);

      const card = {
        number: formData.get("order_address[number]"),
        cvc: formData.get("order_address[cvc]"),
        exp_month: formData.get("order_address[exp_month]"),
        exp_year: `20${formData.get("order_address[exp_year]")}`,
      };

      Payjp.createToken(card, (status, response) => {

        if (status == 200) {
          const token = response.id;
          const renderDom = document.getElementById("charge-form");
          const tokenObj = `<input value=${token} name='token' type="hidden"> `;
          renderDom.insertAdjacentHTML("beforeend", tokenObj);
        }
        
          document.getElementById("card-number").removeAttribute("name");
          document.getElementById("card-cvc").removeAttribute("name");
          document.getElementById("card-exp-month").removeAttribute("name");
          document.getElementById("card-exp-year").removeAttribute("name");

          document.getElementById("charge-form").submit();
        });

  });
};

window.addEventListener("load", pay);
  • app/javascript/card_save.js(登録画面)
const save = () => {
  Payjp.setPublicKey(process.env.PAYJP_PUBLIC_KEY);
  const form = document.getElementById("charge");
  form.addEventListener("submit", (e) => {
    e.preventDefault();
    
    const formResult = document.getElementById("charge");
    const formData = new FormData(formResult);


      const card = {
        number: formData.get("number"),
        cvc: formData.get("cvc"),
        exp_month: formData.get("exp_month"),
        exp_year: `20${formData.get("exp_year")}`,
      };

      Payjp.createToken(card, (status, response) => {

        if (status == 200) {
          const token = response.id;
          const renderDom = document.getElementById("charge");
          const tokenObj = `<input value=${token} name='card_token' type="hidden"> `;
          renderDom.insertAdjacentHTML("beforeend", tokenObj);
        }
        
        document.getElementById("card-number").removeAttribute("name");
        document.getElementById("card-cvc").removeAttribute("name");
        document.getElementById("card-exp-month").removeAttribute("name");
        document.getElementById("card-exp-year").removeAttribute("name");

        document.getElementById("charge").submit();
      });

  });
};

window.addEventListener("load", save);

 ここに関してはもう少し有効な実装方法があったかもしれません。

2. 登録したカード情報を表示させる

 JavaScriptファイルを2つ作り、下記のようなcards_controllerの記述によって、カードの登録と、登録後の購入画面への遷移が可能になりました。

  • app/controllers/cards_controller.rb
class CardsController < ApplicationController
  def new
    session[:previous_url] = request.referer
  end

  def create
    Payjp.api_key = ENV['PAYJP_SECRET_KEY']
    customer = Payjp::Customer.create(
      description: 'test',
      card: params[:card_token]
    )

    card = Card.new(
      card_token: params[:card_token],
      customer_token: customer.id,
      user_id: current_user.id
    )

    if card.save
      redirect_to session[:previous_url]
    else
      render :new
    end
  end

  def session_clear
    session[:previous_url].clear
  end
end

 ただし現状は登録したカード情報がデータベースに保存されるようになっただけであり、購入画面に戻ってもカード入力フォームは残ったままです。したがって、カードの登録が完了した場合は、カード入力フォームではなく、登録したカード情報が表示される必要があります。

  • app/controllers/orders_controller.rbのindexアクション
  def index
    @order_address = OrderAddress.new
    Payjp.api_key = ENV['PAYJP_SECRET_KEY']
    card = Card.find_by(user_id: current_user.id)

    if card.present?
      customer = Payjp::Customer.retrieve(card.customer_token)
      @card = customer.cards.first
    end
  end
  • app/views/orders/index.html.erbの1部
    <% if @card.present? %>
            <div class='credit-card-form'>
      <%# 省略 %>
          <%= "**** **** **** " + @card[:last4]  %>
          <%# 省略 %>
          <div class='input-expiration-date-wrap'>
            <%= @card[:exp_month] %>
            <p></p>
            <%= @card[:exp_year] %>
            <p></p>
          </div>
        <%# 省略 %>
    <% else %>
      <div class='credit-card-form'>
        <%# 省略 %>
          <%= f.text_field :number, class:"input-default", id:"card-number", placeholder:"カード番号(半角英数字)", maxlength:"16" %>
          <%# 省略 %>
          <div class='input-expiration-date-wrap'>
            <%= f.text_area :exp_month, class:"input-expiration-date", id:"card-exp-month", placeholder:"例)3" %>
            <p></p>
            <%= f.text_area :exp_year, class:"input-expiration-date", id:"card-exp-year", placeholder:"例)23" %>
            <p></p>
          </div>
        <%# 省略 %>
          <%= f.text_field :cvc,class:"input-default", id:"card-cvc", placeholder:"カード背面4桁もしくは3桁の番号", maxlength:"4" %>
        <%# 省略 %>
    <% end %>

 このような記述でカード登録時とそうでない場合の表示を分けることができました。

3. カード登録後はjavascriptを読み込ませない

 これでカードの登録と、購入画面でのカード情報の表示も完了しました。しかし今度は、購入ボタンを押そうとすると、そもそも購入ボタンが押せない問題が発生しました。
 カード情報を登録した後になっても、2つ作ったJavaScriptファイル両方の「e.prevent.Default()」が動いていることが原因だと考えられます。
 そこで、カード登録後はjavascriptを読み込ませない記述を考えました。

  • app/views/orders/index.html.erbの1部
        <% if @card.present? %>
      <div class='credit-card-form' id="save_card">

 カードを登録している場合に読み込まれるコードにのみ「save_card」というidを付け加え、「save_card」というidを取得した場合は「return」するようにしたのです。これは2つのJavaScriptファイル両方に記述します。

const pay = () => {
  const saveCard = document.getElementById("save_card")
  if (document.getElementById("save_card")) {
  return saveCard;
  };

 これで購入ボタンを押すことは可能になります。

4. コントローラー内で条件分岐をさせる

 ここまでで購入ボタンを押すことは可能になりましたが、購入の処理自体はコントローラーで行われます。ここでやるべきことは、カードを登録している場合とそうでない場合で購入の処理を分けることです。
 
 購入の処理は購入画面で行われるため、カードを登録している場合もそうでない場合もorders_controllerを使います。

  • app/controllers/orders_controller.rbのprivateメソッド内
  def pay_item
    if @card.present?
      Payjp.api_key = ENV['PAYJP_SECRET_KEY']
      customer_token = current_user.card.customer_token
      Payjp::Charge.create(
        amount: @item.price,
        customer: customer_token,
        currency: 'jpy'
      )
    else
      Payjp.api_key = ENV['PAYJP_SECRET_KEY']
      Payjp::Charge.create(
        amount: @item.price,
        card: order_params[:token],
        currency: 'jpy'
      )
    end
  end

 orders_controllerのcreateアクションでprivateメソッド内のpay_itemを呼び出して購入の処理をするようにしています。このpay_itemの処理を、カード登録している場合としていない場合で条件分岐させてみました。
 これで後はcreateアクション内の記述次第で購入の処理ができるようになりそうです。

5. バリデーションがかかるような記述を加える

 このcreateアクション内の記述には少し工夫が必要でした。というのも、もともとカード情報と同時に住所や連絡先の情報なども送信できるように、Formオブジェクトを用いていたからです。

  • app/models/order_address.rb
class OrderAddress
  include ActiveModel::Model
  attr_accessor :item_id, :user_id, :postal_code, :prefecture_id, :city, :address, :building, :phone_number, :token

  with_options presence: true do
    validates :item_id
    validates :user_id
    validates :postal_code, format: { with: /\A[0-9]{3}-[0-9]{4}\z/, message: 'is invalid. Include hyphen(-)' }
    validates :city
    validates :address
    validates :phone_number, numericality: { only_integer: true }
    validates :token
  end

  validates :prefecture_id, numericality: { other_than: 1, message: "can't be blank" }
  validates :phone_number, length: { minimum: 10, maximum: 11 }

  def save
    order = Order.create(item_id: item_id, user_id: user_id)

    Address.create(postal_code: postal_code, prefecture_id: prefecture_id, city: city, address: address, building: building,
                   phone_number: phone_number, order_id: order.id, user_id: user_id, item_id: item_id)
  end
end

 このFormオブジェクトを用いていた場合は、コントローラー内で以下のような記述をしなければバリデーションがかかりません。

if @order_address.valid?

 しかしながら、このバリデーションをかけると、登録画面ですでにカード情報を登録した場合は、購入画面でカード情報を入力しないため、「Token can't be blank」と表示されてしまうのです。
 そこで僕が思いついたのは、カード登録済みの場合だけ、tokenを除外してバリデーションをかけるということでした。
 ということで、下記のようなコードを実行したり、Formオブジェクト内でifオプションを設けたりしてみました。

if @order_address.where.not(token: params[:token]).valid?

 しかしこんなやり方は通用しませんでした。それならばtokenを除外してバリデーションをかけるのではなく、tokenを付け加えてバリデーションをかけようと考えました。

@order_address.token = current_user.card.customer_token

 ということでカードを登録している場合のみ、上記のtokenを加えた上でバリデーションをかけるようにしてみました。これでうまくいきました。
 結果的にorders_controllerはこのようになりました。

  • app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  before_action :authenticate_user!
  before_action :set_order
  before_action :move_to_index

  def index
    @order_address = OrderAddress.new
    Payjp.api_key = ENV['PAYJP_SECRET_KEY']
    card = Card.find_by(user_id: current_user.id)

    if card.present?
      customer = Payjp::Customer.retrieve(card.customer_token)
      @card = customer.cards.first
    end
  end

  def create
    @order_address = OrderAddress.new(order_params)
    Payjp.api_key = ENV['PAYJP_SECRET_KEY']
    card = Card.find_by(user_id: current_user.id)
    if card.present?
      customer = Payjp::Customer.retrieve(card.customer_token)
      @card = customer.cards.first
      @order_address.token = current_user.card.customer_token
    end

    if @order_address.valid?
      pay_item
      @order_address.save
      redirect_to root_path
    else
      render 'orders/index'
    end
  end

  private

  def order_params
    params.require(:order_address).permit(:postal_code, :prefecture_id, :city, :address, :building, :phone_number, :order_id).merge(
      user_id: current_user.id, item_id: params[:item_id], token: params[:token]
    )
  end

  def set_order
    @item = Item.find(params[:item_id])
  end

  def move_to_index
    redirect_to root_path if current_user.id == @item.user.id || @item.order.present?
  end

  def pay_item
    if @card.present?
      Payjp.api_key = ENV['PAYJP_SECRET_KEY']
      customer_token = current_user.card.customer_token
      Payjp::Charge.create(
        amount: @item.price,
        customer: customer_token,
        currency: 'jpy'
      )
    else
      Payjp.api_key = ENV['PAYJP_SECRET_KEY']
      Payjp::Charge.create(
        amount: @item.price,
        card: order_params[:token],
        currency: 'jpy'
      )
    end
  end
end

 このようにしてクレジットカードを登録してもしなくても商品を購入できる実装が完了しました。しかし、クレジットカード決済機能を実装した後に登録画面を設けるというやり方であったため、かなりコードが煩雑になったように思います。
 もっと最適なコードの書き方があったはずであるため、コメントをもらえると嬉しいです。

1
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?