登録したクレジットカード情報で商品を購入する実装を紹介します。商品を購入できる機能自体は実装済みで話を進めていきます。これから伝えるのは、新しくクレジットカード登録画面を設けて、登録している場合はクレジットカード情報を毎度入力しなくても購入ができるようになる実装です。
この実装を行うことで、クレジットカード情報を登録してもしなくても商品の購入が可能となります。
僕はこの実装にかなり苦戦しました(笑)。Formオブジェクトを用いており、クレジットカード情報と同時に住所や電話番号なども送信できるようにしていることも、苦戦の原因です。
プログラミング初心者であるため、可読性が高い実装ではないことはご容赦ください。またブログの仕様上、インデントが整っていない場合もあります。
では説明していきたいと思います。いろいろと苦戦したのですが、結果的に行ったことはシンプルでした。以下の5点の流れで説明していきます。
1. name属性を区別して実装する
2. クレジットカード登録後はjavascriptを読み込ませない
3. コントローラー内で条件分岐をする
4. バリデーションがかかるような記述を加える
5. 可読性を上げる
1. name属性を区別して実装する
新しくカード登録画面を作った際に最初に起きた問題が、name属性にありました。僕が行いたかった実装は、クレジットカードを登録してもしなくても商品を購入できるようにすることでした。しかし新しくカード登録画面を作った場合、カード登録画面と、購入画面では、カード情報を入力するフォームのname属性が異なります。
カード登録画面では、カードナンバーを入力するフォームのname属性が"number"だったのに対し、購入画面では"order_address[number]"となっていたのです。購入画面では、住所や電話番号なども同時に送信できるように、Formオブジェクトを用いているからです。
そこで、2つのことを試してみました。"order_address[number]"といったname属性をカード登録画面のviewに追記してみることと、もともと作っていたjavascript内で条件分岐をさせることです。条件分岐で行いたかったのは、カード登録画面では"number"、購入画面では"order_address[number]"を取得するといった記述です。しかしどちらもあまりうまくいきませんでした。
そしてそれ以外の方法として、もともと作っていたjavascriptファイルとは別で、カード登録画面用の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);
新たに作成した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);
新たにjavascriptファイルを作成したときに、読み込みの記述を忘れてしまうという初歩的なミスもしてしまったので気をつけてください。console.logやdebuggerを地道に試していけばどこから読み取れていないかを確認できます。
app/javascript/packs/application.js
また、もともと実装していた購入処理はorders_controllerに記述していますが、新しく追加したカード登録の処理では、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
2. クレジットカード登録後はjavascriptを読み込ませない
これでクレジットカード登録が完了しました。登録が完了すると登録したカード情報が購入画面に表示されるようになっています。
app/views/orders/index.html.erb
<% if @card.present? %>
<div class='credit-card-form'>
<h1 class='info-input-haedline'>
クレジットカード情報入力
</h1>
<div class="form-group">
<div class='form-text-wrap'>
<label class="form-text">カード情報</label>
<span class="indispensable">必須</span>
</div>
<%= "**** **** **** " + @card[:last4] %>
<div class='available-card'>
<%= image_tag 'card-visa.gif', class: 'card-logo'%>
<%= image_tag 'card-mastercard.gif', class: 'card-logo'%>
<%= image_tag 'card-jcb.gif', class: 'card-logo'%>
<%= image_tag 'card-amex.gif', class: 'card-logo'%>
</div>
</div>
<div class="form-group">
<div class='form-text-wrap'>
<label class="form-text">有効期限</label>
<span class="indispensable">必須</span>
</div>
<div class='input-expiration-date-wrap'>
<%= @card[:exp_month] %>
<p>月</p>
<%= @card[:exp_year] %>
<p>年</p>
</div>
</div>
</div>
<% else %>
下記の記述が使えるのは、orders_controllerのindexでカード情報の定義をしているからです。
それではこの状態で購入ボタンを押してみます。すると、そもそも購入ボタンが押せない問題が発生しました。どうやらjavascriptに問題がありそうです。card.jsとcard_save.jsに記述している「e.prevent.Default()」が動いていることが原因だと考えられます。これを動かないようにすれば解決できそうです。
購入の処理自体はコントローラーで行うため、カードの登録が完了している場合は、card.jsとcard_save.jsの両方を読み込ませないようにする必要があります。
そこでカードを登録している場合のみ、"save_card"というidを付け加えました。
app/views/orders/index.html.erb
<% if @card.present? %>
<div class='credit-card-form', id="save_card">
<h1 class='info-input-haedline'>
クレジットカード情報入力
</h1>
そしてcard.jsとcard_save.jsの両方に、"save_card"というidを取得したときは、それ以降の記述を読み込ませないようにしました。
app/javascript/card.js
const pay = () => {
const saveCard = document.getElementById("save_card")
if (document.getElementById("save_card")) {
return saveCard;
};
app/javascript/card_save.js
const save = () => {
const saveCard = document.getElementById("save_card")
if (document.getElementById("save_card")) {
return saveCard;
};
これで購入ボタンは押せるようになります。
3. コントローラー内で条件分岐をする
しかし購入ボタンが押せるようになったところで、購入ができるようになったわけではありません。カードを登録している場合と登録していない場合で購入の処理を分ける必要があります。登録している場合もしていない場合も同じ購入画面で処理を行うので、orders_controllerを用います。
createアクションでprivateメソッド内のpay_itemを呼び出すようにしています。その時にpay_itemの処理をカード登録している場合としていない場合で条件分岐させます。
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
またindexアクションでもcreateアクションでもカード情報の定義をする必要があったため、privateメソッド内にsave_cardとしてカード情報の定義をまとめました。indexアクションでsave_cardを呼び出さなければ、登録したカード情報が購入画面に表示されなくなります。このsave_cardをindexアクションとcreateアクションの両方で呼び出すようにしました。
app/controllers/orders_controller.rbのprivateメソッド内
def save_card
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
これで後はcreateアクションでsave_cardとpay_itemを呼び出すような記述をすれば、登録したカード情報で購入すること自体は完了します。createアクション内の記述は後述します。
4. バリデーションがかかるような記述を加える。
ところがこれでもまだ問題があったのです。今回の実装ではorder_address.rbという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/}
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オブジェクトを用いた場合は、createアクション内でバリデーションの記述をしなければバリデーションがかかりません。
save_cardとpay_itemを呼び出せば登録したカード情報で購入すること自体は可能です。しかしバリデーションの記述をしていない場合は、登録に失敗した時にエラーが起きます。
とはいえ、
if @order_address.valid ?という記述をして購入ボタンを押すと、token can't be blankというバリデーションがかかるのです。
カード登録をしていない場合は、購入画面でカード情報を入力するため、"order_address[token]"というように、tokenがorder_addressの中に格納されるため、購入することができます。しかしカード登録画面ですでにカード情報を登録して購入画面に戻った場合は、購入画面でカード情報を入力しません。そのためorder_addressの中にtokenだけが格納されていない状態になってしまうのです。
そこで2つのことを試してみました。まずカードを登録しているときだけ、tokenのバリデーションをかけないように下記の記述をしてみました。
if @order_address.where.not(token: params[:token]).valid?
ところがこれではwhereが定義されていないというエラーが起きます。そして今度はorder_addressモデル内でifオプションを用いて、カード登録しているときだけtokenのバリデーションをかけないようにしようとしましたがうまくいきませんでした。
あと1歩なのにどうしようと悩んだ結果、「付け加えてみよう」と考え、カード登録をしているときだけ、下記の記述を加えました。
@order_address.token = current_user.card.customer_token
結果的にこれでうまくいきました。createアクション内の記述は以下のようになりました。カードを登録している場合はtokenの記述を書き加えた上でバリデーションをかけるようにしました。インデントが整っていないので見づらいと思いますが、許してください。
def create
@order_address = OrderAddress.new(order_params)
if save_card
@order_address.token = current_user.card.customer_token
if @order_address.valid?
pay_item
@order_address.save
redirect_to root_path
else
render 'orders/index'
end
elsif @order_address.valid?
pay_item
@order_address.save
redirect_to root_path
else
render 'orders/index'
end
end
5. 可読性を上げる
追記です。さすがに同じ記述を何度も繰り返し書いていて読みづらいので書き直してみました。インデントが整っていなくて読みづらいのは申し訳ありません。ブログの仕様上です。
app/controllers/orders_controller.rbbの全体
class OrdersController < ApplicationController
before_action :authenticate_user!
before_action :set_order
before_action :move_to_index
def index
@order = Order.new
@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
if card.present?を繰り返し使っていたり、条件分岐の中に条件分岐を書いたりして読みづらくなっていたため、save_cardをprivateメソッド内で定義して呼び出すのをやめて書き直しました。
Payjp.api_key = ENV['PAYJP_SECRET_KEY']
この記述に関しては何度も繰り返して書いているのですが、これはまとめて書いて呼び出すようにするとエラーが起きてしまったからです。
以上でカード情報を登録してもしなくても商品を購入できる実装の完了です。全体的にネーミングセンスが悪く、可読性ももっと高められるはずです。しかし1箇所を修正すると全体を修正する必要性が生じたり、確認作業が増えたりするので、次の実装に生かしていこうと考えています。