クレジットカードを登録してもしなくても商品を購入できる実装をしてみました。挙動は以下の通りです。
- name属性を区別して実装する
- 登録したカード情報を表示させる
- カード登録後はjavascriptを読み込ませない
- コントローラー内で条件分岐をさせる
- バリデーションがかかるような記述を加える
では以上の流れで説明していきます。クレジットカード決済機能導入後に新たに登録画面を設けた形になります。可読性が高いコードではないため、ご意見いただけるとありがたいです。
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
このようにしてクレジットカードを登録してもしなくても商品を購入できる実装が完了しました。しかし、クレジットカード決済機能を実装した後に登録画面を設けるというやり方であったため、かなりコードが煩雑になったように思います。
もっと最適なコードの書き方があったはずであるため、コメントをもらえると嬉しいです。

