Skip to main content

[探索 5 分鐘] PayPal 的沙箱測試


在 CNIS (Canadian Network for International Surgery, 非營利組 織) 當志工時有的負責 SugarCRM 的同事需要跟 PayPal 介接支付, 問我要不要玩一下。稍微實驗一下發現可行, 主要是 PayPal 的沙箱做得不錯, 不會讓你不小心扣到錢 ...。

程式筆記

Email : invalid IPN detected

有時候, 就會發現收到這種通知, 感覺有測成功又覺得哪裡怪怪的

Email: VALID IPN RECEIVED

在 PayPal Instant Payment Notification (IPN) 上有一點卡住, 後來找到一份代碼 Roberto Gomes 2008, 就迎刃而解了, 後面會把原始碼整個提供出來。
沒看到紅字真好 !

myLisener.php

上述 email 顯示的交易情境, 主要是透過 PalPal IPN.php Library 完成的, 客戶端代碼只需要關注  $_POST 資料以及 IPN class 需要的必要資訊, 如交易通知信件以及 log 事件通知信件, 類似這樣:
<?php  
 $_POST['item_name'] = 'Stuffed Bear';  
 $_POST['item_price'] = '$80';  
 $_POST['quantity'] = '8';  
 $_POST['total_amount'] = '40';  
?>  
<?php  
 require 'IPN.php';
 $handler = new IPN();  
 $handler->set_paypal_mail('your.paypal.email');  
 $handler->set_log_mail('your.email');  
 $handler->ipn_is_valid();  
 $handler->complete();  
?> 
當然, $_POST 可以填充更完整的訊息, 可參考這些屬性值
 test_ipn: 1  
 payment_type: instant  
 payment_date: 14:48:23 Sep 26, 2012 PDT  
 payment_status: Completed  
 address_status: confirmed  
 payer_status: unverified  
 first_name: John  
 last_name: Smith  
 payer_email: buyer@paypalsandbox.com  
 payer_id: TESTBUYERID01  
 address_name: John Smith  
 address_country: United States  
 address_country_code: US  
 address_zip: 95131  
 address_state: CA  
 address_city: San Jose  
 address_street: 123, any street  
 receiver_email: seller@paypalsandbox.com  
 receiver_id: TESTSELLERID1  
 residence_country: US  
 item_name1: something  
 item_number1: AK-1234  
 quantity1: 1  
 tax: 2.02  
 mc_currency: USD  
 mc_fee: 0.44  
 mc_gross_1: 9.34  
 mc_handling: 2.06  
 mc_handling1: 1.67  
 mc_shipping: 3.02  

IPN.php

完整的 IPN.php Library 如下, 當初寫的時候太傻太天真, 直接修改原始碼 (請勿模仿), 我蓋掉的部分有這些變數:

dev 變數

  • public $paypal_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr'; // 'https://www.paypal.com/cgi-bin/webscr';
  • public $paypal_email = ''; // your paypal email (the one that receives the payments)  
  • $socket = fsockopen($url_parsed['host'],80,$err_num,$err_str,30); // connect to paypal   
  • public $log_email = ''; // where you want to receive the logs 
  • protected function write_db() { return; } // for test  
  • protected function txn_is_duplicate() { return false; } // for test  

原始碼

<?php  
 /**  
  * Paypal IPN class  
  * v1.0 (27/05/2008)  
  * Copyright 2008 Roberto Gomes  
  * http://ptdev.net  
  *  
  *  This program is free software: you can redistribute it and/or modify  
  *  it under the terms of the GNU General Public License as published by  
   *  the Free Software Foundation, either version 3 of the License, or  
   *  (at your option) any later version.  
   *  
   *  This program is distributed in the hope that it will be useful,  
   *  but WITHOUT ANY WARRANTY; without even the implied warranty of  
   *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the  
   *  GNU General Public License for more details.  
   *  
   *  You should have received a copy of the GNU General Public License  
   *  along with this program. If not, see <http://www.gnu.org/licenses/>.  
   */  
  class IPN {  
  // Official url: https://www.paypal.com/cgi-bin/webscr  
  // Testing urls: (do test!)  
  // - https://www.sandbox.paypal.com/cgi-bin/webscr  
  // - http://www.eliteweaver.co.uk/testing/ipntest.php  
  public $paypal_url = 'https://www.sandbox.paypal.com/cgi-bin/webscr';//'https://www.paypal.com/cgi-bin/webscr';  
  // your paypal email (the one that receives the payments)  
  public $paypal_email = '';    // log to file options  
  public $log_to_file = true;         // write logs to file  
  public $log_filename = '/path/to/ipn.log'; // the log filename (should NOT be web accessible)  
  // log to e-mail options  
  public $log_to_email = true;        // send logs by e-mail  
  public $log_email = '';   // where you want to receive the logs  
  public $log_subject = 'IPN Log: ';     // prefix for the e-mail subject  
  // database information  
  public $log_to_db = true;          // false not recommended  
  public $db_host = 'localhost';  
  public $db_user = 'some_user';  
  public $db_pass = 'some_password';  
  public $db_name = 'ipn';  
  // array of currencies accepted or false to disable  
  public $currencies = array('USD');  
  // date format on log headers (default: dd/mm/YYYY HH:mm:ss)  
  // see http://php.net/date  
  public $date_format = 'd/m/Y H:i:s';  
  // holds the ipn in a "pretty" way for viewing on logs and emails, can set prefix here  
  public $pretty_ipn = "IPN Values received:\n\n";  
  // the IPN information received by post will be on this array  
  public $ipn = array();  
  // the database resource  
  protected $dbc;  
  public function set_paypal_mail($val) {  
       $this->paypal_email = $val;  
  }  
  public function set_log_mail($val) {  
       $this->log_email = $val;  
  }  
  // this is where the action is  
  public function ipn_is_valid() {  
    // loop through the IPN received by POST and do 3 things:  
    // - populate the ipn_data array  
    // - generate a "pretty" list of all ipn variables received (for file logs and e-mails)  
    // - generate the IPN verification string to post back to paypal for validation  
    foreach ($_POST as $key => $value) {  
      $this->ipn["$key"] = $value;  
      $this->pretty_ipn .= "$key: $value\n";  
      $req .= "&$key=".urlencode(stripslashes($value));  
    }  
    // post the ipn back to paypal and exit if invalid  
    if(!$this->ipn_postback($req)) {  
      return false;  
    }  
    // got verified  
    // do the paypal recommended validations  
    // check if payment status is completed  
    if($this->ipn['payment_status'] != 'Completed') {  
      $this->write_log('WARNING: payment status not completed', $this->pretty_ipn);  
      return false;  
    }  
    // check if it's a duplicate transaction id  
    if($this->txn_is_duplicate()) {  
      $this->write_log('WARNING: duplicate transaction id detected', $this->pretty_ipn);  
      return false;  
    }  
    // check if it was payed to your e-mail  
    if($this->ipn['receiver_email'] != $this->paypal_email) {  
      $this->write_log('WARNING: payment was made to different e-mail account', $this->pretty_ipn);  
      return false;  
    }  
    if($this->currencies && !in_array($this->ipn['mc_currency'],$this->currencies)) {  
      $this->write_log('WARNING: payment in unsupported currency', $this->pretty_ipn);  
      return false;  
    }  
   // if we didn't return false until now, then everything should be correct  
   return true;  
 }  
 // this method must be called after ipn_is_valid() and all your custom validations have passed  
 // it finally updates the database (if active) and logs the valid ipn  
  // you can also call it anyway if you want to log invalid ipn's to the database  
  public function complete() {  
    if(is_resource($this->dbc)) {  
      $this->write_db();  
    }  
    $this->write_log('VALID IPN RECEIVED', $this->pretty_ipn);  
  }  
  // this method sends the ipn back to paypal  
  // returns true if Paypal says verified  
  protected function ipn_postback($req) {  
    // split the paypal url  
    $url_parsed=parse_url($this->paypal_url);  
    // connect to paypal  
    $socket = fsockopen($url_parsed['host'],80,$err_num,$err_str,30);  
    if(!$socket) {  
      // could not open the connection. Log it and return  
      $this->write_log('WARNING: failed connection to Paypal',"Error establishing connection to paypal\n\nfsockopen error no. $err_num: $err_str");  
      return false;  
    } else {  
      // connected, add the ipn validation cmd and post everything back to PayPal  
      $req = 'cmd=_notify-validate' . $req;  
      $header .= "POST ".$url_parsed['path']." HTTP/1.0\r\n";  
      $header .= "Content-Type: application/x-www-form-urlencoded\r\n";  
      $header .= "Content-Length: " . strlen($req) . "\r\n\r\n";  
      fputs($socket, $header . $req);  
      // loop through the response from the server and assign it to a variable  
      while(!feof($socket)) {  
        $res .= fgets($socket, 1024);  
      }  
      // close connection  
      fclose($socket);  
      // check Paypals response  
      if (eregi("VERIFIED",$res)) {  
        return true;  
      } else {  
        $this->write_log('WARNING: invalid IPN detected', $this->pretty_ipn);  
        return false;  
      }  
    }  
  }  
  // checks for duplicate transaction id  
  // ignored when database is off  
  // also only now we need to connect to the db if enabled  
  protected function txn_is_duplicate() {  
       return false; // for test  
    if($this->log_to_db) {  
      if($this->connect_db()) {  
        if(mysql_num_rows(mysql_query("SELECT txn_id FROM ipn WHERE txn_id='".mysql_real_escape_string($this->ipn['txn_id'],$this->dbc)."'",$this->dbc)) != 0) {  
          return true;  
        }  
      }  
    }  
    return false;  
  }  
  // connects to database  
  protected function connect_db() {  
    $this->dbc = mysql_connect($this->db_host, $this->db_user, $this->db_pass);  
    if(!$this->dbc) {  
      $this->write_log('WARNING: failed connection to database', "Error connecting to database\n\nmysql error no. " . mysql_errno() . ': ' . mysql_error());  
      return false;  
    } else {  
      $db = mysql_select_db($this->db_name, $this->dbc);  
      if(!$db) {  
        $this->write_log('WARNING: error selecting database',"Error selecting database\n\nmysql error no. " . mysql_errno() . ': ' . mysql_error());  
        return false;  
      } else {  
        return true;  
      }  
    }  
  }  
  // writes the log to file and/or sends to email according to preferences  
  // parameters:  
  // $log_msg -> short descriptive msg (gets appended to e-mail subjects)  
  // $log_descr -> everyting else, generally the pretty ipn (goes in e-mail body and file logs)  
  protected function write_log($log_msg,$log_descr) {  
    $thelog = "------------------------------------------------\n";  
    $thelog .= '----------- [ '.date($this->date_format).' ] ------------' . "\n";  
    $thelog .= "------------------------------------------------\n";  
    $thelog .= $log_msg . "\n\n";  
    $thelog .= $log_descr . "\n";  
    $thelog .= "------------------------------------------------\n";  
    $thelog .= "------------------------------------------------\n\n\n\n";  
    // log to file if enabled  
    if($this->log_to_file) {  
      $fp = fopen($this->log_filename,'a');  
      fwrite($fp, $thelog);  
      fclose($fp); // close file  
    }  
    // send email if enabled  
    if($this->log_to_email) {  
      mail($this->log_email, "$this->log_subject $log_msg", $thelog);  
    }  
  }  
  // write the ipn to the database  
  // the hard way because different types of payment send different vars and may mess up the order  
  protected function write_db() {  
       return; // for test  
    $sql = 'INSERT INTO ipn VALUES(NULL,';  
    $sql .= "'".mysql_real_escape_string($this->ipn['mc_gross'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_status'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payer_id'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['tax'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_street'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payment_date'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payment_status'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['charset'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_zip'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['first_name'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['mc_fee'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_country_code'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_name'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['notify_version'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['custom'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payer_status'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['business'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_country'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_city'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['quantity'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['verify_sign'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payer_email'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['txn_id'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payment_type'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['last_name'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['address_state'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['receiver_email'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payment_fee'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['receiver_id'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['txn_type'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['item_name'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['mc_currency'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['item_number'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['residence_country'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['test_ipn'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['payment_gross'],$this->dbc)."',";  
    $sql .= "'".mysql_real_escape_string($this->ipn['shipping'],$this->dbc)."',";  
    $sql .= "NOW())";  
    //echo $sql;  
    mysql_query($sql, $this->dbc);  
    // check for insert errors, log the query and the ipn for analysis  
    if(mysql_affected_rows() != 1) {  
      $this->write_log('WARNING: error saving to database',"SQL query:\n$sql\n\n mysql error no " . mysql_errno() . ': ' . mysql_error() . "\n\n" . $this->pretty_ipn);  
    }  
  }  
  }  
 ?>  
為了測試, 把 DB 機制先關閉, 也允許重複的交易編號, 先確定 email 信件內容正常; 而後再打開 write_db(), 正式來就不允許有重複交易編號了, db 的  'txn_id' 交易編號欄位加上 unique 索引, 就可抵禦重複資料灌進來了 !

對了, 正式來的時候, 請記得把 $paypal_url 變數切換為正式的API 服務網址: https://www.paypal.com/cgi-bin/webscr

參考資料


Comments