PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260814
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260814
260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 All 189 releases
s2member / src / includes / classes / paypal-utilities.inc.php

paypal-utilities.inc.php in s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions 260814, at src/includes/classes/paypal-utilities.inc.php

2,152 lines 89.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreFile
3 /**
4 * PayPal utilities.
5 *
6 * Copyright: © 2009-2011
7 * {@link http://websharks-inc.com/ WebSharks, Inc.}
8 * (coded in the USA)
9 *
10 * Released under the terms of the GNU General Public License.
11 * You should have received a copy of the GNU General Public License,
12 * along with this software. In the main directory, see: /licensing/
13 * If not, see: {@link http://www.gnu.org/licenses/}.
14 *
15 * @package s2Member\PayPal
16 * @since 3.5
17 */
18 if(!defined('WPINC')) // MUST have WordPress.
19 exit("Do not access this file directly.");
20
21 if(!class_exists("c_ws_plugin__s2member_paypal_utilities"))
22 {
23 /**
24 * PayPal utilities.
25 *
26 * @package s2Member\PayPal
27 * @since 3.5
28 */
29 class c_ws_plugin__s2member_paypal_utilities
30 {
31 /**
32 * Get ``$_POST`` or ``$_REQUEST`` vars from PayPal.
33 *
34 * @package s2Member\PayPal
35 * @since 3.5
36 *
37 * @return array|bool An array of verified ``$_POST`` or ``$_REQUEST`` variables, else false.
38 */
39 public static function paypal_postvars()
40 {
41 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
42 do_action("ws_plugin__s2member_before_paypal_postvars", get_defined_vars());
43 unset($__refs, $__v); // Housekeeping.
44 /*
45 * Custom conditionals can be applied by filters.
46 */
47 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v; // Vars by reference.
48 if(!($postvars = apply_filters("ws_plugin__s2member_during_paypal_postvars_conditionals", array(), get_defined_vars())))
49 {
50 unset($__refs, $__v); // Housekeeping.
51
52 if(!empty($_GET["tx"]) && empty($_GET["s2member_paypal_proxy"]))
53 {
54 $postback["tx"] = $_GET["tx"];
55 $postback["cmd"] = "_notify-synch";
56 $postback["at"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_identity_token"];
57
58 $endpoint = ($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "www.sandbox.paypal.com" : "www.paypal.com";
59
60 if(preg_match("/^SUCCESS/i", ($response = trim(c_ws_plugin__s2member_utils_urls::remote("https://".$endpoint."/cgi-bin/webscr", $postback, array("timeout" => 20))))))
61 {
62 foreach(preg_split("/[\r\n]+/", preg_replace("/^SUCCESS/i", "", $response)) as $varline)
63 {
64 if (!empty($varline)) {
65 list($key, $value) = preg_split("/\=/", $varline, 2);
66 if (strlen($key = trim($key)) && strlen($value = trim($value)))
67 $postvars[$key] = trim(stripslashes(urldecode($value)));
68 }
69 }
70 $postvars = self::paypal_postvars_back_compat($postvars); // From verified data.
71
72 $postvars = self::paypal_postvars_utf8($postvars);
73 return apply_filters("ws_plugin__s2member_paypal_postvars", $postvars, get_defined_vars());
74 }
75 else return false;
76 }
77 else if(!empty($_REQUEST) && is_array($postvars = stripslashes_deep($_REQUEST)))
78 {
79 foreach($postvars as $key => $value)
80 if(preg_match("/^s2member_/", $key))
81 unset($postvars[$key]);
82
83 $postback = $postvars; // Copy.
84 $postback["cmd"] = "_notify-validate";
85
86 $postvars = self::paypal_postvars_back_compat($postvars);
87 $postvars = c_ws_plugin__s2member_utils_strings::trim_deep($postvars);
88
89 $postvars = self::paypal_postvars_utf8($postvars);
90 $endpoint = ($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "www.sandbox.paypal.com" : "www.paypal.com";
91
92 if(!empty($_REQUEST["s2member_paypal_proxy"]) && !empty($_REQUEST["s2member_paypal_proxy_verification"]) && $_REQUEST["s2member_paypal_proxy_verification"] === c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen())
93 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_REQUEST["s2member_paypal_proxy"])), get_defined_vars());
94
95 else if(empty($_POST) && !empty($_GET["s2member_paypal_proxy"]) && !empty($_GET["s2member_paypal_proxy_verification"]) && c_ws_plugin__s2member_utils_urls::s2member_sig_ok($_SERVER["REQUEST_URI"], false, false, "s2member_paypal_proxy_verification"))
96 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_GET["s2member_paypal_proxy"])), get_defined_vars());
97
98 else if(trim(strtolower(c_ws_plugin__s2member_utils_urls::remote("https://".$endpoint."/cgi-bin/webscr", $postback, array("timeout" => 20)))) === "verified")
99 return apply_filters("ws_plugin__s2member_paypal_postvars", $postvars, get_defined_vars());
100
101 else return false;
102 }
103 else return false;
104 }
105 else // Else a custom conditional has been applied by Filters.
106 {
107 unset($__refs, $__v); // Housekeeping.
108 $postvars = self::paypal_postvars_back_compat($postvars);
109 return apply_filters("ws_plugin__s2member_paypal_postvars", $postvars, get_defined_vars());
110 }
111 }
112 /**
113 * Convert PayPal post vars to UTF-8 when PayPal reports a usable charset.
114 *
115 * @since 260603
116 *
117 * @return array PayPal post vars.
118 */
119 public static function paypal_postvars_utf8($postvars)
120 {
121 $postvars = (array) $postvars;
122
123 if(empty($postvars["charset"]))
124 return $postvars;
125
126 $charset = trim((string) $postvars["charset"]);
127 $charset = (strtolower($charset) === "gb2312") ? "GBK" : $charset;
128
129 foreach($postvars as &$value)
130 if(is_string($value))
131 {
132 $converted = false;
133
134 if(function_exists("mb_convert_encoding"))
135 {
136 try
137 {
138 $converted = @mb_convert_encoding($value, "UTF-8", $charset);
139 }
140 catch(ValueError $exception)
141 {
142 }
143 }
144
145 if($converted === false && function_exists("iconv"))
146 $converted = @iconv($charset, "UTF-8//IGNORE", $value);
147
148 if($converted !== false)
149 $value = $converted;
150 }
151 unset($value);
152
153 return $postvars;
154 }
155 /**
156 * Back compat. PayPal post vars.
157 *
158 * @since 170722 PayPal IPN variable change.
159 *
160 * @return array Updated PayPal IPN data.
161 *
162 * @see https://github.com/websharks/s2member/issues/1112
163 */
164 public static function paypal_postvars_back_compat($postvars)
165 {
166 $postvars = (array) $postvars;
167
168 foreach ($postvars as $_key => $_value) {
169 if (is_string($_key) && preg_match('/_?[0-9]+$/u', $_key)) {
170 $_old_key = preg_replace('/_?[0-9]+$/u', '', $_key);
171 if (!isset($postvars[$_old_key])) $postvars[$_old_key] = $_value;
172 }
173 } // unset($_key, $_old_key, $_value); // Housekeeping.
174
175 return $postvars; // w/ back. compat keys.
176 }
177 /**
178 * Generates a PayPal Proxy Key, for simulated IPN responses.
179 *
180 * @package s2Member\PayPal
181 * @since 3.5
182 *
183 * @return string A Proxy Key. It's an MD5 Hash, 32 chars, URL-safe.
184 */
185 public static function paypal_proxy_key_gen()
186 {
187 global /* Multisite Networking. */ $current_site, $current_blog;
188
189 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
190 do_action("ws_plugin__s2member_before_paypal_proxy_key_gen", get_defined_vars());
191 unset($__refs, $__v);
192
193 if(is_multisite() && !is_main_site())
194 $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(strtolower($current_blog->domain.$current_blog->path), false, false));
195
196 else {
197 $host = ($GLOBALS['WS_PLUGIN__']['s2member']['o']['skip_ipn_domain_validation']) ? parse_url(home_url('/'), PHP_URL_HOST) : $_SERVER["HTTP_HOST"]; //250917
198 $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(preg_replace("/\:[0-9]+$/", "", strtolower((string) $host)), false, false));
199 }
200
201 return apply_filters("ws_plugin__s2member_paypal_proxy_key_gen", $key, get_defined_vars());
202 }
203 /**
204 * Acquires a short-lived dedupe lock.
205 *
206 * @package s2Member\PayPal
207 * @since 260406
208 *
209 * @param string $lock_option Dedupe lock option name.
210 * @param integer $lock_timeout Optional. Lock timeout in seconds.
211 *
212 * @return bool TRUE if lock acquired; else FALSE.
213 */
214 public static function dedupe_lock_acquire($lock_option, $lock_timeout = 900)
215 {
216 if(!$lock_option || !is_string($lock_option))
217 return FALSE;
218
219 if(add_option($lock_option, time(), '', 'no'))
220 return TRUE;
221
222 $lock_time = (int)get_option($lock_option, 0);
223
224 if($lock_time > 0 && (time() - $lock_time) >= abs($lock_timeout))
225 {
226 delete_option($lock_option);
227
228 if(add_option($lock_option, time(), '', 'no'))
229 return TRUE;
230 }
231 return FALSE;
232 }
233 /**
234 * Releases a short-lived dedupe lock.
235 *
236 * @package s2Member\PayPal
237 * @since 260406
238 *
239 * @param string $lock_option Dedupe lock option name.
240 *
241 * @return void
242 */
243 public static function dedupe_lock_release($lock_option)
244 {
245 if($lock_option && is_string($lock_option))
246 delete_option($lock_option);
247 }
248 /**
249 * Gets a dedupe done-marker time and expires it lazily when needed.
250 *
251 * @package s2Member\PayPal
252 * @since 260406
253 *
254 * @param string $done_option Dedupe done-marker option name.
255 * @param integer $done_ttl Optional. Marker TTL in seconds.
256 *
257 * @return integer UNIX timestamp if still valid; else 0.
258 */
259 public static function dedupe_done_time_get($done_option, $done_ttl = 0)
260 {
261 if(!$done_option || !is_string($done_option))
262 return 0;
263
264 $done_time = (int)get_option($done_option, 0);
265
266 if($done_time > 0 && $done_ttl > 0 && (time() - $done_time) >= abs($done_ttl))
267 {
268 delete_option($done_option);
269 return 0;
270 }
271 return $done_time;
272 }
273 /**
274 * Marks a dedupe done-marker as done.
275 *
276 * @package s2Member\PayPal
277 * @since 260406
278 *
279 * @param string $done_option Dedupe done-marker option name.
280 *
281 * @return void
282 */
283 public static function dedupe_done_mark($done_option)
284 {
285 if($done_option && is_string($done_option))
286 {
287 if(!add_option($done_option, time(), '', 'no'))
288 update_option($done_option, time(), false);
289 }
290 }
291 /**
292 * Occasionally cleans up expired dedupe markers.
293 *
294 * @package s2Member\PayPal
295 * @since 260406
296 *
297 * @param string $cleanup_transient Cleanup throttle transient name.
298 * @param array $markers Array of arrays, each with `prefix` and `ttl` keys.
299 * @param integer $throttle_ttl Optional. Cleanup throttle TTL in seconds.
300 *
301 * @return void
302 */
303 public static function dedupe_markers_cleanup($cleanup_transient, $markers = array(), $throttle_ttl = 21600)
304 {
305 if(!$cleanup_transient || !is_string($cleanup_transient) || !is_array($markers) || empty($markers))
306 return;
307
308 if(get_transient($cleanup_transient))
309 return;
310
311 global $wpdb;
312
313 foreach($markers as $marker)
314 if(!empty($marker['prefix']) && isset($marker['ttl']) && is_string($marker['prefix']))
315 {
316 $cutoff = (string)(time() - abs((int)$marker['ttl']));
317
318 $wpdb->query("DELETE FROM `".$wpdb->options."` WHERE `option_name` LIKE '".esc_sql($marker['prefix'])."%' AND CAST(`option_value` AS UNSIGNED) > 0 AND CAST(`option_value` AS UNSIGNED) < '".$cutoff."'");
319 }
320
321 set_transient($cleanup_transient, time(), abs((int)$throttle_ttl));
322 }
323 /**
324 * Calls upon the PayPal API, and returns the response.
325 *
326 * @package s2Member\PayPal
327 * @since 3.5
328 *
329 * @param array $post_vars An array of variables to send through the PayPal API call.
330 * @return array An array of variables returned by the PayPal API.
331 *
332 * @todo Optimize this routine with ``empty()`` and ``isset()``.
333 * @todo Possibly integrate this API: {@link http://msdn.microsoft.com/en-us/library/ff512417.aspx}.
334 */
335 public static function paypal_api_response($post_vars = FALSE)
336 {
337 global /* For Multisite support. */ $current_site, $current_blog;
338
339 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
340 do_action("ws_plugin__s2member_before_paypal_api_response", get_defined_vars());
341 unset($__refs, $__v);
342
343 $url = "https://".(($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "api-3t.sandbox.paypal.com" : "api-3t.paypal.com")."/nvp";
344
345 $post_vars = apply_filters("ws_plugin__s2member_paypal_api_post_vars", $post_vars, get_defined_vars());
346 $post_vars = (is_array($post_vars)) ? $post_vars : array();
347
348 $post_vars["VERSION"] = /* Configure the PayPal API version. */ "71.0";
349 $post_vars["USER"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_api_username"];
350 $post_vars["PWD"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_api_password"];
351 $post_vars["SIGNATURE"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_api_signature"];
352
353 foreach($post_vars as $_key => &$_value /* We need to clean these up. */)
354 $_value = c_ws_plugin__s2member_paypal_utilities::paypal_api_nv_cleanup($_key, $_value);
355 unset($_key, $_value);
356
357 $input_time = /* Record input/nvp for logging. */ date("D M j, Y g:i:s a T");
358
359 $nvp = trim(c_ws_plugin__s2member_utils_urls::remote($url, $post_vars, array("timeout" => 20)));
360
361 $output_time = /* Now record after output time. */ date("D M j, Y g:i:s a T");
362
363 wp_parse_str /* Parse NVP response. */($nvp, $response);
364 $response = c_ws_plugin__s2member_utils_strings::trim_deep($response);
365
366 if(!$response["ACK"] || !preg_match("/^(Success|SuccessWithWarning)$/i", $response["ACK"]))
367 {
368 if(strlen($response["L_ERRORCODE0"]) || $response["L_SHORTMESSAGE0"] || $response["L_LONGMESSAGE0"])
369 /* translators: Exclude `%2$s` and `%3$s`. These are English details returned by PayPal. Replace `%2$s` and `%3$s` with: `Unable to process, please try again`, or something to that affect. Or, if you prefer, you could Filter ``$response["__error"]`` with `ws_plugin__s2member_paypal_api_response`. */
370 $response["__error"] = sprintf(_x('Error #%1$s. %2$s. %3$s.', "s2member-front", "s2member"), $response["L_ERRORCODE0"], rtrim($response["L_SHORTMESSAGE0"], "."), rtrim($response["L_LONGMESSAGE0"], "."));
371
372 else // Else, generate an error messsage - so something is reported back to the Customer.
373 $response["__error"] = _x("Error. Please contact Support for assistance.", "s2member-front", "s2member");
374 }
375 $logt = c_ws_plugin__s2member_utilities::time_details ();
376 $logv = c_ws_plugin__s2member_utilities::ver_details();
377 $logm = c_ws_plugin__s2member_utilities::mem_details();
378 $log4 = $_SERVER["HTTP_HOST"].$_SERVER["REQUEST_URI"]."\nUser-Agent: ".@$_SERVER["HTTP_USER_AGENT"];
379 $log4 = (is_multisite() && !is_main_site()) ? ($_log4 = $current_blog->domain.$current_blog->path)."\n".$log4 : $log4;
380 $log2 = (is_multisite() && !is_main_site()) ? "paypal-api-4-".trim(preg_replace("/[^a-z0-9]/i", "-", $_log4), "-").".log" : "paypal-api.log";
381
382 if(isset($post_vars["ACCT"]) && strlen($post_vars["ACCT"]) > 4)
383 $post_vars["ACCT"] = str_repeat("*", strlen($post_vars["ACCT"]) - 4).substr($post_vars["ACCT"], -4);
384
385 if($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["gateway_debug_logs"])
386 if(is_dir($logs_dir = $GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["logs_dir"]))
387 if(is_writable($logs_dir) && c_ws_plugin__s2member_utils_logs::archive_oversize_log_files())
388 if(($log = "-------- Input vars: ( ".$input_time." ) --------\n".var_export($post_vars, true)."\n"))
389 if(($log .= "-------- Output string/vars: ( ".$output_time." ) --------\n".$nvp."\n".var_export($response, true)))
390 file_put_contents($logs_dir."/".$log2,
391 "LOG ENTRY: ".$logt . "\n" . $logv."\n".$logm."\n".$log4."\n".
392 c_ws_plugin__s2member_utils_logs::conceal_private_info($log)."\n\n",
393 FILE_APPEND);
394
395 return apply_filters("ws_plugin__s2member_paypal_api_response", c_ws_plugin__s2member_paypal_utilities::_paypal_api_response_filters($response), get_defined_vars());
396 }
397 /**
398 * A sort of callback function that Filters PayPal responses.
399 *
400 * Provides alternative explanations in some cases that require special attention.
401 *
402 * @package s2Member\PayPal
403 * @since 3.5
404 *
405 * @param array $response Expects an array of response variables returned by the PayPal API.
406 * @return array An array of variables returned by the PayPal API, after ``$response["__error"]`` is Filtered.
407 */
408 public static function _paypal_api_response_filters($response = FALSE)
409 {
410 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
411 do_action("_ws_plugin__s2member_before_paypal_api_response_filters", get_defined_vars());
412 unset($__refs, $__v);
413
414 if(!empty($response["__error"]) && !empty($response["L_ERRORCODE0"]))
415 {
416 if((int)$response["L_ERRORCODE0"] === 10422)
417 $response["__error"] = sprintf(_x("Error #%s. Transaction declined. Please use an alternate funding source.", "s2member-front", "s2member"), $response["L_ERRORCODE0"]);
418
419 else if((int)$response["L_ERRORCODE0"] === 10435)
420 $response["__error"] = sprintf(_x("Error #%s. Transaction declined. Express Checkout was NOT confirmed.", "s2member-front", "s2member"), $response["L_ERRORCODE0"]);
421
422 else if((int)$response["L_ERRORCODE0"] === 10417)
423 $response["__error"] = sprintf(_x("Error #%s. Transaction declined. Please use an alternate funding source.", "s2member-front", "s2member"), $response["L_ERRORCODE0"]);
424 }
425 return /* Filters already applied with: ``ws_plugin__s2member_paypal_api_response``. */ $response;
426 }
427 /**
428 * Cleans up values passed through PayPal NVP strings.
429 *
430 * @package s2Member\PayPal
431 * @since 121202
432 *
433 * @param string $key Expects a string value.
434 * @param string $value Expects a string value.
435 * @return string Cleaned string value.
436 */
437 public static function paypal_api_nv_cleanup($key = FALSE, $value = FALSE)
438 {
439 $value = (string)$value;
440 $value = preg_replace('/"/', "'", $value);
441
442 if(($key === "DESC" || $key === "BA_DESC" #
443 || preg_match("/^L_NAME[0-9]+$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_DESC$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_NAME[0-9]+$/", $key) #
444 || preg_match("/^L_BILLINGAGREEMENTDESCRIPTION[0-9]+$/", $key)) && strlen($value) > 60)
445 $value = substr($value, 0, 57)."...";
446
447 return apply_filters("ws_plugin__s2member_paypal_api_nv_cleanup", $value, get_defined_vars());
448 }
449 /**
450 * Calls upon the PayPal PayFlow API, and returns the response.
451 *
452 * @package s2Member\PayPal
453 * @since 120514
454 *
455 * @param array $post_vars An array of variables to send through the PayPal PayFlow API call.
456 * @return array An array of variables returned by the PayPal PayFlow API.
457 */
458 public static function paypal_payflow_api_response($post_vars = FALSE)
459 {
460 global /* For Multisite support. */ $current_site, $current_blog;
461
462 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
463 do_action("ws_plugin__s2member_before_paypal_payflow_api_response", get_defined_vars());
464 unset($__refs, $__v);
465
466 $url = "https://".(($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "pilot-payflowpro.paypal.com" : "payflowpro.paypal.com");
467
468 $post_vars = apply_filters("ws_plugin__s2member_paypal_payflow_api_post_vars", $post_vars, get_defined_vars());
469 $post_vars = (is_array($post_vars)) ? $post_vars : array();
470
471 $post_vars["VERBOSITY"] = "HIGH";
472 $post_vars["USER"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_username"];
473 $post_vars["PARTNER"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_partner"];
474 $post_vars["VENDOR"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_vendor"];
475 $post_vars["PWD"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_password"];
476
477 foreach($post_vars as $_key => &$_value /* We need to clean these up. */)
478 $_value = c_ws_plugin__s2member_paypal_utilities::paypal_payflow_api_nv_cleanup($_key, $_value);
479 unset($_key, $_value);
480
481 $input_time = /* Record input/nvp for logging. */ date("D M j, Y g:i:s a T");
482
483 $nvp_post_vars = /* Initialize this to an empty string. */ "";
484 foreach($post_vars as $_key => $_value /* A ridiculous `text/namevalue` format. */)
485 $nvp_post_vars .= (($nvp_post_vars) ? "&" : "").$_key."[".strlen($_value)."]=".$_value;
486 unset($_key, $_value);
487
488 $nvp = trim(c_ws_plugin__s2member_utils_urls::remote($url, $nvp_post_vars, array("timeout" => 20, "headers" => array("Content-Type" => "text/namevalue"))));
489
490 $output_time = /* Now record after output time. */ date("D M j, Y g:i:s a T");
491
492 wp_parse_str /* Parse NVP response. */($nvp, $response);
493 $response = c_ws_plugin__s2member_utils_strings::trim_deep($response);
494
495 if($response["RESULT"] !== "0")
496 {
497 if(strlen($response["RESPMSG"]))
498 /* translators: Exclude `%2$s`. These are English details returned by PayPal. Replace `%2$s` with: `Unable to process, please try again`, or something to that affect. Or, if you prefer, you could Filter ``$response["__error"]`` with `ws_plugin__s2member_paypal_payflow_api_response`. */
499 $response["__error"] = sprintf(_x('Error #%1$s. %2$s.', "s2member-front", "s2member"), $response["RESULT"], rtrim($response["RESPMSG"], "."));
500
501 else $response["__error"] = _x("Error. Please contact Support for assistance.", "s2member-front", "s2member");
502 }
503 else if(isset($response["TRXRESULT"]) && $response["TRXRESULT"] !== "0")
504 {
505 if(strlen($response["TRXRESPMSG"]))
506 /* translators: Exclude `%2$s`. These are English details returned by PayPal. Replace `%2$s` with: `Unable to process, please try again`, or something to that affect. Or, if you prefer, you could Filter ``$response["__error"]`` with `ws_plugin__s2member_paypal_payflow_api_response`. */
507 $response["__error"] = sprintf(_x('Error #%1$s. %2$s.', "s2member-front", "s2member"), $response["TRXRESULT"], rtrim($response["TRXRESPMSG"], "."));
508
509 else $response["__error"] = _x("Error. Please contact Support for assistance.", "s2member-front", "s2member");
510 }
511
512 $logt = c_ws_plugin__s2member_utilities::time_details ();
513 $logv = c_ws_plugin__s2member_utilities::ver_details();
514 $logm = c_ws_plugin__s2member_utilities::mem_details();
515 $log4 = $_SERVER["HTTP_HOST"].$_SERVER["REQUEST_URI"]."\nUser-Agent: ".@$_SERVER["HTTP_USER_AGENT"];
516 $log4 = (is_multisite() && !is_main_site()) ? ($_log4 = $current_blog->domain.$current_blog->path)."\n".$log4 : $log4;
517 $log2 = (is_multisite() && !is_main_site()) ? "paypal-payflow-api-4-".trim(preg_replace("/[^a-z0-9]/i", "-", $_log4), "-").".log" : "paypal-payflow-api.log";
518
519 if(isset($post_vars["ACCT"]) && strlen($post_vars["ACCT"]) > 4)
520 $post_vars["ACCT"] = str_repeat("*", strlen($post_vars["ACCT"]) - 4).substr($post_vars["ACCT"], -4);
521
522 if($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["gateway_debug_logs"])
523 if(is_dir($logs_dir = $GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["logs_dir"]))
524 if(is_writable($logs_dir) && c_ws_plugin__s2member_utils_logs::archive_oversize_log_files())
525 if(($log = "-------- Input vars: ( ".$input_time." ) --------\n".$nvp_post_vars."\n".var_export($post_vars, true)."\n"))
526 if(($log .= "-------- Output string/vars: ( ".$output_time." ) --------\n".$nvp."\n".var_export($response, true)))
527 file_put_contents($logs_dir."/".$log2,
528 "LOG ENTRY: ".$logt . "\n" . $logv."\n".$logm."\n".$log4."\n".
529 c_ws_plugin__s2member_utils_logs::conceal_private_info($log)."\n\n",
530 FILE_APPEND);
531
532 return apply_filters("ws_plugin__s2member_paypal_payflow_api_response", c_ws_plugin__s2member_paypal_utilities::_paypal_payflow_api_response_filters($response), get_defined_vars());
533 }
534 /**
535 * A sort of callback function that Filters Payflow responses.
536 *
537 * Provides alternative explanations in some cases that require special attention.
538 *
539 * @package s2Member\PayPal
540 * @since 120514
541 *
542 * @param array $response Expects an array of response variables returned by the Payflow API.
543 * @return array An array of variables returned by the Payflow API, after ``$response["__error"]`` is Filtered.
544 */
545 public static function _paypal_payflow_api_response_filters($response = FALSE)
546 {
547 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
548 do_action("_ws_plugin__s2member_before_paypal_payflow_api_response_filters", get_defined_vars());
549 unset($__refs, $__v);
550
551 if(!empty($response["__error"]) && !empty($response["HOSTCODE"]))
552 {
553 if((int)$response["HOSTCODE"] === 11452)
554 $response["__error"] .= _x(" Please contact PayPal Merchant Technical Support (www.paypal.com/mts) and request `Recurring Billing` service, and also ask to have `Reference Transactions` enabled for Recurring Billing via Express Checkout.", "s2member-front", "s2member");
555 }
556
557 return /* Filters already applied with: ``ws_plugin__s2member_paypal_payflow_api_response``. */ $response;
558 }
559 /**
560 * Cleans up values passed through PayPal text/namevalue strings.
561 *
562 * @package s2Member\PayPal
563 * @since 121202
564 *
565 * @param string $key Expects a string value.
566 * @param string $value Expects a string value.
567 * @return string Cleaned string value.
568 */
569 public static function paypal_payflow_api_nv_cleanup($key = FALSE, $value = FALSE)
570 {
571 $value = (string)$value;
572 $value = preg_replace('/"/', "'", $value);
573
574 if(($key === "DESC" || $key === "ORDERDESC" || $key === "BA_DESC" || $key === "BA_CUSTOM" #
575 || preg_match("/^L_NAME[0-9]+$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_DESC$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_NAME[0-9]+$/", $key) #
576 || preg_match("/^L_BILLINGAGREEMENTDESCRIPTION[0-9]+$/", $key)) && strlen($value) > 60)
577 $value = substr($value, 0, 57)."...";
578
579 return apply_filters("ws_plugin__s2member_paypal_payflow_api_nv_cleanup", $value, get_defined_vars());
580 }
581 /**
582 * Converts a term `D|W|M|Y` into PayPal Pro format.
583 *
584 * @package s2Member\PayPal
585 * @since 3.5
586 *
587 * @param string $term Expects one of `D|W|M|Y`.
588 * @return bool|str A full singular description of the term *( i.e., `Day|Week|Month|Year` )*, else false.
589 */
590 public static function paypal_pro_term($term = FALSE)
591 {
592 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
593 do_action("ws_plugin__s2member_before_paypal_pro_term", get_defined_vars());
594 unset($__refs, $__v);
595
596 $paypal_pro_terms = array("D" => "Day", "W" => "Week", "M" => "Month", "Y" => "Year");
597
598 $pro_term = (!empty($paypal_pro_terms[strtoupper($term)])) ? $paypal_pro_terms[strtoupper($term)] : false;
599
600 return apply_filters("ws_plugin__s2member_paypal_pro_term", $pro_term, get_defined_vars());
601 }
602 /**
603 * Converts a term `D|W|M|Y` into Payflow format.
604 *
605 * @package s2Member\PayPal
606 * @since 120514
607 *
608 * @param string $term Expects one of `D|W|M|Y`.
609 * @param string $period Expects a numeric value.
610 * @return bool|str A full singular description of the term *( i.e., `DAY|WEEK|BIWK|MONT|QTER|SMYR|YEAR` )*, else false.
611 *
612 * @note Payflow unfortunately does NOT support daily and/or bi-monthly billing.
613 */
614 public static function paypal_payflow_term($term = FALSE, $period = FALSE)
615 {
616 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
617 do_action("ws_plugin__s2member_before_paypal_payflow_term", get_defined_vars());
618 unset($__refs, $__v);
619
620 $paypal_payflow_terms = array("D" => "DAY", "W" => "WEEK", "M" => "MONT", "Y" => "YEAR");
621
622 $payflow_term = (!empty($paypal_payflow_terms[strtoupper($term)])) ? $paypal_payflow_terms[strtoupper($term)] : false;
623
624 if($payflow_term === "WEEK" && $period === "2")
625 $payflow_term = "BIWK";
626
627 else if($payflow_term === "MONT" && $period === "3")
628 $payflow_term = "QTER";
629
630 else if($payflow_term === "MONT" && $period === "6")
631 $payflow_term = "SMYR";
632
633 return apply_filters("ws_plugin__s2member_paypal_payflow_term", $payflow_term, get_defined_vars());
634 }
635 /**
636 * Converts a term `Day|Week|Month|Year` into PayPal Standard format.
637 *
638 * @package s2Member\PayPal
639 * @since 3.5
640 *
641 * @param string $term Expects one of `Day|Week|Month|Year`.
642 * @return bool|str A term code *( i.e., `D|W|M|Y` )*, else false.
643 */
644 public static function paypal_std_term($term = FALSE)
645 {
646 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
647 do_action("ws_plugin__s2member_before_paypal_std_term", get_defined_vars());
648 unset($__refs, $__v);
649
650 $paypal_std_terms = array("DAY" => "D", "WEEK" => "W", "MONTH" => "M", "YEAR" => "Y");
651
652 $std_term = (!empty($paypal_std_terms[strtoupper($term)])) ? $paypal_std_terms[strtoupper($term)] : false;
653
654 return apply_filters("ws_plugin__s2member_paypal_std_term", $std_term, get_defined_vars());
655 }
656 /**
657 * Get `subscr_id` from either an array with `recurring_payment_id|subscr_id`, or use an existing string.
658 *
659 * @package s2Member\PayPal
660 * @since 3.5
661 *
662 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
663 * @return str|bool A `subscr_id` string if non-empty, else false.
664 */
665 public static function paypal_pro_subscr_id($array_or_string = FALSE)
666 {
667 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
668 do_action("ws_plugin__s2member_before_paypal_pro_subscr_id", get_defined_vars());
669 unset($__refs, $__v);
670
671 if(is_array($array = $array_or_string) && !empty($array["subscr_id"]))
672 $subscr_id = trim($array["subscr_id"]);
673
674 else if(is_array($array = $array_or_string) && !empty($array["recurring_payment_id"]))
675 $subscr_id = trim($array["recurring_payment_id"]);
676
677 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
678 && ($ipn_signup_var_subscr_id = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("subscr_id", FALSE, $array["mp_id"])))
679 $subscr_id = trim($ipn_signup_var_subscr_id); // Found w/ a Billing Agreement ID.
680
681 else if(is_string($string = $array_or_string) && !empty($string)) $subscr_id = trim($string);
682
683 return apply_filters("ws_plugin__s2member_paypal_pro_subscr_id", ((!empty($subscr_id)) ? $subscr_id : false), get_defined_vars());
684 }
685 /**
686 * Get `item_number` from either an array with `PROFILEREFERENCE|rp_invoice_id|item_number1|item_number`, or use an existing string.
687 *
688 * @package s2Member\PayPal
689 * @since 3.5
690 *
691 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
692 * If it's a string, we make sure it is a valid `level:ccaps:eotper` or `sp:ids:expiration` combination.
693 * @return str|bool An `item_number` string if non-empty, else false.
694 */
695 public static function paypal_pro_item_number($array_or_string = FALSE)
696 {
697 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
698 do_action("ws_plugin__s2member_before_paypal_pro_item_number", get_defined_vars());
699 unset($__refs, $__v);
700
701 if(is_array($array_or_string) && isset($array_or_string["PROFILENAME"]) /* Payflow. */)
702 $array_or_string["PROFILEREFERENCE"] = $array_or_string["PROFILENAME"];
703
704 if(is_array($array = $array_or_string) && !empty($array["item_number"]))
705 $_item_number = trim($array["item_number"]);
706
707 else if(is_array($array = $array_or_string) && !empty($array["item_number1"]))
708 $_item_number = trim($array["item_number1"]);
709
710 else if(is_array($array = $array_or_string) && (!empty($array["PROFILEREFERENCE"]) || !empty($array["rp_invoice_id"])))
711 list($_reference, $_domain, $_item_number) = array_map("trim", preg_split("/~/", ((!empty($array["PROFILEREFERENCE"])) ? $array["PROFILEREFERENCE"] : $array["rp_invoice_id"]), 3));
712
713 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
714 && ($ipn_signup_var_item_number = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("item_number", FALSE, $array["mp_id"])))
715 $_item_number = trim($ipn_signup_var_item_number); // Found w/ a Billing Agreement ID.
716
717 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit item_number on cancellations).
718 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
719 && ($ipn_signup_var_item_number = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("item_number", FALSE, ((!empty($array["recurring_payment_id"])) ? $array["recurring_payment_id"] : $array["subscr_id"]))))
720 $_item_number = trim($ipn_signup_var_item_number); // Found w/ a Subscription ID.
721
722 else if(is_string($string = $array_or_string) && !empty($string)) $_item_number = trim($string);
723
724 if(!empty($_item_number) && preg_match($GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["membership_item_number_w_or_wo_level_regex"], $_item_number))
725 $item_number = $_item_number;
726
727 else if(!empty($_item_number) && preg_match($GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["sp_access_item_number_regex"], $_item_number))
728 $item_number = $_item_number;
729
730 return apply_filters("ws_plugin__s2member_paypal_pro_item_number", ((!empty($item_number)) ? $item_number : false), get_defined_vars());
731 }
732 /**
733 * Get `item_name` from either an array with `product_name|item_name1|item_name`, or use an existing string.
734 *
735 * @package s2Member\PayPal
736 * @since 3.5
737 *
738 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
739 * @return str|bool An `item_name` string if non-empty, else false.
740 */
741 public static function paypal_pro_item_name($array_or_string = FALSE)
742 {
743 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
744 do_action("ws_plugin__s2member_before_paypal_pro_item_name", get_defined_vars());
745 unset($__refs, $__v);
746
747 if(is_array($array = $array_or_string) && !empty($array["item_name"]))
748 $item_name = trim($array["item_name"]);
749
750 else if(is_array($array = $array_or_string) && !empty($array["item_name1"]))
751 $item_name = trim($array["item_name1"]);
752
753 else if(is_array($array = $array_or_string) && !empty($array["product_name"]))
754 $item_name = trim($array["product_name"]);
755
756 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
757 && ($ipn_signup_var_item_name = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("item_name", FALSE, $array["mp_id"])))
758 $item_name = trim($ipn_signup_var_item_name); // Found w/ a Billing Agreement ID.
759
760 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit item_name on cancellations).
761 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
762 && ($ipn_signup_var_item_name = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("item_name", FALSE, ((!empty($array["recurring_payment_id"])) ? $array["recurring_payment_id"] : $array["subscr_id"]))))
763 $item_name = trim($ipn_signup_var_item_name); // Found w/ a Subscription ID.
764
765 else if(is_string($string = $array_or_string) && !empty($string)) $item_name = trim($string);
766
767 return apply_filters("ws_plugin__s2member_paypal_pro_item_name", ((!empty($item_name)) ? $item_name : false), get_defined_vars());
768 }
769 /**
770 * Get `period1` from either an array with `PROFILEREFERENCE|rp_invoice_id|period1`, or use an existing string.
771 *
772 * This will also convert `1 Day`, into `1 D`, and so on.
773 * This will also convert `1 SemiMonth`, into `2 W`, and so on.
774 *
775 * @package s2Member\PayPal
776 * @since 3.5
777 *
778 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
779 * If it's a string, we make sure it is a valid `period term` combination.
780 * @param string $default Optional. Value if unavailable. Defaults to `0 D`.
781 * @return string A `period1` string if possible, or defaults to `0 D`.
782 */
783 public static function paypal_pro_period1($array_or_string = FALSE, $default = "0 D")
784 {
785 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
786 do_action("ws_plugin__s2member_before_paypal_pro_period1", get_defined_vars());
787 unset($__refs, $__v);
788
789 if(is_array($array_or_string) && isset($array_or_string["PROFILENAME"]) /* Payflow. */)
790 $array_or_string["PROFILEREFERENCE"] = $array_or_string["PROFILENAME"];
791
792 if(is_array($array = $array_or_string) && !empty($array["period1"])) $_period1 = trim($array["period1"]);
793
794 else if(is_array($array = $array_or_string) && (!empty($array["PROFILEREFERENCE"]) || !empty($array["rp_invoice_id"])))
795 {
796 list($_reference, $_domain, $_item_number) = array_map("trim", preg_split("/~/", ((!empty($array["PROFILEREFERENCE"])) ? $array["PROFILEREFERENCE"] : $array["rp_invoice_id"]), 3));
797 list($_start_time, $_period1, $_period3) = array_map("trim", preg_split("/\:/", $_reference, 3));
798 }
799 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
800 && ($ipn_signup_var_period1 = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("period1", FALSE, $array["mp_id"])))
801 $_period1 = trim($ipn_signup_var_period1); // Found w/ a Billing Agreement ID.
802
803 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit period1 on cancellations).
804 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
805 && ($ipn_signup_var_period1 = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("period1", FALSE, ((!empty($array["recurring_payment_id"])) ? $array["recurring_payment_id"] : $array["subscr_id"]))))
806 $_period1 = trim($ipn_signup_var_period1); // Found w/ a Subscription ID.
807
808 else if(is_string($string = $array_or_string) && !empty($string)) $_period1 = trim($string);
809
810 if /* Were we able to get a `period1` string? */(!empty($_period1))
811 {
812 list($num, $span) = array_map("trim", preg_split("/ /", $_period1, 2));
813
814 if(strtoupper($span) === "SEMIMONTH" && is_numeric($num) && $num >= 1)
815 { $num = "2"; $span = "W"; }
816
817 if /* To Standard format. */(strlen($span) !== 1)
818 $span = c_ws_plugin__s2member_paypal_utilities::paypal_std_term($span);
819
820 $span = (preg_match("/^[DWMY]$/i", $span)) ? $span : "";
821 $num = ($span && is_numeric($num) && $num >= 0) ? $num : "";
822
823 $period1 = ($num && $span) ? $num." ".strtoupper($span) : $default;
824
825 return apply_filters("ws_plugin__s2member_paypal_pro_period1", $period1, get_defined_vars());
826 }
827 else return apply_filters("ws_plugin__s2member_paypal_pro_period1", $default, get_defined_vars());
828 }
829 /**
830 * Get `period3` from either an array with `PROFILEREFERENCE|rp_invoice_id|period3`, or use an existing string.
831 *
832 * This will also convert `1 Day`, into `1 D`, and so on.
833 * This will also convert `1 SemiMonth`, into `2 W`, and so on.
834 * The Regular Period can never be less than 1 day ( `1 D` ).
835 *
836 * @package s2Member\PayPal
837 * @since 3.5
838 *
839 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
840 * If it's a string, we make sure it is a valid `period term` combination.
841 * @param string $default Optional. Value if unavailable. Defaults to `1 D`.
842 * @return string A `period3` string if possible, or defaults to `1 D`.
843 */
844 public static function paypal_pro_period3($array_or_string = FALSE, $default = "1 D")
845 {
846 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
847 do_action("ws_plugin__s2member_before_paypal_pro_period3", get_defined_vars());
848 unset($__refs, $__v);
849
850 if(is_array($array_or_string) && isset($array_or_string["PROFILENAME"]) /* Payflow. */)
851 $array_or_string["PROFILEREFERENCE"] = $array_or_string["PROFILENAME"];
852
853 if(is_array($array = $array_or_string) && !empty($array["period3"])) $_period3 = trim($array["period3"]);
854
855 else if(is_array($array = $array_or_string) && (!empty($array["PROFILEREFERENCE"]) || !empty($array["rp_invoice_id"])))
856 {
857 list($_reference, $_domain, $_item_number) = array_map("trim", preg_split("/~/", ((!empty($array["PROFILEREFERENCE"])) ? $array["PROFILEREFERENCE"] : $array["rp_invoice_id"]), 3));
858 list($_start_time, $_period1, $_period3) = array_map("trim", preg_split("/\:/", $_reference, 3));
859 }
860 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
861 && ($ipn_signup_var_period3 = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("period3", FALSE, $array["mp_id"])))
862 $_period3 = trim($ipn_signup_var_period3); // Found w/ a Billing Agreement ID.
863
864 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit period3 on cancellations).
865 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
866 && ($ipn_signup_var_period3 = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("period3", FALSE, ((!empty($array["recurring_payment_id"])) ? $array["recurring_payment_id"] : $array["subscr_id"]))))
867 $_period3 = trim($ipn_signup_var_period3); // Found w/ a Subscription ID.
868
869 else if(is_string($string = $array_or_string) && !empty($string)) $_period3 = trim($string);
870
871 if /* Were we able to get a `period3` string? */(!empty($_period3))
872 {
873 list($num, $span) = array_map("trim", preg_split("/ /", $_period3, 2));
874
875 if(strtoupper($span) === "SEMIMONTH" && is_numeric($num) && $num >= 1)
876 { $num = "2"; $span = "W"; }
877
878 if /* To Standard format. */(strlen($span) !== 1)
879 $span = c_ws_plugin__s2member_paypal_utilities::paypal_std_term($span);
880
881 $span = (preg_match("/^[DWMY]$/i", $span)) ? $span : "";
882 $num = ($span && is_numeric($num) && $num >= 0) ? $num : "";
883
884 $period3 = ($num && $span) ? $num." ".strtoupper($span) : $default;
885
886 return apply_filters("ws_plugin__s2member_paypal_pro_period3", $period3, get_defined_vars());
887 }
888 else return apply_filters("ws_plugin__s2member_paypal_pro_period3", $default, get_defined_vars());
889 }
890
891 //260106 PayPal Checkout
892 /**
893 * Returns true when PayPal Checkout is enabled and required credentials exist.
894 *
895 * @since 260106
896 *
897 * @return bool
898 */
899 public static function paypal_checkout_is_enabled()
900 {
901 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_enable']))
902 return false;
903
904 if(self::paypal_checkout_is_sandbox())
905 return (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id'])
906 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_secret']));
907
908 return (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'])
909 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_secret']));
910 }
911
912 /**
913 * Returns true when PayPal Checkout webhook processing can operate.
914 *
915 * This is intentionally decoupled from `paypal_checkout_enable` so that:
916 * - sites can switch new sales back to PayPal Standard
917 * - while still processing webhooks for existing Checkout subscriptions
918 *
919 * @since 260218
920 *
921 * @return bool
922 */
923 public static function paypal_checkout_webhook_processing_is_enabled()
924 {
925 // Full Checkout enabled? Then yes.
926 if(self::paypal_checkout_is_enabled())
927 return true;
928
929 // Otherwise: allow webhook processing when creds + webhook id exist (either env).
930 $live_ready = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'])
931 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_secret'])
932 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_webhook_id']));
933
934 $sandbox_ready = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id'])
935 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_secret'])
936 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_webhook_id']));
937
938 return ($live_ready || $sandbox_ready);
939 }
940
941 /**
942 * Returns true when PayPal Checkout is in sandbox mode.
943 *
944 * @since 260101
945 *
946 * @return bool
947 */
948 public static function paypal_checkout_is_sandbox()
949 {
950 return !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox']);
951 }
952
953 /**
954 * Returns the PayPal REST API base URL for the active environment.
955 *
956 * @since 260101
957 *
958 * @return string
959 */
960 public static function paypal_checkout_api_base()
961 {
962 return (self::paypal_checkout_is_sandbox())
963 ? 'https://api-m.sandbox.paypal.com'
964 : 'https://api-m.paypal.com';
965 }
966
967 /**
968 * Returns PayPal Checkout REST credentials for the active environment.
969 *
970 * @since 260101
971 *
972 * @return array{client_id:string,secret:string}
973 */
974 public static function paypal_checkout_creds()
975 {
976 if(self::paypal_checkout_is_sandbox())
977 return array(
978 'client_id' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id'],
979 'secret' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_secret'],
980 );
981
982 return array(
983 'client_id' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'],
984 'secret' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_secret'],
985 );
986 }
987
988 /**
989 * Returns a stable short id derived from the PayPal Client ID (per env).
990 *
991 * Used to bucket caches in:
992 * - $options['paypal_checkout_cache'][$cred_id][...]
993 *
994 * @since 260127
995 *
996 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
997 *
998 * @return string 12-char hash prefix or empty string.
999 */
1000 public static function paypal_checkout_cred_id($env = '')
1001 {
1002 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1003
1004 $client_id = ($env === 'sandbox')
1005 ? (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id']
1006 : (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'];
1007
1008 $client_id = trim($client_id);
1009 if(!$client_id)
1010 return '';
1011
1012 return substr(md5(strtolower($client_id)), 0, 12);
1013 }
1014
1015 /**
1016 * Returns a cached PayPal REST access token (fetches a new one when needed).
1017 *
1018 * Stored in a transient keyed by environment.
1019 *
1020 * @since 260101
1021 *
1022 * @return string Access token or empty string on failure.
1023 */
1024 public static function paypal_checkout_access_token()
1025 {
1026 $transient = self::paypal_checkout_is_sandbox() ? 's2m_ppco_at_sandbox' : 's2m_ppco_at_live';
1027
1028 if(($cached = get_transient($transient)) && is_array($cached) && !empty($cached['access_token']))
1029 return $cached['access_token'];
1030
1031 $creds = self::paypal_checkout_creds();
1032 $client_id = (string)$creds['client_id'];
1033 $secret = (string)$creds['secret'];
1034 $client_len_hash = strlen($client_id).'_'.substr(hash('sha256', $client_id), 0, 16);
1035 $secret_len_hash = strlen($secret).'_'.substr(hash('sha256', $secret), 0, 16);
1036
1037 if(!$client_id || !$secret)
1038 return '';
1039
1040 $url = self::paypal_checkout_api_base().'/v1/oauth2/token';
1041 $body = 'grant_type=client_credentials';
1042
1043 $args = array(
1044 'timeout' => 20,
1045 'headers' => array(
1046 'Authorization' => 'Basic '.base64_encode($client_id.':'.$secret),
1047 'Content-Type' => 'application/x-www-form-urlencoded',
1048 'Accept' => 'application/json',
1049 'Accept-Language' => 'en_US',
1050 ),
1051 );
1052
1053 $r = c_ws_plugin__s2member_utils_urls::remote($url, $body, $args, true);
1054
1055 if(!is_array($r))
1056 $r = array('code' => 0, 'message' => 'request_failed', 'headers' => array(), 'body' => '');
1057
1058 if(!isset($r['code']) || (int)$r['code'] !== 200)
1059 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1060 'ppco' => 'oauth',
1061 'event' => 'token_failed',
1062 'env_setting' => self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live',
1063 'client_len_hash' => $client_len_hash,
1064 'secret_len_hash' => $secret_len_hash,
1065 'url' => $url,
1066 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
1067 'message' => !empty($r['message']) ? (string)$r['message'] : '',
1068 'body' => !empty($r['body']) ? $r['body'] : '',
1069 ));
1070
1071 $data = array();
1072 if(!empty($r['body']) && is_string($r['body']))
1073 $data = json_decode($r['body'], true);
1074
1075 if(!empty($data['access_token']) && !empty($data['expires_in']))
1076 {
1077 $ttl = max(60, (int)$data['expires_in'] - 60);
1078 set_transient($transient, array('access_token' => $data['access_token']), $ttl);
1079
1080 return $data['access_token'];
1081 }
1082 return '';
1083 }
1084
1085 /**
1086 * Tests PayPal Checkout REST credentials for the selected environment.
1087 *
1088 * Forces a real access token request (clears cached token transient first).
1089 * Intended for admin UI diagnostics during beta/QA.
1090 *
1091 * @since 260115
1092 *
1093 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
1094 *
1095 * @return bool True if an access token was obtained; otherwise false.
1096 */
1097 public static function paypal_checkout_creds_test($env = '')
1098 {
1099 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1100
1101 $orig_sandbox = self::paypal_checkout_is_sandbox();
1102 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = ($env === 'sandbox') ? '1' : '0';
1103
1104 // Force a real token request (ignore cached transient).
1105 $transient = self::paypal_checkout_is_sandbox() ? 's2m_ppco_at_sandbox' : 's2m_ppco_at_live';
1106 delete_transient($transient);
1107
1108 $token = self::paypal_checkout_access_token();
1109 $ok = ($token) ? true : false;
1110
1111 $creds = self::paypal_checkout_creds();
1112 $client_len_hash = strlen((string)$creds['client_id']).'_'.substr(hash('sha256', (string)$creds['client_id']), 0, 16);
1113 $secret_len_hash = strlen((string)$creds['secret']).'_'.substr(hash('sha256', (string)$creds['secret']), 0, 16);
1114
1115 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1116 'ppco' => 'checkout',
1117 'event' => $ok ? 'creds_test_ok' : 'creds_test_failed',
1118 'env_setting' => $env,
1119 'client_len_hash' => $client_len_hash,
1120 'secret_len_hash' => $secret_len_hash,
1121 ));
1122
1123 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1124 return $ok;
1125 }
1126
1127 /**
1128 * Clears PayPal Checkout plan/product caches (per environment) and the cached access token.
1129 *
1130 * Cache storage:
1131 * - $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']
1132 * - $options['paypal_checkout_cache'][$cred_id][$env]['product_ids']
1133 *
1134 * Intended for QA and for situations where a cached plan/product id becomes stale
1135 * due to changes in billing attributes.
1136 *
1137 * @since 260127
1138 *
1139 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
1140 *
1141 * @return bool
1142 */
1143 public static function paypal_checkout_clear_cache($env = '')
1144 {
1145 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1146
1147 $orig_sandbox = self::paypal_checkout_is_sandbox();
1148 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = ($env === 'sandbox') ? '1' : '0';
1149
1150 $cred_id = self::paypal_checkout_cred_id($env);
1151
1152 $options = get_option('ws_plugin__s2member_options');
1153 if(!is_array($options))
1154 $options = array();
1155
1156 // New cache format: $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids'|'product_ids'].
1157 if($cred_id && !empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])
1158 && !empty($options['paypal_checkout_cache'][$cred_id]) && is_array($options['paypal_checkout_cache'][$cred_id])
1159 && !empty($options['paypal_checkout_cache'][$cred_id][$env]) && is_array($options['paypal_checkout_cache'][$cred_id][$env]))
1160 {
1161 if(isset($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']))
1162 unset($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']);
1163
1164 if(isset($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']))
1165 unset($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']);
1166
1167 if(empty($options['paypal_checkout_cache'][$cred_id][$env]))
1168 unset($options['paypal_checkout_cache'][$cred_id][$env]);
1169
1170 if(empty($options['paypal_checkout_cache'][$cred_id]))
1171 unset($options['paypal_checkout_cache'][$cred_id]);
1172 }
1173
1174 // Delete legacy cache keys (no migration; just remove).
1175 if(isset($options['paypal_checkout_plan_ids']))
1176 unset($options['paypal_checkout_plan_ids']);
1177
1178 if(isset($options['paypal_checkout_product_ids']))
1179 unset($options['paypal_checkout_product_ids']);
1180
1181 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
1182
1183 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
1184
1185 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]['paypal_checkout_cache'] = (!empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])) ? $options['paypal_checkout_cache'] : array();
1186
1187 // Clear cached access token for this env too.
1188 $transient = self::paypal_checkout_is_sandbox() ? 's2m_ppco_at_sandbox' : 's2m_ppco_at_live';
1189 delete_transient($transient);
1190
1191 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1192 'ppco' => 'checkout',
1193 'event' => 'cleared_cache',
1194 'env_setting' => $env,
1195 'cred_id' => $cred_id,
1196 ));
1197
1198 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1199 return true;
1200 }
1201
1202 /**
1203 * Performs a PayPal REST API request using the current environment access token.
1204 *
1205 * @since 260101
1206 *
1207 * @param string $method HTTP method.
1208 * @param string $path API path beginning with '/'.
1209 * @param mixed $body Array/object body or raw string; null for no body.
1210 * @param array $headers Additional headers.
1211 *
1212 * @return array Response array from c_ws_plugin__s2member_utils_urls::remote().
1213 */
1214 public static function paypal_checkout_api_request($method = 'GET', $path = '/', $body = null, $headers = array())
1215 {
1216 $method = strtoupper((string)$method);
1217 $url = self::paypal_checkout_api_base().$path;
1218
1219 $args = array(
1220 'timeout' => 20,
1221 'method' => $method,
1222 'headers' => array_merge(array(
1223 'Authorization' => 'Bearer '.self::paypal_checkout_access_token(),
1224 'Content-Type' => 'application/json',
1225 'Accept' => 'application/json',
1226 ), (array)$headers),
1227 );
1228
1229 if($body !== null)
1230 {
1231 $encoded = is_string($body) ? $body : wp_json_encode($body);
1232 $args['body'] = ($encoded !== false) ? $encoded : '{}';
1233 }
1234
1235 $r = c_ws_plugin__s2member_utils_urls::remote($url, false, $args, true);
1236
1237 if(!is_array($r))
1238 $r = array('code' => 0, 'message' => 'request_failed', 'headers' => array(), 'body' => '');
1239
1240 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1241 'ppco' => 'api_request',
1242 'env_setting' => self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live',
1243 'method' => $method,
1244 'path' => $path,
1245 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
1246 'message' => !empty($r['message']) ? (string)$r['message'] : '',
1247 'body' => !empty($r['body']) ? $r['body'] : '',
1248 ));
1249
1250 return $r;
1251 }
1252
1253 /**
1254 * Creates a PayPal Checkout order for one-time (Buy Now) purchases.
1255 *
1256 * This must be server-side to prevent client-side manipulation of amount, item_number,
1257 * custom fields, etc. The resulting order id is returned to the JS SDK or used for
1258 * redirect-mode approval.
1259 *
1260 * @since 260101
1261 *
1262 * @param array $token Signed/validated purchase token.
1263 *
1264 * @return array API request result array from paypal_checkout_api_request().
1265 */
1266 public static function paypal_checkout_order_create($token = array())
1267 {
1268 // token: invoice, custom, item_name, item_number, amount, cc, ns, return, cancel.
1269 $invoice = (string)$token['invoice'];
1270 $custom = (string)$token['custom'];
1271 $amount = (string)$token['amount'];
1272 $cc = strtoupper((string)$token['cc']);
1273
1274 $item_name = trim((string)$token['item_name']);
1275 if(!$item_name)
1276 $item_name = 's2Member Purchase';
1277
1278 // PayPal limits various fields; keep item name within common limits.
1279 if(strlen($item_name) > 127)
1280 $item_name = substr($item_name, 0, 127);
1281
1282 $item_sku = trim((string)$token['item_number']);
1283 if(strlen($item_sku) > 127)
1284 $item_sku = substr($item_sku, 0, 127);
1285
1286 $purchase_unit = array(
1287 'invoice_id' => $invoice,
1288 'amount' => array(
1289 'currency_code' => $cc,
1290 'value' => $amount,
1291 'breakdown' => array(
1292 'item_total' => array(
1293 'currency_code' => $cc,
1294 'value' => $amount,
1295 ),
1296 ),
1297 ),
1298 'description' => $item_name,
1299 'items' => array(
1300 array(
1301 'name' => $item_name,
1302 'quantity' => '1',
1303 'unit_amount' => array(
1304 'currency_code' => $cc,
1305 'value' => $amount,
1306 ),
1307 ),
1308 ),
1309 );
1310
1311 if($item_sku)
1312 $purchase_unit['items'][0]['sku'] = $item_sku;
1313
1314 // PayPal limits custom_id length; keep it short/consistent.
1315 if($custom && strlen($custom) <= 127)
1316 $purchase_unit['custom_id'] = $custom;
1317
1318 $body = array(
1319 'intent' => 'CAPTURE',
1320 'purchase_units' => array($purchase_unit),
1321 'application_context' => array(
1322 'user_action' => 'PAY_NOW',
1323 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1324 'return_url' => (string)$token['return'],
1325 'cancel_url' => (string)$token['cancel'],
1326 ),
1327 );
1328
1329 // Idempotency: stable per invoice for create-order retries.
1330 $headers = array(
1331 'PayPal-Request-Id' => 's2m-ppco-order-'.md5($invoice),
1332 );
1333
1334 $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1335
1336 $data = array();
1337 if(!empty($r['body']) && is_string($r['body']))
1338 $data = json_decode($r['body'], true);
1339
1340 return is_array($data) ? $data : array();
1341 }
1342
1343 /**
1344 * Retrieves PayPal Checkout subscription details via the Subscriptions REST API.
1345 *
1346 * @since 260517
1347 *
1348 * @param string $subscription_id PayPal subscription id (I-...).
1349 *
1350 * @return array Decoded subscription response, with __code/__body added; __error on failure.
1351 */
1352 public static function paypal_checkout_subscription_details($subscription_id = '')
1353 {
1354 $subscription_id = trim((string)$subscription_id);
1355
1356 if(!$subscription_id)
1357 return array('__error' => 'missing_subscription_id', '__code' => 0, '__body' => '');
1358
1359 $r = self::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
1360
1361 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1362 $body = !empty($r['body']) ? (string)$r['body'] : '';
1363 $data = ($body) ? json_decode($body, true) : array();
1364 $data = is_array($data) ? $data : array();
1365
1366 $data['__code'] = $code;
1367 $data['__body'] = $body;
1368
1369 if(!($code >= 200 && $code <= 299) || empty($data['id']))
1370 $data['__error'] = 'subscription_details_failed';
1371
1372 return $data;
1373 }
1374
1375 /**
1376 * Cancels a PayPal Checkout subscription via the Subscriptions REST API.
1377 *
1378 * Used by the optional on-site cancellation flow (logged-in users).
1379 *
1380 * @since 260114
1381 *
1382 * @param string $subscription_id PayPal subscription id (I-...).
1383 * @param string $reason Short human readable reason (PayPal limit applies).
1384 *
1385 * @return array API request result array from paypal_checkout_api_request().
1386 */
1387 public static function paypal_checkout_subscription_cancel($subscription_id = '', $reason = '')
1388 {
1389 $subscription_id = trim((string)$subscription_id);
1390 $reason = trim((string)$reason);
1391
1392 if(!$subscription_id)
1393 return array('code' => 0, 'message' => 'missing_subscription_id', 'body' => '');
1394
1395 // PayPal docs: reason 1..128 chars.
1396 $reason = substr(preg_replace('/\s+/', ' ', strip_tags($reason)), 0, 128);
1397 if(!$reason)
1398 $reason = 'Cancelled by subscriber.';
1399
1400 $body = array('reason' => $reason);
1401
1402 return self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions/'.rawurlencode($subscription_id).'/cancel', $body);
1403 }
1404
1405 /**
1406 * Cancels a PayPal Standard/legacy recurring profile via the classic NVP API.
1407 *
1408 * This is used by cross-gateway replacement flows when the old subscription appears
1409 * to be a PayPal Standard recurring profile. //260407
1410 *
1411 * @since 260407
1412 *
1413 * @param string $profile_id PayPal recurring profile id.
1414 * @param string $action Optional status action. Defaults to `Cancel`.
1415 *
1416 * @return array API response array from paypal_api_response().
1417 */
1418 public static function paypal_standard_subscription_cancel($profile_id = '', $action = 'Cancel')
1419 {
1420 $profile_id = trim((string)$profile_id);
1421 $action = trim((string)$action);
1422
1423 if(!$profile_id)
1424 return array('__error' => 'missing_profile_id');
1425
1426 if(!$action)
1427 $action = 'Cancel';
1428
1429 //260407 This still goes through the existing authenticated NVP helper, so current PayPal API credentials are required.
1430 return self::paypal_api_response(array(
1431 'METHOD' => 'ManageRecurringPaymentsProfileStatus',
1432 'ACTION' => $action,
1433 'PROFILEID' => $profile_id,
1434 ));
1435 }
1436
1437 /**
1438 * Captures a PayPal Checkout order (server-side) after buyer approval.
1439 *
1440 * Used by the JS SDK onApprove callback (capture_order op) and by redirect-mode
1441 * return handling. On success, the capture details are proxied into the legacy
1442 * s2Member PayPal notify/return handlers.
1443 *
1444 * @since 260101
1445 *
1446 * @param string $order_id PayPal Checkout order id.
1447 * @param array $token Signed/validated purchase token.
1448 *
1449 * @return array API request result array from paypal_checkout_api_request().
1450 */
1451 public static function paypal_checkout_order_capture($order_id = '', $token = array())
1452 {
1453 $order_id = trim((string)$order_id);
1454 if(!$order_id)
1455 return array();
1456
1457 // Idempotency: stable per order capture retries.
1458 $headers = array(
1459 'PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id),
1460 );
1461
1462 $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
1463
1464 $data = array();
1465 if(!empty($r['body']) && is_string($r['body']))
1466 $data = json_decode($r['body'], true);
1467
1468 return is_array($data) ? $data : array();
1469 }
1470
1471 /**
1472 * Creates a PayPal Checkout subscription (server-side) when using redirect-mode approval.
1473 *
1474 * In JS SDK button mode, subscriptions are created client-side using plan_id and
1475 * then confirmed server-side. Redirect-mode requires server-side creation.
1476 *
1477 * @since 260114
1478 *
1479 * @param array $token Signed/validated purchase token.
1480 *
1481 * @return array API request result array from paypal_checkout_api_request().
1482 */
1483 public static function paypal_checkout_subscription_create($token = array())
1484 {
1485 if(!is_array($token))
1486 return array();
1487
1488 $invoice = (string)$token['invoice'];
1489
1490 $plan_id = self::paypal_checkout_plan_get_id($token);
1491 if(!$plan_id)
1492 return array();
1493
1494 $brand_name = get_bloginfo('name');
1495 $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
1496
1497 $body = array(
1498 'plan_id' => $plan_id,
1499 'custom_id' => $invoice,
1500 'application_context' => array(
1501 'brand_name' => $brand_name,
1502 'return_url' => (string)$token['return'],
1503 'cancel_url' => (string)$token['cancel'],
1504 'user_action' => 'SUBSCRIBE_NOW',
1505 'shipping_preference' => 'NO_SHIPPING',
1506 ),
1507 );
1508
1509 // Idempotency: stable per invoice for create-subscription retries.
1510 $headers = array(
1511 'PayPal-Request-Id' => 's2m-ppco-sub-'.md5($invoice),
1512 );
1513
1514 $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
1515
1516 $data = array();
1517 if(!empty($r['body']) && is_string($r['body']))
1518 $data = json_decode($r['body'], true);
1519
1520 return is_array($data) ? $data : array();
1521 }
1522
1523 /**
1524 * Returns a PayPal Checkout Plan ID for a subscription token (creates product/plan if needed).
1525 *
1526 * Plan/product creation is cached in ws_plugin__s2member_options to avoid duplicates.
1527 * Cache key is derived from plan-affecting attributes (currency, billing cycles, trial).
1528 *
1529 * @since 260101
1530 *
1531 * @param array $token Signed/validated purchase token from shortcode flow.
1532 *
1533 * @return string PayPal plan id (P-...) or empty string on failure.
1534 */
1535 public static function paypal_checkout_plan_get_id($token = array())
1536 {
1537 if(!is_array($token))
1538 return '';
1539
1540 $cc = !empty($token['cc']) ? strtoupper(trim((string)$token['cc'])) : '';
1541 $rr = isset($token['rr']) ? strtoupper(trim((string)$token['rr'])) : '';
1542 $ra = isset($token['amount']) ? (string)$token['amount'] : '';
1543 $rp = !empty($token['rp']) ? (int)$token['rp'] : 0;
1544 $rt = !empty($token['rt']) ? strtoupper(trim((string)$token['rt'])) : '';
1545
1546 $rrt = !empty($token['rrt']) ? (int)$token['rrt'] : 0;
1547 $rra = isset($token['rra']) ? (int)$token['rra'] : 1;
1548
1549 // rrt/rra are only meaningful when rr="1" (recurring).
1550 if($rr !== '1')
1551 {
1552 $rrt = 0;
1553 $rra = 0;
1554 }
1555
1556 $ta = isset($token['ta']) ? (string)$token['ta'] : '';
1557 $tp = !empty($token['tp']) ? (int)$token['tp'] : 0;
1558 $tt = !empty($token['tt']) ? strtoupper(trim((string)$token['tt'])) : '';
1559
1560 if(!$cc || $rr === '' || $rr === 'BN' || $rp < 1 || !$rt)
1561 return '';
1562
1563 $env = self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
1564 $cred_id = self::paypal_checkout_cred_id($env);
1565 if(!$cred_id)
1566 return '';
1567
1568 $plan_key = md5(serialize(array(
1569 'env' => $env,
1570 'cc' => $cc,
1571 'rr' => $rr,
1572 'ra' => (string)$ra,
1573 'rp' => (int)$rp,
1574 'rt' => (string)$rt,
1575
1576 'rrt' => (int)$rrt,
1577 'rra' => (int)$rra,
1578
1579 'ta' => (string)$ta,
1580 'tp' => (int)$tp,
1581 'tt' => (string)$tt,
1582 'item_number' => !empty($token['item_number']) ? (string)$token['item_number'] : '',
1583 'item_name' => !empty($token['item_name']) ? (string)$token['item_name'] : '',
1584 )));
1585
1586 $ppco_opt = !empty($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"]) ? $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] : array();
1587 if(!is_array($ppco_opt))
1588 $ppco_opt = array();
1589
1590 $plan_ids = (!empty($ppco_opt[$cred_id][$env]['plan_ids']) && is_array($ppco_opt[$cred_id][$env]['plan_ids'])) ? $ppco_opt[$cred_id][$env]['plan_ids'] : array();
1591
1592 if(!empty($plan_ids[$plan_key]) && is_string($plan_ids[$plan_key]))
1593 return $plan_ids[$plan_key];
1594
1595 $product_id = self::paypal_checkout_product_get_id();
1596 if(!$product_id)
1597 return '';
1598
1599 $unit_map = array('D' => 'DAY', 'W' => 'WEEK', 'M' => 'MONTH', 'Y' => 'YEAR');
1600 $rt_unit = !empty($unit_map[$rt]) ? $unit_map[$rt] : 'MONTH';
1601 $tt_unit = !empty($unit_map[$tt]) ? $unit_map[$tt] : $rt_unit;
1602
1603 $rp = max(1, (int)$rp);
1604 $tp = max(0, (int)$tp);
1605
1606 $ra_v = number_format((float)$ra, 2, '.', '');
1607 $ta_v = number_format((float)$ta, 2, '.', '');
1608
1609 $regular_total_cycles = 0; // 0 = infinite.
1610
1611 // rrt = number of payments (limited recurring). Only applies to rr="1".
1612 if($rr === '1' && $rrt > 0)
1613 $regular_total_cycles = min(999, max(1, (int)$rrt));
1614 else if($rr === '0')
1615 $regular_total_cycles = 1;
1616
1617 $payment_failure_threshold = ($rr === '1' && $rra) ? 2 : 1;
1618
1619 $billing_cycles = array();
1620 $seq = 1;
1621
1622 if($tp > 0)
1623 {
1624 $billing_cycles[] = array(
1625 'frequency' => array(
1626 'interval_unit' => $tt_unit,
1627 'interval_count' => $tp,
1628 ),
1629 'tenure_type' => 'TRIAL',
1630 'sequence' => $seq++,
1631 'total_cycles' => 1,
1632 'pricing_scheme' => array(
1633 'fixed_price' => array(
1634 'value' => $ta_v,
1635 'currency_code' => $cc,
1636 ),
1637 ),
1638 );
1639 }
1640
1641 $billing_cycles[] = array(
1642 'frequency' => array(
1643 'interval_unit' => $rt_unit,
1644 'interval_count' => $rp,
1645 ),
1646 'tenure_type' => 'REGULAR',
1647 'sequence' => $seq++,
1648 'total_cycles' => $regular_total_cycles,
1649 'pricing_scheme' => array(
1650 'fixed_price' => array(
1651 'value' => $ra_v,
1652 'currency_code' => $cc,
1653 ),
1654 ),
1655 );
1656
1657 $plan_name = !empty($token['item_name']) ? (string)$token['item_name'] : 's2Member Plan';
1658 $plan_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($plan_name))), 0, 127);
1659
1660 $plan_desc = $plan_name;
1661 if(!empty($token['rr']) && $token['rr'] !== 'BN' && !empty($token['rp']) && !empty($token['rt']))
1662 {
1663 $plan_desc .= ' (recurring)';
1664 }
1665 $plan_desc = substr(preg_replace('/\s+/', ' ', trim(strip_tags($plan_desc))), 0, 127);
1666
1667 $body = array(
1668 'product_id' => $product_id,
1669 'name' => $plan_name,
1670 'description' => $plan_desc,
1671 'status' => 'ACTIVE',
1672 'billing_cycles' => $billing_cycles,
1673 'payment_preferences' => array(
1674 'auto_bill_outstanding' => true,
1675 'setup_fee' => array('value' => '0.00', 'currency_code' => $cc),
1676 'setup_fee_failure_action' => 'CONTINUE',
1677 'payment_failure_threshold' => $payment_failure_threshold,
1678 ),
1679 );
1680
1681 $headers = array(
1682 'PayPal-Request-Id' => 's2m-ppco-plan-'.md5($env.'|'.$plan_key.'|'.md5((string)wp_json_encode($body))),
1683 );
1684
1685 $r = self::paypal_checkout_api_request('POST', '/v1/billing/plans', $body, $headers);
1686
1687 $data = array();
1688 if(!empty($r['body']) && is_string($r['body']))
1689 $data = json_decode($r['body'], true);
1690
1691 $plan_id = !empty($data['id']) ? (string)$data['id'] : '';
1692 if(!$plan_id)
1693 {
1694 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1695 'ppco' => 'plan',
1696 'event' => 'plan_create_failed',
1697 'env_setting' => $env,
1698 'plan_key' => $plan_key,
1699 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
1700 'message' => !empty($r['message']) ? (string)$r['message'] : '',
1701 'body' => !empty($r['body']) ? (string)$r['body'] : '',
1702 'request' => $body,
1703 ));
1704 return '';
1705 }
1706
1707 $plan_ids[$plan_key] = $plan_id;
1708
1709 $options = get_option('ws_plugin__s2member_options');
1710 if(!is_array($options))
1711 $options = array();
1712
1713 if(empty($options['paypal_checkout_cache']) || !is_array($options['paypal_checkout_cache']))
1714 $options['paypal_checkout_cache'] = array();
1715
1716 if(empty($options['paypal_checkout_cache'][$cred_id]) || !is_array($options['paypal_checkout_cache'][$cred_id]))
1717 $options['paypal_checkout_cache'][$cred_id] = array();
1718
1719 if(empty($options['paypal_checkout_cache'][$cred_id][$env]) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]))
1720 $options['paypal_checkout_cache'][$cred_id][$env] = array();
1721
1722 if(empty($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']))
1723 $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids'] = array();
1724
1725 $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids'] = $plan_ids;
1726
1727 // Delete legacy cache keys (no migration; just remove).
1728 if(isset($options['paypal_checkout_plan_ids']))
1729 unset($options['paypal_checkout_plan_ids']);
1730
1731 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
1732
1733 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
1734 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] = (!empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])) ? $options['paypal_checkout_cache'] : array();
1735
1736 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1737 'ppco' => 'plan',
1738 'event' => 'plan_cached',
1739 'env_setting' => $env,
1740 'cred_id' => $cred_id,
1741 'plan_key' => $plan_key,
1742 'plan_id' => $plan_id,
1743 ));
1744
1745 return $plan_id;
1746 }
1747
1748 /**
1749 * Returns a PayPal Catalog Product ID (creates and caches if needed).
1750 *
1751 * Cached under:
1752 * - $options['paypal_checkout_cache'][$cred_id][$env]['product_ids'][$product_key]
1753 *
1754 * @since 260101
1755 *
1756 * @return string PayPal product id (PROD-...) or empty string on failure.
1757 */
1758 public static function paypal_checkout_product_get_id()
1759 {
1760 $env = self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
1761 $cred_id = self::paypal_checkout_cred_id($env);
1762 if(!$cred_id)
1763 return '';
1764
1765 $product_key = 'default';
1766
1767 $ppco_opt = !empty($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"]) ? $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] : array();
1768 if(!is_array($ppco_opt))
1769 $ppco_opt = array();
1770
1771 $product_ids = (!empty($ppco_opt[$cred_id][$env]['product_ids']) && is_array($ppco_opt[$cred_id][$env]['product_ids'])) ? $ppco_opt[$cred_id][$env]['product_ids'] : array();
1772
1773 if(!empty($product_ids[$product_key]) && is_string($product_ids[$product_key]))
1774 return $product_ids[$product_key];
1775
1776 $name = get_bloginfo('name');
1777 $url = home_url('/');
1778
1779 $name = substr(preg_replace('/\s+/', ' ', trim(strip_tags((string)$name))), 0, 127);
1780 if(!$name)
1781 $name = 's2Member';
1782
1783 $body = array(
1784 'name' => $name.' Membership',
1785 'description' => 'Membership billing product (created by s2Member).',
1786 'type' => 'SERVICE',
1787 'category' => 'SOFTWARE',
1788 'home_url' => $url,
1789 );
1790
1791 $headers = array(
1792 'PayPal-Request-Id' => 's2m-ppco-prod-'.md5($env),
1793 );
1794
1795 $r = self::paypal_checkout_api_request('POST', '/v1/catalogs/products', $body, $headers);
1796
1797 $data = array();
1798 if(!empty($r['body']) && is_string($r['body']))
1799 $data = json_decode($r['body'], true);
1800
1801 $product_id = !empty($data['id']) ? (string)$data['id'] : '';
1802 if(!$product_id)
1803 {
1804 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1805 'ppco' => 'product',
1806 'event' => 'product_create_failed',
1807 'env_setting' => $env,
1808 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
1809 'message' => !empty($r['message']) ? (string)$r['message'] : '',
1810 'body' => !empty($r['body']) ? (string)$r['body'] : '',
1811 ));
1812 return '';
1813 }
1814
1815 $product_ids[$product_key] = $product_id;
1816
1817 $options = get_option('ws_plugin__s2member_options');
1818 if(!is_array($options))
1819 $options = array();
1820
1821 if(empty($options['paypal_checkout_cache']) || !is_array($options['paypal_checkout_cache']))
1822 $options['paypal_checkout_cache'] = array();
1823
1824 if(empty($options['paypal_checkout_cache'][$cred_id]) || !is_array($options['paypal_checkout_cache'][$cred_id]))
1825 $options['paypal_checkout_cache'][$cred_id] = array();
1826
1827 if(empty($options['paypal_checkout_cache'][$cred_id][$env]) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]))
1828 $options['paypal_checkout_cache'][$cred_id][$env] = array();
1829
1830 if(empty($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']))
1831 $options['paypal_checkout_cache'][$cred_id][$env]['product_ids'] = array();
1832
1833 $options['paypal_checkout_cache'][$cred_id][$env]['product_ids'] = $product_ids;
1834
1835 // Delete legacy cache keys (no migration; just remove).
1836 if(isset($options['paypal_checkout_product_ids']))
1837 unset($options['paypal_checkout_product_ids']);
1838
1839 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
1840
1841 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
1842 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] = (!empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])) ? $options['paypal_checkout_cache'] : array();
1843
1844 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1845 'ppco' => 'product',
1846 'event' => 'product_cached',
1847 'env_setting' => $env,
1848 'cred_id' => $cred_id,
1849 'product_key' => $product_key,
1850 'product_id' => $product_id,
1851 ));
1852
1853 return $product_id;
1854 }
1855
1856 /**
1857 * Returns the stored PayPal webhook id for the active environment.
1858 *
1859 * @since 260101
1860 *
1861 * @return string Webhook id or empty string.
1862 */
1863 public static function paypal_checkout_webhook_id()
1864 {
1865 return self::paypal_checkout_is_sandbox()
1866 ? (string)$GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_sandbox_webhook_id"]
1867 : (string)$GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_webhook_id"];
1868 }
1869
1870 /**
1871 * Verifies a PayPal webhook signature via PayPal's verify-webhook-signature API.
1872 *
1873 * @since 260115
1874 *
1875 * @param mixed $event Decoded event array (or raw JSON string in $raw_body).
1876 * @param string $raw_body Raw webhook body.
1877 * @param array $headers Request headers (lowercase keys expected).
1878 *
1879 * @return bool True if signature verifies; otherwise false.
1880 */
1881 public static function paypal_checkout_verify_webhook_signature($event, $raw_body, $headers = array())
1882 {
1883 $tx_id = !empty($headers['paypal-transmission-id']) ? $headers['paypal-transmission-id'] : '';
1884 $tx_time = !empty($headers['paypal-transmission-time']) ? $headers['paypal-transmission-time'] : '';
1885 $tx_sig = !empty($headers['paypal-transmission-sig']) ? $headers['paypal-transmission-sig'] : '';
1886 $cert = !empty($headers['paypal-cert-url']) ? $headers['paypal-cert-url'] : '';
1887 $algo = !empty($headers['paypal-auth-algo']) ? $headers['paypal-auth-algo'] : '';
1888
1889 if(!$tx_id || !$tx_time || !$tx_sig || !$cert || !$algo)
1890 return false;
1891
1892 //260205 Detect sandbox vs live from the cert URL.
1893 $orig_sandbox = self::paypal_checkout_is_sandbox();
1894 $cert_is_sandbox = (strpos((string)$cert, 'sandbox') !== false);
1895
1896 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $cert_is_sandbox ? '1' : '0';
1897
1898 $webhook_id = self::paypal_checkout_webhook_id();
1899 if(!$webhook_id)
1900 {
1901 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1902 return false;
1903 }
1904
1905 $body = array(
1906 'transmission_id' => $tx_id,
1907 'transmission_time' => $tx_time,
1908 'cert_url' => $cert,
1909 'auth_algo' => $algo,
1910 'transmission_sig' => $tx_sig,
1911 'webhook_id' => $webhook_id,
1912 'webhook_event' => is_array($event) ? $event : json_decode((string)$raw_body, true),
1913 );
1914
1915 $r = self::paypal_checkout_api_request('POST', '/v1/notifications/verify-webhook-signature', $body);
1916 if(empty($r['code']) || (int)$r['code'] !== 200 || empty($r['body']))
1917 {
1918 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1919 return false;
1920 }
1921
1922 if(!is_string($r['body']))
1923 {
1924 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1925 return false;
1926 }
1927
1928 $data = json_decode($r['body'], true);
1929
1930 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1931 return !empty($data['verification_status']) && $data['verification_status'] === 'SUCCESS';
1932 }
1933
1934 /**
1935 * Returns the PayPal Checkout webhook event names processed by s2Member.
1936 *
1937 * These events are used for:
1938 * - Recurring payment bookkeeping (completed payments).
1939 * - Subscription lifecycle changes (cancel/suspend/expire/payment failed).
1940 *
1941 * @since 260115
1942 *
1943 * @return array<string> Event type names.
1944 */
1945 public static function paypal_checkout_webhook_event_names()
1946 {
1947 return array(
1948 'BILLING.SUBSCRIPTION.CANCELLED',
1949 'BILLING.SUBSCRIPTION.SUSPENDED',
1950 'BILLING.SUBSCRIPTION.EXPIRED',
1951 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
1952
1953 'PAYMENT.SALE.COMPLETED',
1954 'PAYMENT.CAPTURE.COMPLETED',
1955 );
1956 }
1957
1958 /**
1959 * Creates or updates a PayPal Checkout webhook for the current site URL and required events.
1960 *
1961 * Used by the admin "Create/Update Webhook Automatically" buttons.
1962 * Persists the webhook id into ws_plugin__s2member_options for the selected environment.
1963 *
1964 * @since 260115
1965 *
1966 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
1967 *
1968 * @return array Result array on success with keys:
1969 * - id (string) webhook id
1970 * - op (string) 'created'|'updated'
1971 * - env (string) 'live'|'sandbox'
1972 * Empty array on failure.
1973 */
1974 public static function paypal_checkout_webhook_upsert($env = '')
1975 {
1976 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1977
1978 $orig_sandbox = self::paypal_checkout_is_sandbox();
1979 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = ($env === 'sandbox') ? '1' : '0';
1980
1981 $url = add_query_arg('s2member_paypal_webhook', '1', home_url('/', 'https'));
1982
1983 $event_types = array();
1984 foreach(self::paypal_checkout_webhook_event_names() as $name)
1985 $event_types[] = array('name' => $name);
1986
1987 $existing_id = self::paypal_checkout_webhook_id();
1988
1989 if($existing_id)
1990 {
1991 $patch = array(
1992 array('op' => 'replace', 'path' => '/url', 'value' => $url),
1993 array('op' => 'replace', 'path' => '/event_types', 'value' => $event_types),
1994 );
1995 $r = self::paypal_checkout_api_request('PATCH', '/v1/notifications/webhooks/'.rawurlencode($existing_id), $patch);
1996
1997 if(!empty($r['code']) && (int)$r['code'] === 200)
1998 {
1999 self::paypal_checkout_webhook_store_id($existing_id);
2000
2001 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2002 'ppco' => 'webhook',
2003 'event' => 'updated_webhook',
2004 'env_setting' => $env,
2005 'id' => $existing_id,
2006 'url' => $url,
2007 'code' => (int)$r['code'],
2008 ));
2009
2010 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2011 return array('id' => $existing_id, 'op' => 'updated', 'env' => $env);
2012 }
2013
2014 //260205 PayPal may return 400 when there is no change; treat as success.
2015 $no_change = false;
2016 if(!empty($r['body']) && is_string($r['body']))
2017 {
2018 $d = json_decode($r['body'], true);
2019 $no_change = !empty($d['name']) && $d['name'] === 'WEBHOOK_PATCH_REQUEST_NO_CHANGE';
2020 }
2021 if($no_change)
2022 {
2023 self::paypal_checkout_webhook_store_id($existing_id);
2024
2025 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2026 'ppco' => 'webhook',
2027 'event' => 'updated_webhook_no_change',
2028 'env_setting' => $env,
2029 'id' => $existing_id,
2030 'url' => $url,
2031 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2032 ));
2033
2034 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2035 return array('id' => $existing_id, 'op' => 'updated', 'env' => $env);
2036 }
2037
2038 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2039 'ppco' => 'webhook',
2040 'event' => 'update_webhook_failed',
2041 'env_setting' => $env,
2042 'id' => $existing_id,
2043 'url' => $url,
2044 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2045 'message' => !empty($r['message']) ? (string)$r['message'] : '',
2046 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2047 ));
2048 }
2049
2050 $body = array(
2051 'url' => $url,
2052 'event_types' => $event_types,
2053 );
2054 $r = self::paypal_checkout_api_request('POST', '/v1/notifications/webhooks', $body);
2055
2056 $id = '';
2057 if(!empty($r['code']) && (int)$r['code'] === 201 && !empty($r['body']) && is_string($r['body']))
2058 {
2059 $data = json_decode($r['body'], true);
2060 if(!empty($data['id']))
2061 $id = (string)$data['id'];
2062 }
2063
2064 $adopted_existing = false;
2065
2066 //260205 If URL already exists, lookup existing webhook by URL and adopt its ID.
2067 if(!$id && !empty($r['code']) && (int)$r['code'] === 400 && !empty($r['body']) && is_string($r['body']))
2068 {
2069 $d = json_decode($r['body'], true);
2070 if(!empty($d['name']) && $d['name'] === 'WEBHOOK_URL_ALREADY_EXISTS')
2071 {
2072 $lr = self::paypal_checkout_api_request('GET', '/v1/notifications/webhooks');
2073 if(!empty($lr['code']) && (int)$lr['code'] === 200 && !empty($lr['body']) && is_string($lr['body']))
2074 {
2075 $ld = json_decode($lr['body'], true);
2076 if(!empty($ld['webhooks']) && is_array($ld['webhooks']))
2077 {
2078 foreach($ld['webhooks'] as $_wh)
2079 if(!empty($_wh['url']) && (string)$_wh['url'] === $url && !empty($_wh['id']))
2080 {
2081 $id = (string)$_wh['id'];
2082 $adopted_existing = true;
2083 break;
2084 }
2085 }
2086 }
2087 }
2088 }
2089
2090 if($id)
2091 {
2092 self::paypal_checkout_webhook_store_id($id);
2093
2094 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2095 'ppco' => 'webhook',
2096 'event' => $adopted_existing ? 'adopted_webhook' : 'created_webhook',
2097 'env_setting' => $env,
2098 'id' => $id,
2099 'url' => $url,
2100 'code' => $adopted_existing ? 200 : (int)$r['code'],
2101 ));
2102
2103 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2104 return array('id' => $id, 'op' => $adopted_existing ? 'adopted' : 'created', 'env' => $env);
2105 }
2106
2107 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2108 'ppco' => 'webhook',
2109 'event' => 'create_webhook_failed',
2110 'env_setting' => $env,
2111 'url' => $url,
2112 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2113 'message' => !empty($r['message']) ? (string)$r['message'] : '',
2114 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2115 ));
2116
2117 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2118 return array();
2119 }
2120
2121 /**
2122 * Stores a PayPal Checkout webhook id into ws_plugin__s2member_options for the current env.
2123 *
2124 * @since 260115
2125 *
2126 * @param string $webhook_id Webhook id returned by PayPal.
2127 *
2128 * @return void
2129 */
2130 protected static function paypal_checkout_webhook_store_id($webhook_id)
2131 {
2132 $options = get_option('ws_plugin__s2member_options');
2133 if(!is_array($options))
2134 $options = array();
2135
2136 if(self::paypal_checkout_is_sandbox())
2137 $options['paypal_checkout_sandbox_webhook_id'] = (string)$webhook_id;
2138 else
2139 $options['paypal_checkout_webhook_id'] = (string)$webhook_id;
2140
2141 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
2142
2143 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
2144
2145 if(self::paypal_checkout_is_sandbox())
2146 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_sandbox_webhook_id"] = (string)$webhook_id;
2147 else
2148 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_webhook_id"] = (string)$webhook_id;
2149 }
2150 }
2151 }
2152