こんにちは、ヒカルです。
今回は私の所有する空いた土地を駐車場として貸し出すアプリを自作した経緯や苦労した点などを記します!
本記事の内容
- 他のサイトは手数料で50%も取られるから自作したほうがお得!
- [その場利用、予約機能]重複を防ぐためのvalidate
- [Pay.jp]導入に苦労した決済システム
- [firebase]なぜユーザー登録でdeviseではなくfirebaseにしたか
4つのテーマで紹介します。
まずは私が作成したアプリをご覧ください。
SHIMAHIKA-PARKING
他のサイトは手数料で50%も取られるから自作したほうがお得!
これまではakippaというサイトで駐車場として貸し出していました。
このサイトは簡単に貸し出し可能な駐車場にすることができるので非常に便利です。
ですが手数料で50%も取られてしまいます。
500円で貸し出していましたが、結局250円にしかならずそこまで利益を上げることができません。
なので私は自作することにしました。それと決済システムの導入の仕方について知りたかったというのもあります。
サーバーはherokuを使っていて、かかる経費は
hobbyモードなので700円
手数料は3~4%
です。
akippaで500円で貸し出した場合と自作アプリで400円で貸し出した時を比べると
6日/月 貸し出すとする
akippa: 500 × 6 × 0.5 = 1,500円
自作アプリ: 400 × 3.5% × 6 – 700 = 1,616円
6日間貸し出した時点で得になることがわかります。 需要が高い場所は自作がお勧めできますね。
[その場利用、予約機能]重複を防ぐためのvalidate
予約をする時に他の予約と重複してはいけないので以下のような形で実装しました。
app/models/reserve_validator.rb
class ReserveValidator < ActiveModel::EachValidator
def validate_each(record, attribute, value)
# 新規登録する期間
new_start_date = record.start_on
new_finish_date = record.finish_on
return unless new_start_date.present? && new_finish_date.present?
# 重複する期間を検索(編集時は自期間を除いて検索)
if record.id.present?
not_own_reserves = Reserve.where('id NOT IN (?) AND start_on <= ? AND finish_on >= ?', record.id, new_finish_date, new_start_date)
not_own_reserves_by_park = Park.where('id NOT IN (?) AND start_on <= ? AND finish_on_schedule >= ?', record.id, new_finish_date, new_start_date)
else
not_own_reserves = Reserve.where('start_on <= ? AND finish_on >= ?', new_finish_date, new_start_date)
not_own_reserves_by_park = Park.where('start_on <= ? AND finish_on_schedule >= ?', new_finish_date, new_start_date)
end
record.errors.add(attribute, 'に重複があります') if not_own_reserves.present? or not_own_reserves_by_park.present?
end
end
config/locals/models/ja.yml
ja:
activerecord:
models:
reserve: 予約
attributes:
reserve:
start_on: 利用開始時刻
finish_on: 利用終了時刻
park:
start_on: 利用開始時刻
finish_on_schedule: 利用終了予定時刻
app/models/reserve.rb
class Reserve < ApplicationRecord
validates :start_on, reserve: true
validates :finish_on, reserve: true
end
attributeは設定しないと英語表記になってしまうので日本語表記になるように設定しましょう。
[Pay.jp]導入に苦労した決済システム
記事がhaml記法を使ったものがほとんどで僕はhaml記法を知らなかったものですから理解するのに苦労しました。
あとは記事通りに書いてもerrorが起こるものだから、1つ1つ理解してどこでerrorが起こってるのか解読するのに苦労しました。
誰かの参考になるようにコードを載せておきます。
私が参考にした記事も合わせて載せます。
Payjpでクレジットカード登録と削除機能を実装する(Rails)
[Rails]Pay.jpを利用したクレジット決済機能実装 ① ~実装の準備・APIの導入~
app/views/layouts/application.html.erb
<script type="text/javascript" src="https://js.pay.jp/"></script>
app/views/card/new.html.erb
<%=form_tag(pay_card_index_path, method: :post ,id:'charge-form', name: "inputForm") do %>
<div class="number-details">
<div class="card-number">
カード番号<span class="must-check">必須</span>
</div>
</div>
<div class="number">
<%= text_field_tag :number, "", class: "number", placeholder: "半角数字のみ" ,maxlength: "16", type: "text", id: "card_number" %>
</div>
<div class="brand-image">
<img src="/cards/logo_visa.gif" alt="各種クレジットカードブランドロゴ" class="brand-logo">
<img src="/cards/logo_mastercard.gif" alt="各種クレジットカードブランドロゴ" class="brand-logo">
<img src="/cards/jcb.png" alt="各種クレジットカードブランドロゴ" class="brand-logo">
<img src="/cards/american_express.png" alt="各種クレジットカードブランドロゴ" class="brand-logo">
<img src="/cards/discover.png" alt="各種クレジットカードブランドロゴ" class="brand-logo">
<img src="/cards/diners_club.png" alt="各種クレジットカードブランドロゴ" class="brand-logo">
</div>
<div class="expirationdate">
<div class="expirationdate-details">
<div class="date">
有効期限<span class="must-check">必須</span>
</div>
</div>
<div class="expirationdate-choice d-flex">
<div class="month d-flex">
<%= select_tag "exp_month" ,options_for_select([['--', ''], ['01', '1'], ['02', '2'], ['03', '3'], ['04', '4'], ['05', '5'], ['06', '6'], ['07', '7'], ['08', '8'], ['09', '9'], ['10', '10'], ['11', '11'], ['12', '12']]) , type: "text"%>
<div class="month-detail">月</div>
</div>
<div class="year d-flex">
<%= select_tag "exp_year" ,options_for_select([['--', ''], ['21', '2021'], ['22', '2022'], ['23', '2023'], ['24', '2024'], ['25', '2025'], ['26', '2026'], ['27', '2027'], ['28', '2028'], ['29', '2029'], ['30', '2030'], ['31', '2031'], ['32', '2032']]), type: "text" %>
<div class="year-detail">年</div>
</div>
</div>
</div>
<div class="securitycode">
<div class="securitycode-details">
<div class="securitycode-title">
セキュリティコード<span class="must-check">必須</span>
</div>
</div>
<div class="securitycode-cardsecurity">
<%= text_field_tag :cvc , "", class: "cvc", placeholder: "カード背面4桁もしくは3桁の番号", maxlength: "4", id: "cvc"%>
</div>
</div>
<div class="submit">
<%= submit_tag "登録する" , id: "token_submit" %>
</div>
<% end %>
app/views/card/show.html.erb
<div class="col-xs-7 fs left">
<div class="card-info">
<div class="card-info-brand">
<img src="/cards/<%=@card_src%>" alt="クレジットカードブランド情報">
</div>
<div class="card-info-numbers">
<div class="number">
<%="**** **** **** " + @default_card_information.last4%>
</div>
<div class="expiration-date">
<div class="expiration-date-title">
有効期限
</div>
<div class="expiration-date-info">
<%=@exp_month + " / " + @exp_year%>
</div>
</div>
</div>
</div>
</div>
<div class="col-xs-5 fs right">
<%= link_to("削除する",delete_card_index_path, method: :post, class: "destroy__function__link__btn") %>
</div>
app/javascript/pay.js
document.addEventListener(
"DOMContentLoaded", e => {
if (document.getElementById("token_submit") != null) { //token_submitというidがnullの場合、下記コードを実行しない
Payjp.setPublicKey('pk_test_e22308e13375d08934d38e8f'); //ここに公開鍵を直書き
let btn = document.getElementById("token_submit"); //IDがtoken_submitの場合に取得されます
btn.addEventListener("click", e => { //ボタンが押されたときに作動します
e.preventDefault(); //ボタンを一旦無効化します
let card = {
number: document.getElementById("card_number").value,
cvc: document.getElementById("cvc").value,
exp_month: document.getElementById("exp_month").value,
exp_year: document.getElementById("exp_year").value
}; //入力されたデータを取得します。
Payjp.createToken(card, (status, response) => {
if (status === 200) { //成功した場合
$("#card_number").removeAttr("name");
$("#cvc").removeAttr("name");
$("#exp_month").removeAttr("name");
$("#exp_year").removeAttr("name"); //データを自サーバにpostしないように削除
$("#charge-form").append(
$('<input type="hidden" name="payjp-token">').val(response.id)
); //取得したトークンを送信できる状態にします
document.inputForm.submit();
// alert("登録が完了しました"); //確認用
} else {
alert("カード情報が正しくありません。"); //確認用
}
});
});
}
},
false
);
app/controllers/purchase_controller.rb
class PurchaseController < ApplicationController
require 'payjp'
before_action :authenticate_user!
def index
@user = @current_user
if @parking = Park.find_by(user_id: @user.id, finish_stamp: "no")
if @parking.finish_on
@card = Card.where(user_id: @user.id).first
if @card.present?
#Cardテーブルは前回記事で作成、テーブルからpayjpの顧客IDを検索
Payjp.api_key = ENV["PAYJP_PRIVATE_KEY"]
#保管した顧客IDでpayjpから情報取得
customer = Payjp::Customer.retrieve(@card.customer_id)
#保管したカードIDでpayjpから情報取得、カード情報表示のためインスタンス変数に代入
@default_card_information = customer.cards.retrieve(@card.card_id)
##カードのアイコン表示のための定義づけ
@card_brand = @default_card_information.brand
case @card_brand
when "Visa"
# 例えば、Pay.jpからとってきたカード情報の、ブランドが"Visa"だった場合は返り値として
# (画像として登録されている)Visa.pngを返す
@card_src = "logo_visa.gif"
when "JCB"
@card_src = "jcb.png"
when "MasterCard"
@card_src = "logo_mastercard.gif"
when "American Express"
@card_src = "american_express.png"
when "Diners Club"
@card_src = "diners_club.png"
when "Discover"
@card_src = "discover.png"
end
# viewの記述を簡略化
## 有効期限'月'を定義
@exp_month = @default_card_information.exp_month.to_s
## 有効期限'年'を定義
@exp_year = @default_card_information.exp_year.to_s.slice(2,3)
end
else
redirect_to("")
flash[:alert] = "利用を終了するを押してください"
end
else
redirect_to("/purchase/history")
end
end
def pay
@user = @current_user
@path = Rails.application.routes.recognize_path(request.referer)
Payjp.api_key = ENV['PAYJP_PRIVATE_KEY']
if @path[:controller] == "purchase"
@parking = Park.find_by(user_id: @user.id, finish_stamp: "no")
@price = @parking.price
@card = Card.find_by(user_id: @user.id)
if @card.present?
# ログインユーザーがクレジットカード登録済みの場合の処理
# ログインユーザーのクレジットカード情報を引っ張ってきます。
#登録したカードでの、クレジットカード決済処理
charge = Payjp::Charge.create(
# 商品(product)の値段を引っ張ってきて決済金額(amount)に入れる
amount: @price,
customer: Payjp::Customer.retrieve(@card.customer_id),
currency: 'jpy'
)
else
# ログインユーザーがクレジットカード登録されていない場合(Checkout機能による処理を行います)
# APIの「Checkout」ライブラリによる決済処理の記述
Payjp::Charge.create(
amount: @price,
card: params['payjp-token'], # フォームを送信すると作成・送信されてくるトークン
currency: 'jpy'
)
end
@purchase = Purchase.create(user_id: @user.id, no_reservation_id: @user.id, start_on: @parking.start_on, finish_on: @parking.finish_on, price: @price)
@parking.finish_stamp = "yes"
@parking.save
redirect_to action: 'done'
end
def done
@user = @current_user
@parking = Park.where(user_id: @user.id, finish_stamp: "yes").last
@price = @parking.price
end
end
app/controllers/card_controller.rb
class CardController < ApplicationController
require 'payjp'
before_action :authenticate_user!
def new
@card = Card.where(user_id: @current_user.id)
redirect_to("/user/#{@current_user.id}") if @card.exists?
end
def pay #payjpとCardのデータベース作成を実施します。
Payjp.api_key = ENV["PAYJP_PRIVATE_KEY"]
if params['payjp-token']
customer = Payjp::Customer.create(
description: '登録テスト', #なくてもOK
email: @current_user.email, #なくてもOK
card: params['payjp-token'],
metadata: {user_id: @current_user.id}
) #念の為metadataにuser_idを入れましたがなくてもOK
@card = Card.new(user_id: @current_user.id, customer_id: customer.id, card_id: customer.default_card)
if @card.save
redirect_to("/user/#{@current_user.id}")
else
redirect_to action: "pay"
end
else
redirect_to action: "new", alert: "クレジットカードを登録できませんでした。"
end
end
def delete #PayjpとCardデータベースを削除します
card = Card.where(user_id: @current_user.id).first
if card.blank?
redirect_to action: "new"
else
Payjp.api_key = ENV["PAYJP_PRIVATE_KEY"]
customer = Payjp::Customer.retrieve(card.customer_id)
customer.delete
card.delete
if card.destroy
redirect_to("/")
flash[:notice] = "削除しました。"
else
redirect_to user_path, alert: "削除できませんでした。"
end
end
end
def show #Cardのデータpayjpに送り情報を取り出します
card = Card.where(user_id: @current_user.id).first
if card.blank?
redirect_to action: "new"
else
Payjp.api_key = ENV["PAYJP_PRIVATE_KEY"]
customer = Payjp::Customer.retrieve(card.customer_id)
@default_card_information = customer.cards.retrieve(card.card_id)
##カードのアイコン表示のための定義づけ
@card_brand = @default_card_information.brand
case @card_brand
when "Visa"
# 例えば、Pay.jpからとってきたカード情報の、ブランドが"Visa"だった場合は返り値として
# (画像として登録されている)Visa.pngを返す
@card_src = "logo_visa.gif"
when "JCB"
@card_src = "jcb.png"
when "MasterCard"
@card_src = "logo_mastercard.gif"
when "American Express"
@card_src = "american_express.png"
when "Diners Club"
@card_src = "diners_club.png"
when "Discover"
@card_src = "discover.png"
end
# viewの記述を簡略化
## 有効期限'月'を定義
@exp_month = @default_card_information.exp_month.to_s
## 有効期限'年'を定義
@exp_year = @default_card_information.exp_year.to_s.slice(2,3)
end
end
end
このあとにも書いてあるとおり私はdeviseからfirebaseログインに変えたため、
authenticate_user!
はdeviseのものではなく独自に設定したものです。
したがって@current_userになっています。
お気をつけください。
[firebase]なぜユーザー登録でdeviseではなくfirebaseにしたか
最初はもちろんdeviseを使ってユーザー管理をしていました。 ですが途中から友達がIOSアプリとしても作りたいと言い出しました。
ユーザーを一致させたかったため、話し合いでfirebaseのauthenticateを使ったユーザー登録をすることにしました。
IOSはfirebaseしか使わないからと言われたからです。
ですが結論、railsでfirebaseログインを実装する必要はなかったと思います。
このアプリで言うと、その場利用や予約の情報もIOSに共有したいのでデータをバックエンドからswiftに送ることになります。
その手順でユーザーデータも送ることはできるはずなのでfirebaseログインを実装したのは経験としては価値がありましたが、無駄な労力でした。実装の説明が載ってる記事も片手で数えられるほどしかなかったからかなり苦労しましたし。
ただ、一応無事実装することができたので下にコードを載せておきます。
Googleログインだけ実装しています。
app/views/layouts/application.html.erb
<!-- The core Firebase JS SDK is always required and must be listed first -->
<script src="https://www.gstatic.com/firebasejs/8.2.7/firebase-app.js"></script>
<script src="https://www.gstatic.com/firebasejs/8.2.7/firebase-auth.js"></script>
<!-- TODO: Add SDKs for Firebase products that you want to use
https://firebase.google.com/docs/web/setup#available-libraries -->
<script src="https://www.gstatic.com/firebasejs/8.2.7/firebase-analytics.js"></script>
<script src="https://www.gstatic.com/firebasejs/ui/4.6.1/firebase-ui-auth.js"></script>
<link type="text/css" rel="stylesheet" href="https://www.gstatic.com/firebasejs/ui/4.6.1/firebase-ui-auth.css" />
<script>
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
if(!firebase.apps.length) {
const firebaseConfig = {
apiKey: "xxxxxxxxx",
authDomain: "xxxxxxxxx.firebaseapp.com",
projectId: "xxxxxxxxx",
storageBucket: "xxxxxxxxx.appspot.com",
messagingSenderId: "xxxxxxxxx",
appId: "xxxxxxxxx",
measurementId: "xxxxxxxxx"
};
firebase.initializeApp(firebaseConfig);
firebase.analytics();
}
</script>
</body>
app/javascript/firebase.js
document.addEventListener("turbolinks:load"
, function () {
$(function() {
if (document.getElementById("firebaseui-auth-container") != null) {
var ui = new firebaseui.auth.AuthUI(firebase.auth());
var uiConfig = {
callbacks: {
signInSuccessWithAuthResult: (authResult, redirectUrl) => {
authResult.user.getIdToken(true)
.then((idToken) => { railsLogin(authResult.additionalUserInfo.isNewUser, idToken) })
.catch((error) => { console.log(`Firebase getIdToken failed!: ${error.message}`) });
return false; // firebase側にログイン後はリダイレクトせず、railsへajaxでリクエストを送る
},
uiShown: () => { document.getElementById('loader').style.display = 'none' }
},
signInFlow: 'redirect',
signInOptions: [
firebase.auth.GoogleAuthProvider.PROVIDER_ID // Google認証
],
tosUrl: '',
// Privacy policy url/callback.
privacyPolicyUrl: function() {
window.location.assign('');
}
};
// ログイン画面表示
ui.start('#firebaseui-auth-container', uiConfig);
var csrfTokenObj = () => {
return { "X-CSRF-TOKEN": $('meta[name="csrf-token"]').attr('content') };
}
var authorizationObj = (idToken) => {
return { "Authorization": `Bearer ${idToken}` };
}
var railsLogin = (isNewUser, idToken) => {
var url = isNewUser ? "/accounts" : "/login/googlecreate";
var headers = Object.assign(csrfTokenObj(), authorizationObj(idToken));
$.ajax({url: url, type: "POST", headers: headers})
.done((data) => { console.log("Rails login!") })
.fail((data) => { console.log("Rails login failed!") });
}
}
});
})
app/controllers/accounts_controller.erb
class AccountsController < FirebaseController
def create
super do |decoded_token|
User.create(
email: decoded_token['decoded_token'][:payload]['email'],
uid: decoded_token['uid']
)
end
end
end
app/controllers/firebase_sessions_controller.erb
class FirebaseSessionsController < FirebaseController
def create
super do |decoded_token|
User.find_by(uid: decoded_token['uid'])
end
end
# DELETE /logout
def destroy
flash[:success] = 'ログアウトしました。'
super
end
end
app/controllers/firebase_controller.erb
class FirebaseController < ApplicationController
def create
if decoded_token = authenticate_firebase_id_token
user = yield(decoded_token)
session[:user_id] = user.id
flash[:notice] = 'ログインしました。'
redirect_to("/")
else
flash[:alert] = 'ログインできませんでした。'
redirect_to("/login")
end
end
end
config/firebase_config.yml
project_info:
project_number: "xxxxxx"
firebase_url: "https://xxxxxxx.firebaseio.com"
project_id: "xxxxxxxx"
secret: ""
Gemfile
gem 'jwt'
app/controllers/application_controller.erb
class ApplicationController < ActionController::Base
protect_from_forgery with: :exception
include SessionsHelper
private
# tokenが正規のものであれば、デコード結果を返す
# そうでなければfalseを返す
def authenticate_firebase_id_token
# authenticate_with_http_tokenは、HTTPリクエストヘッダーに
# Authorizationが含まれていればブロックを評価する。
# 含まれていなければnilを返す。
authenticate_with_http_token do |token, options|
begin
decoded_token = FirebaseHelper::Auth.verify_id_token(token)
rescue => e
logger.error(e.message)
false
end
end
end
end
app/helpers/firebase_helper.rb
require 'jwt'
require 'yaml'
require 'net/http'
module FirebaseHelper
CONFIG = YAML.load_file(Rails.root.join("config/firebase_config.yml"))
module Auth
ALGORITHM = 'RS256'
ISSUER_BASE_URL = 'https://securetoken.google.com/'
CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'
class << self
def verify_id_token(token)
raise 'Id token must be a String' unless token.is_a?(String)
full_decoded_token = decode_jwt(token)
errors = validate(full_decoded_token)
raise errors.join(" / ") unless errors.empty?
public_key = fetch_public_keys[full_decoded_token[:header]['kid']]
unless public_key
raise <<-EOS.squish
Firebase ID token has "kid" claim which does not correspond to a known public key.
Most likely the ID token is expired, so get a fresh token from your client app and try again.
EOS
end
certificate = OpenSSL::X509::Certificate.new(public_key)
decoded_token = decode_jwt(token, certificate.public_key, true, { algorithm: ALGORITHM, verify_iat: true })
{
'uid' => decoded_token[:payload]['sub'],
'decoded_token' => decoded_token
}
end
private
def decode_jwt(token, key=nil, verify=false, options={})
begin
decoded_token = JWT.decode(token, key, verify, options)
rescue JWT::ExpiredSignature => e
raise 'Firebase ID token has expired. Get a fresh token from your client app and try again.'
rescue => e
raise "Firebase ID token has invalid signature. #{e.message}"
end
{
payload: decoded_token[0],
header: decoded_token[1]
}
end
def fetch_public_keys
uri = URI.parse(CLIENT_CERT_URL)
https = Net::HTTP.new(uri.host, uri.port)
https.use_ssl = true
res = https.start {
https.get(uri.request_uri)
}
data = JSON.parse(res.body)
if (data['error']) then
msg = %Q(Error fetching public keys for Google certs: #{data['error']} (#{res['error_description']})) if (data['error_description'])
raise msg
end
data
end
def validate(json)
errors = Array.new
project_id = FirebaseHelper::CONFIG['project_info']['project_id']
payload = json[:payload]
header = json[:header]
issuer = ISSUER_BASE_URL + project_id
unless header['kid'] then errors << %Q(Firebase ID token has no "kid" claim.) end
unless header['alg'] == ALGORITHM then errors << %Q(Firebase ID token has incorrect algorithm. Expected "#{ALGORITHM}" but got "#{header['alg']}".) end
unless payload['aud'] == project_id then errors << %Q(Firebase ID token has incorrect aud (audience) claim. Expected "#{project_id}" but got "#{payload['aud']}".) end
unless payload['iss'] == issuer then errors << %Q(Firebase ID token has incorrect "iss" (issuer) claim. Expected "#{issuer}" but got "#{payload['iss']}".) end
unless payload['sub'].is_a?(String) then errors << %Q(Firebase ID token has no "sub" (subject) claim.) end
if payload['sub'].empty? then errors << %Q(Firebase ID token has an empty string "sub" (subject) claim.) end
if payload['sub'].size > 128 then errors << %Q(Firebase ID token has "sub" (subject) claim longer than 128 characters.) end
errors
end
end
end
end
気をつけるポイントは
accounts_controllerとfirebase_controllerで
1行目
class AccountsController < FirebaseController
とすることです。
初めてコードをブログに載せましたがこんなに大変な作業だと思いませんでした。
これからありがたみを感じながら記事を読むことにします。