paypal_payment   F
last analyzed

Complexity

Total Complexity 66

Size/Duplication

Total Lines 302
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 107
dl 0
loc 302
rs 3.12
c 0
b 0
f 0
wmc 66

10 Methods

Rating   Name   Duplication   Size   Complexity  
A isRefund() 0 6 6
A performCancel() 0 2 1
C isValid() 0 20 17
A isSubscription() 0 6 3
A _findSubscription() 0 44 5
A isCancellation() 0 9 3
A isPayment() 0 6 3
A getCost() 0 3 2
F precheck() 0 103 23
A close() 0 14 3

How to fix   Complexity   

Complex Class

Complex classes like paypal_payment often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use paypal_payment, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
/**
4
 * Simple Machines Forum (SMF)
5
 *
6
 * @package SMF
7
 * @author Simple Machines https://www.simplemachines.org
8
 * @copyright 2022 Simple Machines and individual contributors
9
 * @license https://www.simplemachines.org/about/smf/license.php BSD
10
 *
11
 * @version 2.1.0
12
 */
13
14
// This won't be dedicated without this - this must exist in each gateway!
15
// SMF Payment Gateway: paypal
16
17
if (!defined('SMF'))
18
	die('No direct access...');
19
20
/**
21
 * Class for returning available form data for this gateway
22
 */
23
class paypal_display
24
{
25
	/**
26
	 * @var string Name of this payment gateway
27
	 */
28
	public $title = 'PayPal';
29
30
	/**
31
	 * Return the admin settings for this gateway
32
	 *
33
	 * @return array An array of settings data
34
	 */
35
	public function getGatewaySettings()
36
	{
37
		global $txt;
38
39
		$setting_data = array(
40
			array(
41
				'email', 'paypal_email',
42
				'subtext' => $txt['paypal_email_desc'],
43
				'size' => 60
44
			),
45
			array(
46
				'email', 'paypal_additional_emails',
47
				'subtext' => $txt['paypal_additional_emails_desc'],
48
				'size' => 60
49
			),
50
			array(
51
				'email', 'paypal_sandbox_email',
52
				'subtext' => $txt['paypal_sandbox_email_desc'],
53
				'size' => 60
54
			),
55
		);
56
57
		return $setting_data;
58
	}
59
60
	/**
61
	 * Is this enabled for new payments?
62
	 *
63
	 * @return boolean Whether this gateway is enabled (for PayPal, whether the PayPal email is set)
64
	 */
65
	public function gatewayEnabled()
66
	{
67
		global $modSettings;
68
69
		return !empty($modSettings['paypal_email']);
70
	}
71
72
	/**
73
	 * What do we want?
74
	 *
75
	 * Called from Profile-Actions.php to return a unique set of fields for the given gateway
76
	 * plus all the standard ones for the subscription form
77
	 *
78
	 * @param string $unique_id The unique ID of this gateway
79
	 * @param array $sub_data Subscription data
80
	 * @param int|float $value The amount of the subscription
81
	 * @param string $period
82
	 * @param string $return_url The URL to return the user to after processing the payment
83
	 * @return array An array of data for the form
84
	 */
85
	public function fetchGatewayFields($unique_id, $sub_data, $value, $period, $return_url)
86
	{
87
		global $modSettings, $txt, $boardurl;
88
89
		$return_data = array(
90
			'form' => 'https://www.' . (!empty($modSettings['paidsubs_test']) ? 'sandbox.' : '') . 'paypal.com/cgi-bin/webscr',
91
			'id' => 'paypal',
92
			'hidden' => array(),
93
			'title' => $txt['paypal'],
94
			'desc' => $txt['paid_confirm_paypal'],
95
			'submit' => $txt['paid_paypal_order'],
96
			'javascript' => '',
97
		);
98
99
		// All the standard bits.
100
		$return_data['hidden']['business'] = $modSettings['paypal_email'];
101
		$return_data['hidden']['item_name'] = $sub_data['name'] . ' ' . $txt['subscription'];
102
		$return_data['hidden']['item_number'] = $unique_id;
103
		$return_data['hidden']['currency_code'] = strtoupper($modSettings['paid_currency_code']);
104
		$return_data['hidden']['no_shipping'] = 1;
105
		$return_data['hidden']['no_note'] = 1;
106
		$return_data['hidden']['amount'] = $value;
107
		$return_data['hidden']['cmd'] = !$sub_data['repeatable'] ? '_xclick' : '_xclick-subscriptions';
108
		$return_data['hidden']['return'] = $return_url;
109
		$return_data['hidden']['a3'] = $value;
110
		$return_data['hidden']['src'] = 1;
111
		$return_data['hidden']['notify_url'] = $boardurl . '/subscriptions.php';
112
113
		// If possible let's use the language we know we need.
114
		$return_data['hidden']['lc'] = !empty($txt['lang_paypal']) ? $txt['lang_paypal'] : 'US';
115
116
		// Now stuff dependant on what we're doing.
117
		if ($sub_data['flexible'])
118
		{
119
			$return_data['hidden']['p3'] = 1;
120
			$return_data['hidden']['t3'] = strtoupper(substr($period, 0, 1));
121
		}
122
		else
123
		{
124
			preg_match('~(\d*)(\w)~', $sub_data['real_length'], $match);
125
			$unit = $match[1];
126
			$period = $match[2];
127
128
			$return_data['hidden']['p3'] = $unit;
129
			$return_data['hidden']['t3'] = $period;
130
		}
131
132
		// If it's repeatable do some javascript to respect this idea.
133
		if (!empty($sub_data['repeatable']))
134
			$return_data['javascript'] = '
135
				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>\');
136
137
				function switchPaypalRecur()
138
				{
139
					document.getElementById("paypal_cmd").value = document.getElementById("do_paypal_recur").checked ? "_xclick-subscriptions" : "_xclick";
140
				}';
141
142
		return $return_data;
143
	}
144
}
145
146
/**
147
 * Class of functions to validate a IPN response and provide details of the payment
148
 */
149
class paypal_payment
150
{
151
	/**
152
	 * @var string $return_data The data to return
153
	 */
154
	private $return_data;
155
156
	/**
157
	 * This function returns true/false for whether this gateway thinks the data is intended for it.
158
	 *
159
	 * @return boolean Whether this gateway things the data is valid
160
	 */
161
	public function isValid()
162
	{
163
		global $modSettings;
164
165
		// Has the user set up an email address?
166
		if ((empty($modSettings['paidsubs_test']) && empty($modSettings['paypal_email'])) || (!empty($modSettings['paidsubs_test']) && empty($modSettings['paypal_sandbox_email'])))
167
			return false;
168
		// Check the correct transaction types are even here.
169
		if ((!isset($_POST['txn_type']) && !isset($_POST['payment_status'])) || (!isset($_POST['business']) && !isset($_POST['receiver_email'])))
170
			return false;
171
		// Correct email address?
172
		if (!isset($_POST['business']))
173
			$_POST['business'] = $_POST['receiver_email'];
174
175
		// Are we testing?
176
		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'])))))
177
			return false;
178
		elseif (strtolower($modSettings['paypal_email']) != strtolower($_POST['business']) && (empty($modSettings['paypal_additional_emails']) || !in_array(strtolower($_POST['business']), explode(',', $modSettings['paypal_additional_emails']))))
179
			return false;
180
		return true;
181
	}
182
183
	/**
184
	 * Post the IPN data received back to paypal for validation
185
	 * Sends the complete unaltered message back to PayPal. The message must contain the same fields
186
	 * in the same order and be encoded in the same way as the original message
187
	 * PayPal will respond back with a single word, which is either VERIFIED if the message originated with PayPal or INVALID
188
	 *
189
	 * If valid returns the subscription and member IDs we are going to process if it passes
190
	 *
191
	 * @return string A string containing the subscription ID and member ID, separated by a +
192
	 */
193
	public function precheck()
194
	{
195
		global $modSettings, $txt;
196
197
		// Put this to some default value.
198
		if (!isset($_POST['txn_type']))
199
			$_POST['txn_type'] = '';
200
201
		// Build the request string - starting with the minimum requirement.
202
		$requestString = 'cmd=_notify-validate';
203
204
		// Now my dear, add all the posted bits in the order we got them
205
		foreach ($_POST as $k => $v)
206
			$requestString .= '&' . $k . '=' . urlencode($v);
207
208
		// Can we use curl?
209
		if (function_exists('curl_init') && $curl = curl_init((!empty($modSettings['paidsubs_test']) ? 'https://www.sandbox.' : 'https://www.') . 'paypal.com/cgi-bin/webscr'))
210
		{
211
			// Set the post data.
212
			curl_setopt($curl, CURLOPT_POST, true);
213
			curl_setopt($curl, CURLOPT_POSTFIELDSIZE, 0);
0 ignored issues
show
Bug introduced by
The constant CURLOPT_POSTFIELDSIZE was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
214
			curl_setopt($curl, CURLOPT_POSTFIELDS, $requestString);
215
216
			// Set up the headers so paypal will accept the post
217
			curl_setopt($curl, CURLOPT_HTTP_VERSION, CURL_HTTP_VERSION_1_1);
218
			curl_setopt($curl, CURLOPT_SSL_VERIFYPEER, 1);
219
			curl_setopt($curl, CURLOPT_SSL_VERIFYHOST, 2);
220
			curl_setopt($curl, CURLOPT_FORBID_REUSE, 1);
221
			curl_setopt($curl, CURLOPT_HTTPHEADER, array(
222
				'Host: www.' . (!empty($modSettings['paidsubs_test']) ? 'sandbox.' : '') . 'paypal.com',
223
				'Connection: close'
224
			));
225
226
			// Fetch the data returned as a string.
227
			curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
228
229
			// Fetch the data.
230
			$this->return_data = curl_exec($curl);
0 ignored issues
show
Documentation Bug introduced by
It seems like curl_exec($curl) can also be of type true. However, the property $return_data is declared as type string. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
231
232
			// Close the session.
233
			curl_close($curl);
234
		}
235
		// Otherwise good old HTTP.
236
		else
237
		{
238
			// Setup the headers.
239
			$header = 'POST /cgi-bin/webscr HTTP/1.1' . "\r\n";
240
			$header .= 'content-type: application/x-www-form-urlencoded' . "\r\n";
241
			$header .= 'Host: www.' . (!empty($modSettings['paidsubs_test']) ? 'sandbox.' : '') . 'paypal.com' . "\r\n";
242
			$header .= 'content-length: ' . strlen($requestString) . "\r\n";
243
			$header .= 'connection: close' . "\r\n\r\n";
244
245
			// Open the connection.
246
			if (!empty($modSettings['paidsubs_test']))
247
				$fp = fsockopen('ssl://www.sandbox.paypal.com', 443, $errno, $errstr, 30);
248
			else
249
				$fp = fsockopen('www.paypal.com', 80, $errno, $errstr, 30);
250
251
			// Did it work?
252
			if (!$fp)
0 ignored issues
show
introduced by
$fp is of type resource, thus it always evaluated to false.
Loading history...
253
				generateSubscriptionError($txt['paypal_could_not_connect']);
254
255
			// Put the data to the port.
256
			fputs($fp, $header . $requestString);
257
258
			// Get the data back...
259
			while (!feof($fp))
260
			{
261
				$this->return_data = fgets($fp, 1024);
262
				if (strcmp(trim($this->return_data), 'VERIFIED') === 0)
263
					break;
264
			}
265
266
			// Clean up.
267
			fclose($fp);
268
		}
269
270
		// If this isn't verified then give up...
271
		if (strcmp(trim($this->return_data), 'VERIFIED') !== 0)
0 ignored issues
show
Bug introduced by
It seems like $this->return_data can also be of type true; however, parameter $string of trim() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

271
		if (strcmp(trim(/** @scrutinizer ignore-type */ $this->return_data), 'VERIFIED') !== 0)
Loading history...
272
			exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
273
274
		// Check that this is intended for us.
275
		if (strtolower($modSettings['paypal_email']) != strtolower($_POST['business']) && (empty($modSettings['paypal_additional_emails']) || !in_array(strtolower($_POST['business']), explode(',', strtolower($modSettings['paypal_additional_emails'])))))
276
			exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
277
278
		// Is this a subscription - and if so is it a secondary payment that we need to process?
279
		// If so, make sure we get it in the expected format. Seems PayPal sometimes sends it without urlencoding.
280
		if (!empty($_POST['item_number']) && strpos($_POST['item_number'], ' ') !== false)
281
			$_POST['item_number'] = str_replace(' ', '+', $_POST['item_number']);
282
		if ($this->isSubscription() && (empty($_POST['item_number']) || strpos($_POST['item_number'], '+') === false))
283
			// Calculate the subscription it relates to!
284
			$this->_findSubscription();
285
286
		// Verify the currency!
287
		if (strtolower($_POST['mc_currency']) !== strtolower($modSettings['paid_currency_code']))
288
			exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
289
290
		// Can't exist if it doesn't contain anything.
291
		if (empty($_POST['item_number']))
292
			exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
293
294
		// Return the id_sub and id_member
295
		return explode('+', $_POST['item_number']);
296
	}
297
298
	/**
299
	 * Is this a refund?
300
	 *
301
	 * @return boolean Whether this is a refund
302
	 */
303
	public function isRefund()
304
	{
305
		if ($_POST['payment_status'] === 'Refunded' || $_POST['payment_status'] === 'Reversed' || $_POST['txn_type'] === 'Refunded' || ($_POST['txn_type'] === 'reversal' && $_POST['payment_status'] === 'Completed'))
306
			return true;
307
		else
308
			return false;
309
	}
310
311
	/**
312
	 * Is this a subscription?
313
	 *
314
	 * @return boolean Whether this is a subscription
315
	 */
316
	public function isSubscription()
317
	{
318
		if (substr($_POST['txn_type'], 0, 14) === 'subscr_payment' && $_POST['payment_status'] === 'Completed')
319
			return true;
320
		else
321
			return false;
322
	}
323
324
	/**
325
	 * Is this a normal payment?
326
	 *
327
	 * @return boolean Whether this is a normal payment
328
	 */
329
	public function isPayment()
330
	{
331
		if ($_POST['payment_status'] === 'Completed' && $_POST['txn_type'] === 'web_accept')
332
			return true;
333
		else
334
			return false;
335
	}
336
337
	/**
338
	 * Is this a cancellation?
339
	 *
340
	 * @return boolean Whether this is a cancellation
341
	 */
342
	public function isCancellation()
343
	{
344
		// subscr_cancel is sent when the user cancels, subscr_eot is sent when the subscription reaches final payment
345
		// Neither require us to *do* anything as per performCancel().
346
		// subscr_eot, if sent, indicates an end of payments term.
347
		if (substr($_POST['txn_type'], 0, 13) === 'subscr_cancel' || substr($_POST['txn_type'], 0, 10) === 'subscr_eot')
348
			return true;
349
		else
350
			return false;
351
	}
352
353
	/**
354
	 * Things to do in the event of a cancellation
355
	 *
356
	 * @param string $subscription_id
357
	 * @param int $member_id
358
	 * @param array $subscription_info
359
	 */
360
	public function performCancel($subscription_id, $member_id, $subscription_info)
361
	{
362
		// PayPal doesn't require SMF to notify it every time the subscription is up for renewal.
363
		// A cancellation should not cause the user to be immediately dropped from their subscription, but
364
		// let it expire normally. Some systems require taking action in the database to deal with this, but
365
		// PayPal does not, so we actually just do nothing. But this is a nice prototype/example just in case.
366
	}
367
368
	/**
369
	 * How much was paid?
370
	 *
371
	 * @return float The amount paid
372
	 */
373
	public function getCost()
374
	{
375
		return (isset($_POST['tax']) ? $_POST['tax'] : 0) + $_POST['mc_gross'];
376
	}
377
378
	/**
379
	 * Record the transaction reference to finish up.
380
	 *
381
	 */
382
	public function close()
383
	{
384
		global $smcFunc, $subscription_id;
385
386
		// If it's a subscription record the reference.
387
		if ($_POST['txn_type'] == 'subscr_payment' && !empty($_POST['subscr_id']))
388
		{
389
			$smcFunc['db_query']('', '
390
				UPDATE {db_prefix}log_subscribed
391
				SET vendor_ref = {string:vendor_ref}
392
				WHERE id_sublog = {int:current_subscription}',
393
				array(
394
					'current_subscription' => $subscription_id,
395
					'vendor_ref' => $_POST['subscr_id'],
396
				)
397
			);
398
		}
399
	}
400
401
	/**
402
	 * A private function to find out the subscription details.
403
	 *
404
	 * @access private
405
	 * @return boolean|void False on failure, otherwise just sets $_POST['item_number']
406
	 */
407
	private function _findSubscription()
408
	{
409
		global $smcFunc;
410
411
		// Assume we have this?
412
		if (empty($_POST['subscr_id']))
413
			return false;
414
415
		// Do we have this in the database?
416
		$request = $smcFunc['db_query']('', '
417
			SELECT id_member, id_subscribe
418
			FROM {db_prefix}log_subscribed
419
			WHERE vendor_ref = {string:vendor_ref}
420
			LIMIT 1',
421
			array(
422
				'vendor_ref' => $_POST['subscr_id'],
423
			)
424
		);
425
		// No joy?
426
		if ($smcFunc['db_num_rows']($request) == 0)
427
		{
428
			// Can we identify them by email?
429
			if (!empty($_POST['payer_email']))
430
			{
431
				$smcFunc['db_free_result']($request);
432
				$request = $smcFunc['db_query']('', '
433
					SELECT ls.id_member, ls.id_subscribe
434
					FROM {db_prefix}log_subscribed AS ls
435
						INNER JOIN {db_prefix}members AS mem ON (mem.id_member = ls.id_member)
436
					WHERE mem.email_address = {string:payer_email}
437
					LIMIT 1',
438
					array(
439
						'payer_email' => $_POST['payer_email'],
440
					)
441
				);
442
				if ($smcFunc['db_num_rows']($request) === 0)
443
					return false;
444
			}
445
			else
446
				return false;
447
		}
448
		list ($member_id, $subscription_id) = $smcFunc['db_fetch_row']($request);
449
		$_POST['item_number'] = $member_id . '+' . $subscription_id;
450
		$smcFunc['db_free_result']($request);
451
	}
452
}
453
454
?>