<?php
/*
#
# PayPal IPN Handling Agent
#
# $Id: ipn_agent.php,v 1.7 2005/02/10 18:08:27 nemesis Exp $
#
# Copyright (C) 2004 Kees Cook
# kees@outflux.net, http://outflux.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 2
# 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, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
# http://www.gnu.org/copyleft/gpl.html
#
*/

require_once("DB.php");

class IPN_Agent {
    /* class-specific */
    var $version="1.2.0"; /* release of IPN Agent */
    var $ipn_protocol_version="1.6"; /* IPN protocol designed against */
    var $url="https://www.paypal.com/cgi-bin/webscr"; /* verification URL */
    /*
      It would be nice to get access to the full certificate so
      we can test the full CN better.  For now, depend on libcurl
      and openssl's sanity checking.
    var $paypal_cn="/C=US/ST=California/L=Palo Alto/O=PayPal, Inc./OU=Information Systems/CN=www.paypal.com";
    */

    // These are the fields to save to the database
    var $record_fields=array(
            /* Basic Information */
            "business",
            "receiver_email",
            "receiver_id",
            # "item_name*",
            # "item_number*",
            # "quantity*",
            # These fields will show up in a txn_id-indexed "cart" table

            /* Advanced and Custom Information */
            "invoice",
            "custom",
            "memo",
            "tax",
            # "option_name*",,
            # "option_select*",
            # These fields will show up in a txn_id-indexed "options" table

            /* Shopping Cart Information */
            "num_cart_items",

            /* Transaction Information */
            "payment_status",
            "pending_reason",
            "reason_code",
            "payment_date",
            "txn_id",
            "parent_txn_id",
            "txn_type",
            "payment_type",

            /* Currency and Exchange Information */
            "mc_gross",
            "mc_fee",
            "mc_currency",
            "settle_amount",
            "settle_currency",
            "exchange_rate",
            "payment_gross",
            "payment_fee",
            
            /* Auction Information */
            "for_auction",
            "auction_buyer_id",
            "auction_closing_date",
            "auction_multi_item",

            /* Buyer Information */
            "first_name",
            "last_name",
            "payer_business_name",
            "address_name",
            "address_street",
            "address_city",
            "address_state",
            "address_zip",
            "address_country",
            "address_status",
            "payer_email",
            "payer_id",
            "payer_status",

            /* IPN Information */
            "notify_version",

            /* Security Information */
            "verify_sign",
        );

    # These are fields that may or may not have numbers after them, 
    # with multiple entries per IPN
    var $record_numbered_fields = array(
            /* Basic Information */
            "cart" => array(
                        "item_name",
                        "item_number",
                        "quantity",
                      ),

            /* Advanced and Custom Information */
            "options" => array(
                            "option_name",
                            "option_selection",
                         ),
        );

    /* instance-specific configurables */
    var $merchant_email;
    var $ca_file;
    var $ignore_cert; /* this is DANGEROUS to set.  Be warned! */
    var $tablename_prefix;
    var $dsn;
    var $error_file; /* where to log errors */
    var $run_stored;    /* tool to run when a txn is recorded */
    var $run_completed; /* tool to run when a txn is completed */

    /* instance-specific long-running */
    var $db;        /* database handle */

    /*********************/
    /* Public functions */
    function IPN_Agent($args) {
        $this->merchant_email = $args["merchant_email"];
        $this->ca_file = $args["ca_file"];
        $this->ignore_cert = $args["ignore_cert"];
        $this->tablename_prefix = $args["tablename_prefix"];
        $this->dsn = $args["dsn"];
        $this->error_file = $args["error_file"];
        $this->run_stored = $args["run_stored"];
        $this->run_completed = $args["run_completed"];

        // Change the verfication URL.  Hopefully this is only for
        // debugging your scripts.  As such, you'll probably want to
        // turn off the "ca_file", and turn on "ignore_cert".
        if (isset($args["url"])) {
            $this->url=$args["url"];
        }

        
        $this->stderr = @fopen($this->error_file, 'a');
        if (!$this->stderr) {
            die("Cannot open 'error_file' for logging");
        }
    }

    function init() {
        if (!isset($this->dsn)) {
            $this->_log("'dsn' not set");
            return FALSE;
        }
        if (empty($this->dsn["password"])) {
            $this->_log("DB DSN's 'password' is empty");
            return FALSE;
        }
        if (!isset($this->merchant_email)) {
            $this->_log("'merchant_email' not set");
            return FALSE;
        }

        $options = array(
                        'debug' => 2,
                        //portability => DB_PORTABILITY_ALL,
                        );

        $this->db =& DB::connect($this->dsn, $options);
        if (DB::isError($this->db)) {
            $this->_log($this->db->getDebugInfo());
            return FALSE;
        }

        return TRUE;
    }

    # Call with $_POST
    function handle_ipn($fields) {
        $id = $fields["txn_id"];
        #if (!isset($id) || empty($id)) {
        #    $this->_log("Missing txn_id");
        #    return FALSE;
        #}

        foreach ($fields as $field => $value) {
            if (preg_match("/[^A-Za-z0-9\-_]/",$field)) {
                $this->_log("Invalid character in POST field '$field'");
                return FALSE;
            }
        }

        if (!$this->_validate_ipn($fields)) {
            $this->_log("Invalid fields in IPN $id");
            return FALSE;
        }

        # Check uniqueness
        $state=$this->_get_state($id);
        if (isset($state) && !empty($state)) {
            if ($state=="Pending" &&
                ($fields["payment_status"]=="Completed" ||
                 $fields["payment_status"]=="Failed")) {

                # Removing duplicate txn_id "Pending" transaction
                $this->_log("Removing Pending IPN $id");
                $sql=sprintf("DELETE FROM %s WHERE txn_id=%s",
                             $this->tablename_prefix."transactions",
                             $this->db->quoteSmart($id));

                $result =& $this->db->query($sql);
                if (DB::isError($result) ||
                    $this->db->affectedRows() != 1) {
                    $this->_log("IPN $id: DELETE transaction failed: ".$result->getDebugInfo());
                    return FALSE;
                }

                # Removing duplicate txn_id cart data
                $sql=sprintf("DELETE FROM %s WHERE txn_id=%s",
                             $this->tablename_prefix."cart",
                             $this->db->quoteSmart($id));

                $result =& $this->db->query($sql);
                if (DB::isError($result) ||
                    $this->db->affectedRows() != 1) {
                    $this->_log("IPN $id: DELETE cart failed: ".$result->getDebugInfo());
                    return FALSE;
                }
            }
            else {
                $this->_log("Bad or Duplicate txn_id in IPN $id: '$state'");
                return FALSE;
            }
        }
        $this->_log("Verified IPN $id");

        # Store everything to a database for cart app to examine if they want
        if (!$this->_do_record($fields)) {
            $this->_log("Failed to store IPN $id");
            return FALSE;
        }

        # perform configurable action here (needs to go into background!)
        $this->_log("Purchase ".$fields["payment_status"].": IPN $id");
        if (isset($this->run_stored)) {
            $this->_log("Taking 'Stored' action: IPN $id");
            system(sprintf("%s %s",
                           $this->run_stored,
                           escapeshellarg($id)));
        }
    
        # perform configurable action here (needs to go into background!)
        if ($fields["payment_status"] == "Completed") {
            $this->_log("Taking 'Completed' action: IPN $id");
            if (isset($this->run_completed)) {
                system(sprintf("%s %s",
                               $this->run_completed,
                               escapeshellarg($id)));
            }
        }

        return TRUE;
    }

    /*********************/
    /* Private functions */
    function _log($str) {
        fwrite($this->stderr,sprintf("%s IPN_Agent: %s\n",
                                strftime("%c"), $str));
    }

    # Pass in:
    #    $fields: fields to POST back
    #    $ca: path to file containing CA file to verify https connections
    #    $url: url to use instead of paypal (for debugging)
    function _do_post($fields) {
        # Create a POST string out of the fields
        $post = array();
        foreach ($fields as $field => $value) {
            array_push($post,urlencode($field)."=".urlencode($value));
        }

        $id = $fields["txn_id"];

        $ch=curl_init();
        curl_setopt($ch, CURLOPT_URL, $this->url);
        curl_setopt($ch, CURLOPT_HEADER, 0);
        curl_setopt($ch, CURLOPT_POST, 1);
        curl_setopt($ch, CURLOPT_VERBOSE, 0);
        if (!isset($this->ca_file) && isset($this->ignore_cert)) {
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 0);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0);
        }
        else {
            curl_setopt($ch, CURLOPT_CAINFO, $this->ca_file);
            curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, 1);
            curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2);
        }
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
        curl_setopt($ch, CURLOPT_POSTFIELDS, implode("&",$post));

        $post = curl_exec($ch);
        $error = curl_error($ch);
        curl_close($ch);

        if ($post == FALSE) {
            $this->_log("IPN $id POST failed: '$error'");
            return FALSE;
        }

        if (!preg_match("/^VERIFIED\r?\n?$/",$post)) {
            $this->_log("Invalid IPN $id: '$post'");
            return FALSE;
        }
    
        return TRUE;
    }
    
    function _check_post_field($fields,$field,$value) {
        $id = $fields["txn_id"];

        if ($fields[$field] != $value) {
            $this->_log(sprintf("IPN $id: Invalid $field: '%s' != '%s'",
                $fields[$field],$value));
            return FALSE;
        }
        return TRUE;
    }
    
    function _validate_ipn($fields) {
        # Each of the sub-functions called do _log reporting, so we
        # don't need to in this function.

        # Check merchant info
        if (!$this->_check_post_field($fields,"receiver_email",
                                             $this->merchant_email)) {
            return FALSE;
        }
        # Check protocol version
        if (!$this->_check_post_field($fields,"notify_version",
                                             $this->ipn_protocol_version)) {
            return FALSE;
        }
    
        $fields["cmd"]="_notify-validate";

        return $this->_do_post($fields);
    }

    function _do_record($fields) {
        # test for "txn_id" field
        $id=$fields["txn_id"];
        if (!isset($id) || empty($id)) {
            $this->_log("Missing txn_id");
            return FALSE;
        }

        # Record the static fields to the database
        $columns=array();
        $values=array();
        foreach ($this->record_fields as $field) {
            if (isset($fields[$field])) {
                array_push($columns,$field);
                array_push($values,$this->db->quoteSmart($fields[$field]));
            }
        }
        $sql = sprintf("INSERT INTO %s (%s) VALUES (%s)",
                        $this->tablename_prefix."transactions",
                        implode(", ",$columns),
                        implode(", ",$values));
        $result =& $this->db->query($sql);
        if (DB::isError($result) ||
            $this->db->affectedRows() != 1) {
            $this->_log("IPN $id: 'transactions' failed: ".$result->getDebugInfo());
            return FALSE;
        }

        # For each "variable number of entries" fields, start with the table...
        foreach ($this->record_numbered_fields as $table => $record_fields) {
            # ...record each indexed set of fields, handling the lack of
            #    "1" being appended in the "single" entry case...
            for ($offset=1;
                 ($offset == 1 && isset($fields[$record_fields[0]])) ||
                 isset($fields[$record_fields[0].$offset]);
                 $offset++) {
                    # Build the list of fields in this entry
                    $columns=array();
                    $values=array();
                    foreach ($record_fields as $fieldname) {
                        $field=$fieldname.$offset;
                        if ($offset==1 && !isset($fields[$field])) {
                            $field=$fieldname;
                        }
                        # record by base fieldname
                        if (isset($fields[$field])) {
                            array_push($columns,
                                       $fieldname);
                            array_push($values,
                                       $this->db->quoteSmart($fields[$field]));
                        }
                    }
                    $sql = sprintf("INSERT INTO %s (txn_id, offset, %s) ".
                                   "VALUES (%s, %s, %s)",
                                   $this->tablename_prefix.$table,
                                   implode(", ",$columns),
                                   $this->db->quoteSmart($fields["txn_id"]),
                                   $this->db->quoteSmart($offset),
                                   implode(", ",$values));
                    $result =& $this->db->query($sql);
                    if (DB::isError($result) ||
                        $this->db->affectedRows() != 1) {
                        $this->_log("IPN $id: '$table' failed: ".$result->getDebugInfo());
                        return FALSE;
                    }
            }
        }

        return TRUE;
    }

    function _get_state($txn_id) {
        # Lookup txn in database
        $sql = sprintf("SELECT payment_status from %s WHERE txn_id=%s",
                        $this->tablename_prefix."transactions",
                        $this->db->quoteSmart($txn_id));
        $result =& $this->db->query($sql);
        if (DB::isError($result)) {
            $this->_log("IPN $txn_id: lookup failed: ".$result->getDebugInfo());
            return FALSE;
        }
        if ($result->numRows()==0) {
            /* no matched state */
            return FALSE;
        }
        if ($result->numRows()>1) {
            $this->_log("IPN $txn_id: found multiple txn_id!?");
            return FALSE;
        }
        $row =& $result->fetchRow();
        return $row[0];
    }

    # This is a quick function to dump the recommended table structure
    # for storing the transaction information.
    function _dump_table_layout() {

        printf("CREATE DATABASE /*!32312 IF NOT EXISTS*/ %s;\n",
               $this->dsn["database"]);
        printf("USE %s;\n",
               $this->dsn["database"]);

        # Main transaction history table
        $columns = array();
        foreach ($this->record_fields as $field) {
            /* defaults */
            $length = 127;
            $extra = "";
            if ($field == "txn_id" ||
                $field == "parent_txn_id") {
                    $length=17;
            }
            if ($field == "custom") {
                    $length=255;
            }
            /* extra info */
            if ($field == "txn_id") {
                    $extra.=" NOT NULL UNIQUE";
            }
            array_push($columns,sprintf("%s VARCHAR(%d)%s",
                        $field,
                        $length,$extra));
        }
        array_push($columns,"PRIMARY KEY (txn_id)");
        printf("CREATE TABLE %s\n\t(%s);\n\n",
                $this->tablename_prefix."transactions",
                implode(",\n\t",$columns));

        # dump multi-entry tables
        foreach ($this->record_numbered_fields as $table => $record_fields) {
            $columns = array("txn_id VARCHAR(17) NOT NULL",
                             "offset INT NOT NULL DEFAULT 1");
            foreach ($record_fields as $field) {
                /* defaults */
                $length=127;
                $extra="";
                array_push($columns,sprintf("%s VARCHAR(%d)%s",
                            $field,
                            $length,$extra));
            }
            array_push($columns,"KEY txn_id (txn_id)",
                                "KEY offset (offset)");
            printf("CREATE TABLE %s\n\t(%s);\n\n",
                    $this->tablename_prefix.$table,
                    implode(",\n\t",$columns));
        }

        # dump user commands table
        printf("GRANT SELECT,INSERT,UPDATE,DELETE ON %s.* to %s@%s identified by '%s';\n",
            $this->dsn["database"],
            $this->dsn["username"],
            $this->dsn["hostspec"],
            $this->dsn["password"]);
        printf("FLUSH PRIVILEGES;\n");
    }
}

/* vi:set ai ts=4 sw=4 expandtab: */ ?>
