RailsでPaypalのIPNを処理

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

```erb: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

```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のドキュメント](https://developer.paypal.com/docs/classic/ipn/ht_ipn/)に従い、IPNを適切に処理する。

* IPNを受け付ける
* IPNのステータスに応じた処理を行う
* IPNがPaypalから送信されたものかをチェックする
* 同じ購入IDの処理が重複していないかをチェックする
* 受取人のメールアドレスが事業者のPayPalアカウントと一致するかをチェックする
* 商品の情報が正しいかをチェックする

ただし、具体的な処理は適宜、継承先のメソッドに委ねている。

参考) http://stackoverflow.com/questions/14316426/is-there-a-paypal-ipn-code-sample-for-ruby-on-rails

```rb: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のメソッドをオーバーライドし、各種の具体的処理を行う。
下記のメソッド内の処理はあくまでも一例である。

```rb: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

```