Paypalで決済が完了したときに、PaypalからIPN(Instant Payment Notification)を受信し、Railsで適切に処理する方法をまとめました。
PaypalからコールされるデータをUTF-8に統一
これをしておかないと謎のエラーに苦しめられるので、初めに設定しておきます。
デフォルトではShift-JISでコールされます。
- 「マイアカウント」→「プロファイル設定」→「言語のエンコード」→「詳細オプション」
- 「エンコード方式」をUTF-8に
- 「PayPalから送信されたデータと同じエンコード方式を使用しますか?」をUTF-8に
参考) http://blog.katsuma.tv/2007/06/paypal_pdt_ipn_code.html
Paypalボタンに各種URLを設定
Paypalにポストするhiddenタグに各種URLを追加。
- notify_url IPNの通知先URL
- return ユーザが購入処理後に戻ってくるURL
- cancel_return ユーザが購入をキャンセルしたときに戻ってくるURL
example_form.html.erb
<% base_url =request.protocol + request.host_with_port %>
<% is_sandbox = true # 本番環境の場合はfalse %>
<% if is_sandbox %>
<% paypal_url = "https://www.sandbox.paypal.com/cgi-bin/webscr" %>
<% else %>
<% paypal_url = "https://www.paypal.com/cgi-bin/webscr" %>
<% end %>
<form action="<%= paypal_url %>" method="post" target="_top">
<%= hidden_field_tag 'notify_url', "#{base_url}/paypal/ipn" %>
<%= hidden_field_tag 'return', "#{base_url}/paypal/success" %>
<%= hidden_field_tag 'cancel_return', "#{base_url}/paypal/cancel" %>
<%= hidden_field_tag 'cmd', "_s-xclick" %>
<%= hidden_field_tag 'hosted_button_id', YOUR_BUTTON_ID %>
<input type="submit" value="購入" />
</form>
routes.rb
RAILS_ROOT/config/routes.rb
post '/paypal/purchase' => 'my_paypal#ipn'
post '/paypal/success' => 'my_paypal#success'
get '/paypal/success' => 'my_paypal#success'
post '/paypal/cancel' => 'my_paypal#cancel'
get '/paypal/cancel' => 'my_paypal#cancel'
Controller
Paypal関連の処理のみに絞ったPaypalControllerを作成し、それを継承したMyPaypalControllerでアプリに応じた処理を記述する。
こうすることで、PaypalControllerは使いまわすことができ、継承したコントローラではアプリに応じた処理を集中的に記述することができます。
PaypalControllerの処理
Paypalのドキュメントに従い、IPNを適切に処理する。
- IPNを受け付ける
- IPNのステータスに応じた処理を行う
- IPNがPaypalから送信されたものかをチェックする
- 同じ購入IDの処理が重複していないかをチェックする
- 受取人のメールアドレスが事業者のPayPalアカウントと一致するかをチェックする
- 商品の情報が正しいかをチェックする
ただし、具体的な処理は適宜、継承先のメソッドに委ねている。
参考) http://stackoverflow.com/questions/14316426/is-there-a-paypal-ipn-code-sample-for-ruby-on-rails
RAILS_ROOT/app/controllers/paypal_controller.rb
class PaypalController < ApplicationController
# PaypalのサーバからのIPNの送信を受け付けるため、CSRF対策を無効にする
protect_from_forgery with: :null_session
# ipnの送信を受け付けるメソッド
def ipn
is_sandbox = params[:test_ipn] ? true : false
logger.info ({is_sandbox: is_sandbox}) if ipn_log_level > 0
if skip_validate?
response = "VERIFIED"
else
response = validate_ipn_notification(request.raw_post, is_sandbox)
end
case response
when "VERIFIED"
status = params[:payment_status]
if status == "Completed"
if ipn_overlap?(params, is_sandbox)
ipn_overlap(params, is_sandbox)
else
ipn_completed(params, is_sandbox)
end
elsif status == "Pending"
ipn_pending(params, is_sandbox)
elsif status == "Failed"
ipn_failed(params, is_sandbox)
end
if ipn_log_level == 1 && status == "Completed"
logger.info ({status: status})
elsif ipn_log_level > 0
logger.info ({status: status, params: params})
end
when "INVALID"
logger.info ({status: 'invalid', params: params}) if ipn_log_level > 0
ipn_invalid(params, is_sandbox)
else
logger.info ({status: 'validate_error', params: params }) if ipn_log_level > 0
ipn_validate_error(params, is_sandbox)
end
render :nothing => true, status: :ok # paypalに200(OK)を返す。
rescue => e
logger.info(e) if ipn_log_level == 1
logger.info({ message: e.to_s, backtrace: e.backtrace }) if ipn_log_level == 2
render json: {message: e.to_s}, status: :internal_server_error
end
protected
# 購入が正常に完了した時の処理
# オーバーライドを強制
def ipn_completed(params, is_sandbox)
# override this
raise NotImplementedError, "You must implement #{self.class}##{__method__}"
end
# 購入が重複した場合の処理
def ipn_overlap(params, is_sandbox)
# override this
end
# 購入がPending(未決済)の場合の処理
def ipn_pending(params, is_sandbox)
# override this
end
# 購入がFailed(失敗)の場合の処理
def ipn_failed(params, is_sandbox)
# override this
end
# 購入が不正の場合の処理
def ipn_invalid(params, is_sandbox)
# override this
end
# 通知の確認自体が失敗した時の処理
def ipn_validate_error(params, is_sandbox)
# override this
end
# 判定関係
# 同じ購入IDの処理が重複しているかの判定
def ipn_overlap?(params, is_sandbox)
# params[:txn_id] の比較
# override this
false
end
# receiver_emailが事業者のPayPalアカウントと一致するかを判定
def ipn_receiver_email_correct?(email, is_sandbox)
# override this
true
end
# 商品の情報に誤りがないかを確認
def ipn_item_correct?(params, is_sandbox)
# override this
true
end
# ログの記録
# 0: 記録しない
# 1: 不正なアクセスは記録
# 2: 記録
def ipn_log_level
2
end
# 通知の確認をスキップするか(テスト用)
# trueにした場合、PaypalからのIPNを偽装したアクセスが可能になる。
# 本番環境では必ずfalse
def skip_validate?
false
end
private
# 受信したIPNが本当にPaypalから送信されたものかを確認する。
def validate_ipn_notification(raw, is_sandbox = false)
uri = URI.parse("https://www.#{is_sandbox ? 'sandbox.' : ''}paypal.com/cgi-bin/webscr?cmd=_notify-validate")
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 60
http.read_timeout = 60
http.verify_mode = OpenSSL::SSL::VERIFY_NONE
http.use_ssl = true
response = http.post(uri.request_uri, raw,
'Content-Length' => "#{raw.size}",
'User-Agent' => "My custom user agent"
).body
end
end
MyPaypalControllerの処理
PaypalControllerのメソッドをオーバーライドし、各種の具体的処理を行う。
下記のメソッド内の処理はあくまでも一例である。
RAILS_ROOT/app/controllers/my_paypal_controller.rb
class MyPaypalController < PaypalController
def success
flash[:success] = '購入が処理されました。'
redirect_to '/hoge'
end
def cancel
flash[:danger] = '購入がキャンセルされました。'
redirect_to '/hoge/cancel'
end
protected
# 購入が正常に完了した時の処理
def ipn_completed(params, is_sandbox)
status = params[:payment_status]
email = params[:payer_email]
price = params[:mc_gross].to_i
btn_id = params[:btn_id]
txn_id = params[:txn_id]
item_number = params[:item_number]
item_name = params[:item_name]
# パラメータに基づいてDBに情報を登録するなどの処理
end
def ipn_overlap(params, is_sandbox)
# 購入が重複した場合の処理
end
def ipn_pending(params, is_sandbox)
# 購入がPending(未決済)の場合の処理
end
def ipn_failed(params, is_sandbox)
# 購入がFailed(失敗)の場合の処理
end
def ipn_invalid(params, is_sandbox)
# 購入が不正の場合の処理
end
def ipn_validate_error(params, is_sandbox)
# 通知の確認自体が失敗した時の処理
end
# 判定関係
# 同じ購入IDの処理が重複しているかの判定
def ipn_overlap?(params, is_sandbox)
txn_id = params[:txn_id]
YourModel.find_by_txn_id(txn_id)
end
# receiver_emailが事業者のPayPalアカウントと一致するかを判定
def ipn_receiver_email_correct?(email, is_sandbox)
email == ENV['PAYPAL_EMAIL']
end
# 商品の情報に誤りがないかを確認
def ipn_item_correct?(params, is_sandbox)
item_number = params[:item_number]
price = params[:mc_gross].to_i
btn_id = params[:btn_id]
item = ItemModel.find__by_item_number(item_number)
unless item
return false
end
(item.price == price && item.btn_id == btn_id)
end
# ログの記録
# 0: 記録しない
# 1: 不正なアクセスは記録
# 2: 記録
def ipn_log_level
2
end
# 通知の確認をスキップするか(テスト用)
def skip_validate?
false
end
end