1:   2:   3:   4:   5:   6:   7:   8:   9:  10:  11:  12:  13:  14:  15:  16:  17:  18:  19:  20:  21:  22:  23:  24:  25:  26:  27:  28:  29:  30:  31:  32:  33:  34:  35:  36:  37:  38:  39:  40:  41:  42:  43:  44:  45:  46:  47:  48:  49:  50:  51:  52:  53:  54:  55:  56:  57:  58:  59:  60:  61:  62:  63:  64:  65:  66:  67:  68:  69:  70:  71:  72:  73:  74:  75:  76:  77:  78:  79:  80:  81:  82:  83:  84:  85:  86:  87:  88:  89:  90:  91:  92:  93:  94:  95:  96:  97:  98:  99: 100: 101: 102: 103: 104: 105: 106: 107: 108: 109: 110: 111: 112: 113: 114: 115: 116: 117: 118: 119: 120: 121: 122: 123: 124: 125: 126: 127: 128: 129: 130: 131: 132: 133: 134: 135: 136: 137: 138: 139: 140: 141: 142: 143: 144: 145: 146: 147: 148: 149: 150: 151: 152: 153: 154: 155: 156: 157: 158: 159: 160: 161: 162: 163: 164: 165: 166: 167: 168: 169: 170: 171: 172: 173: 174: 175: 176: 177: 178: 179: 180: 181: 182: 183: 184: 185: 186: 187: 188: 189: 190: 191: 192: 193: 194: 195: 196: 197: 198: 199: 200: 201: 202: 203: 204: 205: 206: 207: 208: 209: 210: 211: 212: 213: 214: 215: 216: 217: 218: 219: 220: 221: 222: 223: 224: 225: 226: 227: 228: 229: 230: 231: 232: 233: 234: 235: 236: 237: 238: 239: 240: 241: 242: 243: 244: 245: 246: 247: 248: 249: 250: 251: 252: 253: 254: 255: 256: 257: 258: 259: 260: 261: 262: 263: 264: 265: 266: 267: 268: 269: 270: 271: 272: 273: 274: 275: 276: 277: 278: 279: 280: 281: 282: 283: 284: 285: 286: 287: 288: 289: 290: 291: 292: 293: 294: 295: 296: 297: 298: 299: 300: 301: 302: 303: 304: 305: 306: 307: 308: 309: 310: 311: 312: 313: 314: 315: 316: 317: 318: 319: 320: 321: 322: 323: 324: 325: 326: 327: 328: 329: 330: 331: 332: 333: 334: 335: 336: 337: 338: 339: 340: 341: 342: 343: 344: 345: 346: 347: 348: 349: 350: 351: 352: 353: 354: 355: 356: 357: 358: 359: 360: 361: 362: 363: 364: 365: 366: 367: 368: 369: 370: 371: 372: 373: 374: 375: 376: 377: 378: 379: 380: 381: 382: 383: 384: 385: 386: 387: 388: 389: 390: 391: 392: 393: 394: 395: 396: 397: 398: 399: 400: 401: 402: 403: 404: 405: 406: 407: 408: 409: 410: 411: 412: 413: 414: 415: 416: 417: 418: 419: 420: 421: 422: 423: 424: 425: 426: 427: 428: 429: 430: 431: 432: 433: 434: 435: 436: 437: 438: 439: 440: 441: 442: 443: 444: 445: 446: 447: 448: 449: 450: 451: 452: 453: 
<?php

/**
 * Simple Machines Forum (SMF)
 *
 * @package SMF
 * @author Simple Machines http://www.simplemachines.org
 * @copyright 2019 Simple Machines and individual contributors
 * @license http://www.simplemachines.org/about/smf/license.php BSD
 *
 * @version 2.1 RC1
 */

// This won't be dedicated without this - this must exist in each gateway!
// SMF Payment Gateway: paypal

if (!defined('SMF'))
    die('No direct access...');

/**
 * Class for returning available form data for this gateway
 */
class paypal_display
{
    /**
     * @var string Name of this payment gateway
     */
    public $title = 'PayPal';

    /**
     * Return the admin settings for this gateway
     *
     * @return array An array of settings data
     */
    public function getGatewaySettings()
    {
        global $txt;

        $setting_data = array(
            array(
                'email', 'paypal_email',
                'subtext' => $txt['paypal_email_desc'],
                'size' => 60
            ),
            array(
                'email', 'paypal_additional_emails',
                'subtext' => $txt['paypal_additional_emails_desc'],
                'size' => 60
            ),
            array(
                'email', 'paypal_sandbox_email',
                'subtext' => $txt['paypal_sandbox_email_desc'],
                'size' => 60
            ),
        );

        return $setting_data;
    }

    /**
     * Is this enabled for new payments?
     *
     * @return boolean Whether this gateway is enabled (for PayPal, whether the PayPal email is set)
     */
    public function gatewayEnabled()
    {
        global $modSettings;

        return !empty($modSettings['paypal_email']);
    }

    /**
     * What do we want?
     *
     * Called from Profile-Actions.php to return a unique set of fields for the given gateway
     * plus all the standard ones for the subscription form
     *
     * @param string $unique_id The unique ID of this gateway
     * @param array $sub_data Subscription data
     * @param int|float $value The amount of the subscription
     * @param string $period
     * @param string $return_url The URL to return the user to after processing the payment
     * @return array An array of data for the form
     */
    public function fetchGatewayFields($unique_id, $sub_data, $value, $period, $return_url)
    {
        global $modSettings, $txt, $boardurl;

        $return_data = array(
            'form' => 'https://www.' . (!empty($modSettings['paidsubs_test']) ? 'sandbox.' : '') . 'paypal.com/cgi-bin/webscr',
            'id' => 'paypal',
            'hidden' => array(),
            'title' => $txt['paypal'],
            'desc' => $txt['paid_confirm_paypal'],
            'submit' => $txt['paid_paypal_order'],
            'javascript' => '',
        );

        // All the standard bits.
        $return_data['hidden']['business'] = $modSettings['paypal_email'];
        $return_data['hidden']['item_name'] = $sub_data['name'] . ' ' . $txt['subscription'];
        $return_data['hidden']['item_number'] = $unique_id;
        $return_data['hidden']['currency_code'] = strtoupper($modSettings['paid_currency_code']);
        $return_data['hidden']['no_shipping'] = 1;
        $return_data['hidden']['no_note'] = 1;
        $return_data['hidden']['amount'] = $value;
        $return_data['hidden']['cmd'] = !$sub_data['repeatable'] ? '_xclick' : '_xclick-subscriptions';
        $return_data['hidden']['return'] = $return_url;
        $return_data['hidden']['a3'] = $value;
        $return_data['hidden']['src'] = 1;
        $return_data['hidden']['notify_url'] = $boardurl . '/subscriptions.php';

        // If possible let's use the language we know we need.
        $return_data['hidden']['lc'] = !empty($txt['lang_paypal']) ? $txt['lang_paypal'] : 'US';

        // Now stuff dependant on what we're doing.
        if ($sub_data['flexible'])
        {
            $return_data['hidden']['p3'] = 1;
            $return_data['hidden']['t3'] = strtoupper(substr($period, 0, 1));
        }
        else
        {
            preg_match('~(\d*)(\w)~', $sub_data['real_length'], $match);
            $unit = $match[1];
            $period = $match[2];

            $return_data['hidden']['p3'] = $unit;
            $return_data['hidden']['t3'] = $period;
        }

        // If it's repeatable do some javascript to respect this idea.
        if (!empty($sub_data['repeatable']))
            $return_data['javascript'] = '
                document.write(\'<label for="do_paypal_recur"><input type="checkbox" name="do_paypal_recur" id="do_paypal_recur" checked onclick="switchPaypalRecur();">' . $txt['paid_make_recurring'] . '</label><br>\');

                function switchPaypalRecur()
                {
                    document.getElementById("paypal_cmd").value = document.getElementById("do_paypal_recur").checked ? "_xclick-subscriptions" : "_xclick";
                }';

        return $return_data;
    }
}

/**
 * Class of functions to validate a IPN response and provide details of the payment
 */
class paypal_payment
{
    private $return_data;

    /**
     * This function returns true/false for whether this gateway thinks the data is intended for it.
     *
     * @return boolean Whether this gateway things the data is valid
     */
    public function isValid()
    {
        global $modSettings;

        // Has the user set up an email address?
        if ((empty($modSettings['paidsubs_test']) && empty($modSettings['paypal_email'])) || (!empty($modSettings['paidsubs_test']) && empty($modSettings['paypal_sandbox_email'])))
            return false;
        // Check the correct transaction types are even here.
        if ((!isset($_POST['txn_type']) && !isset($_POST['payment_status'])) || (!isset($_POST['business']) && !isset($_POST['receiver_email'])))
            return false;
        // Correct email address?
        if (!isset($_POST['business']))
            $_POST['business'] = $_POST['receiver_email'];

        // Are we testing?
        if (empty($modSettings['paidsubs_test']) && strtolower($modSettings['paypal_sandbox_email']) != strtolower($_POST['business']) && (empty($modSettings['paypal_additional_emails']) || !in_array(strtolower($_POST['business']), explode(',', strtolower($modSettings['paypal_additional_emails'])))))
            return false;
        elseif (strtolower($modSettings['paypal_email']) != strtolower($_POST['business']) && (empty($modSettings['paypal_additional_emails']) || !in_array(strtolower($_POST['business']), explode(',', $modSettings['paypal_additional_emails']))))
            return false;
        return true;
    }

    /**
     * Post the IPN data received back to paypal for validation
     * Sends the complete unaltered message back to PayPal. The message must contain the same fields
     * in the same order and be encoded in the same way as the original message
     * PayPal will respond back with a single word, which is either VERIFIED if the message originated with PayPal or INVALID
     *
     * If valid returns the subscription and member IDs we are going to process if it passes
     *
     * @return string A string containing the subscription ID and member ID, separated by a +
     */
    public function precheck()
    {
        global $modSettings, $txt;

        // Put this to some default value.
        if (!isset($_POST['txn_type']))
            $_POST['txn_type'] = '';

        // Build the request string - starting with the minimum requirement.
        $requestString = 'cmd=_notify-validate';

        // Now my dear, add all the posted bits in the order we got them
        foreach ($_POST as $k => $v)
            $requestString .= '&' . $k . '=' . urlencode($v);

        // Can we use curl?
        if (function_exists('curl_init') && $curl = curl_init((!empty($modSettings['paidsubs_test']) ? 'https://www.sandbox.' : 'https://www.') . 'paypal.com/cgi-bin/webscr'))
        {
            // Set the post data.
            curl_setopt($curl, CURLOPT_POST, true);
            curl_setopt($curl, CURLOPT_POSTFIELDSIZE, 0);
            curl_setopt($curl, CURLOPT_POSTFIELDS, $requestString);

            // Set up the headers so paypal will accept the post
            curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
            curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 1);
            curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);
            curl_setopt($curl, CURLOPT_FORBID_REUSE, 1);
            curl_setopt($curl, CURLOPT_HTTPHEADER, array(
                'Host: www.' . (!empty($modSettings['paidsubs_test']) ? 'sandbox.' : '') . 'paypal.com',
                'Connection: close'
            ));

            // Fetch the data returned as a string.
            curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);

            // Fetch the data.
            $this->return_data = curl_exec($curl);

            // Close the session.
            curl_close($curl);
        }
        // Otherwise good old HTTP.
        else
        {
            // Setup the headers.
            $header = 'POST /cgi-bin/webscr HTTP/1.1' . "\r\n";
            $header .= 'content-type: application/x-www-form-urlencoded' . "\r\n";
            $header .= 'Host: www.' . (!empty($modSettings['paidsubs_test']) ? 'sandbox.' : '') . 'paypal.com' . "\r\n";
            $header .= 'content-length: ' . strlen($requestString) . "\r\n";
            $header .= 'connection: close' . "\r\n\r\n";

            // Open the connection.
            if (!empty($modSettings['paidsubs_test']))
                $fp = fsockopen('ssl://www.sandbox.paypal.com', 443, $errno, $errstr, 30);
            else
                $fp = fsockopen('www.paypal.com', 80, $errno, $errstr, 30);

            // Did it work?
            if (!$fp)
                generateSubscriptionError($txt['paypal_could_not_connect']);

            // Put the data to the port.
            fputs($fp, $header . $requestString);

            // Get the data back...
            while (!feof($fp))
            {
                $this->return_data = fgets($fp, 1024);
                if (strcmp(trim($this->return_data), 'VERIFIED') === 0)
                    break;
            }

            // Clean up.
            fclose($fp);
        }

        // If this isn't verified then give up...
        if (strcmp(trim($this->return_data), 'VERIFIED') !== 0)
            exit;

        // Check that this is intended for us.
        if (strtolower($modSettings['paypal_email']) != strtolower($_POST['business']) && (empty($modSettings['paypal_additional_emails']) || !in_array(strtolower($_POST['business']), explode(',', strtolower($modSettings['paypal_additional_emails'])))))
            exit;

        // Is this a subscription - and if so is it a secondary payment that we need to process?
        // If so, make sure we get it in the expected format. Seems PayPal sometimes sends it without urlencoding.
        if (!empty($_POST['item_number']) && strpos($_POST['item_number'], ' ') !== false)
            $_POST['item_number'] = str_replace(' ', '+', $_POST['item_number']);
        if ($this->isSubscription() && (empty($_POST['item_number']) || strpos($_POST['item_number'], '+') === false))
            // Calculate the subscription it relates to!
            $this->_findSubscription();

        // Verify the currency!
        if (strtolower($_POST['mc_currency']) !== strtolower($modSettings['paid_currency_code']))
            exit;

        // Can't exist if it doesn't contain anything.
        if (empty($_POST['item_number']))
            exit;

        // Return the id_sub and id_member
        return explode('+', $_POST['item_number']);
    }

    /**
     * Is this a refund?
     *
     * @return boolean Whether this is a refund
     */
    public function isRefund()
    {
        if ($_POST['payment_status'] === 'Refunded' || $_POST['payment_status'] === 'Reversed' || $_POST['txn_type'] === 'Refunded' || ($_POST['txn_type'] === 'reversal' && $_POST['payment_status'] === 'Completed'))
            return true;
        else
            return false;
    }

    /**
     * Is this a subscription?
     *
     * @return boolean Whether this is a subscription
     */
    public function isSubscription()
    {
        if (substr($_POST['txn_type'], 0, 14) === 'subscr_payment' && $_POST['payment_status'] === 'Completed')
            return true;
        else
            return false;
    }

    /**
     * Is this a normal payment?
     *
     * @return boolean Whether this is a normal payment
     */
    public function isPayment()
    {
        if ($_POST['payment_status'] === 'Completed' && $_POST['txn_type'] === 'web_accept')
            return true;
        else
            return false;
    }

    /**
     * Is this a cancellation?
     *
     * @return boolean Whether this is a cancellation
     */
    public function isCancellation()
    {
        // subscr_cancel is sent when the user cancels, subscr_eot is sent when the subscription reaches final payment
        // Neither require us to *do* anything as per performCancel().
        // subscr_eot, if sent, indicates an end of payments term.
        if (substr($_POST['txn_type'], 0, 13) === 'subscr_cancel' || substr($_POST['txn_type'], 0, 10) === 'subscr_eot')
            return true;
        else
            return false;
    }

    /**
     * Things to do in the event of a cancellation
     *
     * @param string $subscription_id
     * @param int $member_id
     * @param array $subscription_info
     */
    public function performCancel($subscription_id, $member_id, $subscription_info)
    {
        // PayPal doesn't require SMF to notify it every time the subscription is up for renewal.
        // A cancellation should not cause the user to be immediately dropped from their subscription, but
        // let it expire normally. Some systems require taking action in the database to deal with this, but
        // PayPal does not, so we actually just do nothing. But this is a nice prototype/example just in case.
    }

    /**
     * How much was paid?
     *
     * @return float The amount paid
     */
    public function getCost()
    {
        return (isset($_POST['tax']) ? $_POST['tax'] : 0) + $_POST['mc_gross'];
    }

    /**
     * Record the transaction reference and exit
     *
     */
    public function close()
    {
        global $smcFunc, $subscription_id;

        // If it's a subscription record the reference.
        if ($_POST['txn_type'] == 'subscr_payment' && !empty($_POST['subscr_id']))
        {
            $smcFunc['db_query']('', '
                UPDATE {db_prefix}log_subscribed
                SET vendor_ref = {string:vendor_ref}
                WHERE id_sublog = {int:current_subscription}',
                array(
                    'current_subscription' => $subscription_id,
                    'vendor_ref' => $_POST['subscr_id'],
                )
            );
        }

        exit();
    }

    /**
     * A private function to find out the subscription details.
     *
     * @access private
     * @return boolean|void False on failure, otherwise just sets $_POST['item_number']
     */
    private function _findSubscription()
    {
        global $smcFunc;

        // Assume we have this?
        if (empty($_POST['subscr_id']))
            return false;

        // Do we have this in the database?
        $request = $smcFunc['db_query']('', '
            SELECT id_member, id_subscribe
            FROM {db_prefix}log_subscribed
            WHERE vendor_ref = {string:vendor_ref}
            LIMIT 1',
            array(
                'vendor_ref' => $_POST['subscr_id'],
            )
        );
        // No joy?
        if ($smcFunc['db_num_rows']($request) == 0)
        {
            // Can we identify them by email?
            if (!empty($_POST['payer_email']))
            {
                $smcFunc['db_free_result']($request);
                $request = $smcFunc['db_query']('', '
                    SELECT ls.id_member, ls.id_subscribe
                    FROM {db_prefix}log_subscribed AS ls
                        INNER JOIN {db_prefix}members AS mem ON (mem.id_member = ls.id_member)
                    WHERE mem.email_address = {string:payer_email}
                    LIMIT 1',
                    array(
                        'payer_email' => $_POST['payer_email'],
                    )
                );
                if ($smcFunc['db_num_rows']($request) === 0)
                    return false;
            }
            else
                return false;
        }
        list ($member_id, $subscription_id) = $smcFunc['db_fetch_row']($request);
        $_POST['item_number'] = $member_id . '+' . $subscription_id;
        $smcFunc['db_free_result']($request);
    }
}

?>