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

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

- カード登録画面へ遷移
[![Image from Gyazo](https://i.gyazo.com/313d03014bdc490627bfd3df64055673.gif)](https://gyazo.com/313d03014bdc490627bfd3df64055673)

- 登録されたカード情報が表示される
[![Image from Gyazo](https://i.gyazo.com/846027342eb8e9622b9ed6f2bdca6bc6.gif)](https://gyazo.com/846027342eb8e9622b9ed6f2bdca6bc6)



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

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


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

- もともと作っていたapp/javascript/card.jsの1部

```javascript
      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属性

```javascript
      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文で条件分岐をすることです。しかしどちらもうまくいきませんでした。結果的に<font color="Blue">JavaScriptファイルを2つ作る</font>ことで解決しました。

- app/javascript/card.js(購入画面)

```javascript
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(登録画面)

```javascript
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

```ruby
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アクション

```ruby
  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部

```erb
    <% 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を読み込ませない

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

- app/views/orders/index.html.erbの1部

```erb
　　　　　　　　<% if @card.present? %>
      <div class='credit-card-form' id="save_card">
```

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

```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メソッド内

```ruby
  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

```ruby
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オブジェクトを用いていた場合は、コントローラー内で以下のような記述をしなければバリデーションがかかりません。

```ruby
if @order_address.valid?
```

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

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

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

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

- app/controllers/orders_controller.rb

```ruby
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

```




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