PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260829
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260829
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 260829, at src/includes/classes/paypal-utilities.inc.php

2,713 lines 114.8 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 //260817 Allow signed Checkout data through Return or custom handlers, but never use a browser handoff to authenticate the PayPal Notify endpoint.
78 else if(empty($_GET["s2member_paypal_notify"]) && !empty($_GET["s2member_paypal_proxy"]) && $_GET["s2member_paypal_proxy"] === "paypal"
79 && array_key_exists("s2member_paypal_checkout_handoff", $_POST) && is_array($postvars = stripslashes_deep($_POST)))
80 {
81 if(!is_string($postvars["s2member_paypal_checkout_handoff"]) || $postvars["s2member_paypal_checkout_handoff"] === '')
82 return false;
83
84 $handoff = $postvars["s2member_paypal_checkout_handoff"];
85 unset($postvars["s2member_paypal_checkout_handoff"]);
86
87 //260817 Verify the complete PayPal Checkout browser-return payload before trusting any transaction or proxy metadata.
88 if(!self::paypal_checkout_return_handoff_verify($handoff, $postvars))
89 return false;
90
91 if(empty($postvars["s2member_paypal_proxy"]) || $postvars["s2member_paypal_proxy"] !== "paypal"
92 || (string)$_GET["s2member_paypal_proxy"] !== (string)$postvars["s2member_paypal_proxy"])
93 return false;
94
95 //260817 If proxy-use routing is supplied in the URL, it must be scalar and match the signed browser-return metadata.
96 if(!empty($_GET["s2member_paypal_proxy_use"]) && (!is_string($_GET["s2member_paypal_proxy_use"]) || empty($postvars["s2member_paypal_proxy_use"]) || $_GET["s2member_paypal_proxy_use"] !== (string)$postvars["s2member_paypal_proxy_use"]))
97 return false;
98
99 foreach($postvars as $key => $value)
100 if(preg_match("/^s2member_/", $key))
101 unset($postvars[$key]);
102
103 $postvars = self::paypal_postvars_back_compat($postvars);
104 $postvars = c_ws_plugin__s2member_utils_strings::trim_deep($postvars);
105 $postvars = self::paypal_postvars_utf8($postvars);
106
107 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => "paypal")), get_defined_vars());
108 }
109 else if(!empty($_REQUEST) && is_array($postvars = stripslashes_deep($_REQUEST)))
110 {
111 foreach($postvars as $key => $value)
112 if(preg_match("/^s2member_/", $key))
113 unset($postvars[$key]);
114
115 $postback = $postvars; // Copy.
116 $postback["cmd"] = "_notify-validate";
117
118 $postvars = self::paypal_postvars_back_compat($postvars);
119 $postvars = c_ws_plugin__s2member_utils_strings::trim_deep($postvars);
120
121 $postvars = self::paypal_postvars_utf8($postvars);
122 $endpoint = ($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "www.sandbox.paypal.com" : "www.paypal.com";
123
124 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())
125 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_REQUEST["s2member_paypal_proxy"])), get_defined_vars());
126
127 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"))
128 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_GET["s2member_paypal_proxy"])), get_defined_vars());
129
130 else if(trim(strtolower(c_ws_plugin__s2member_utils_urls::remote("https://".$endpoint."/cgi-bin/webscr", $postback, array("timeout" => 20)))) === "verified")
131 return apply_filters("ws_plugin__s2member_paypal_postvars", $postvars, get_defined_vars());
132
133 else return false;
134 }
135 else return false;
136 }
137 else // Else a custom conditional has been applied by Filters.
138 {
139 unset($__refs, $__v); // Housekeeping.
140 $postvars = self::paypal_postvars_back_compat($postvars);
141 return apply_filters("ws_plugin__s2member_paypal_postvars", $postvars, get_defined_vars());
142 }
143 }
144 /**
145 * Convert PayPal post vars to UTF-8 when PayPal reports a usable charset.
146 *
147 * @since 260603
148 *
149 * @return array PayPal post vars.
150 */
151 public static function paypal_postvars_utf8($postvars)
152 {
153 $postvars = (array) $postvars;
154
155 if(empty($postvars["charset"]))
156 return $postvars;
157
158 $charset = trim((string) $postvars["charset"]);
159 $charset = (strtolower($charset) === "gb2312") ? "GBK" : $charset;
160
161 foreach($postvars as &$value)
162 if(is_string($value))
163 {
164 $converted = false;
165
166 if(function_exists("mb_convert_encoding"))
167 {
168 try
169 {
170 $converted = @mb_convert_encoding($value, "UTF-8", $charset);
171 }
172 catch(ValueError $exception)
173 {
174 }
175 }
176
177 if($converted === false && function_exists("iconv"))
178 $converted = @iconv($charset, "UTF-8//IGNORE", $value);
179
180 if($converted !== false)
181 $value = $converted;
182 }
183 unset($value);
184
185 return $postvars;
186 }
187 /**
188 * Back compat. PayPal post vars.
189 *
190 * @since 170722 PayPal IPN variable change.
191 *
192 * @return array Updated PayPal IPN data.
193 *
194 * @see https://github.com/websharks/s2member/issues/1112
195 */
196 public static function paypal_postvars_back_compat($postvars)
197 {
198 $postvars = (array) $postvars;
199
200 foreach ($postvars as $_key => $_value) {
201 if (is_string($_key) && preg_match('/_?[0-9]+$/u', $_key)) {
202 $_old_key = preg_replace('/_?[0-9]+$/u', '', $_key);
203 if (!isset($postvars[$_old_key])) $postvars[$_old_key] = $_value;
204 }
205 } // unset($_key, $_old_key, $_value); // Housekeeping.
206
207 return $postvars; // w/ back. compat keys.
208 }
209 /**
210 * Normalizes PayPal Checkout browser-return variables for handoff signing.
211 *
212 * @package s2Member\PayPal
213 * @since 260817
214 *
215 * @param array $postvars Browser-return variables.
216 *
217 * @return string|bool Canonical payload string, else false.
218 */
219 public static function paypal_checkout_return_handoff_payload($postvars)
220 {
221 if(!is_array($postvars) || !$postvars)
222 return false;
223
224 $normalized = array();
225 foreach($postvars as $key => $value)
226 {
227 $key = (string)$key;
228
229 if($key === 's2member_paypal_checkout_handoff')
230 continue;
231 if(!is_scalar($value) && $value !== null)
232 return false;
233
234 $key = preg_replace('/\r\n|\r|\n/', "\r\n", $key);
235 $value = preg_replace('/\r\n|\r|\n/', "\r\n", (string)$value);
236 $normalized[$key] = $value;
237 }
238 if(!$normalized)
239 return false;
240
241 ksort($normalized, SORT_STRING);
242 return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
243 }
244 /**
245 * Generates the private signing key for PayPal Checkout browser-return handoffs.
246 *
247 * @package s2Member\PayPal
248 * @since 260817
249 *
250 * @return string Private signing key.
251 */
252 public static function paypal_checkout_return_handoff_key()
253 {
254 return hash_hmac('sha256', 's2member_paypal_checkout_return_handoff|'.self::paypal_proxy_key_gen(), c_ws_plugin__s2member_utils_encryption::key());
255 }
256 /**
257 * Creates a short-lived PayPal Checkout browser-return handoff.
258 *
259 * @package s2Member\PayPal
260 * @since 260817
261 *
262 * @param array $postvars Verified browser-return variables.
263 *
264 * @return string Signed handoff token, else an empty string on failure.
265 */
266 public static function paypal_checkout_return_handoff_create($postvars)
267 {
268 $payload = self::paypal_checkout_return_handoff_payload($postvars);
269
270 if($payload === false)
271 return '';
272
273 $expires = time() + HOUR_IN_SECONDS;
274 $signature = hash_hmac('sha256', $expires.'|'.$payload, self::paypal_checkout_return_handoff_key());
275
276 // The browser gets only a transaction-scoped signature; reusable server-side secrets remain private.
277 return $expires.'.'.$signature;
278 }
279 /**
280 * Verifies a PayPal Checkout browser-return handoff.
281 *
282 * @package s2Member\PayPal
283 * @since 260817
284 *
285 * @param string $handoff Signed handoff token.
286 * @param array $postvars Browser-return variables received by POST.
287 *
288 * @return bool TRUE if valid; else FALSE.
289 */
290 public static function paypal_checkout_return_handoff_verify($handoff, $postvars)
291 {
292 $handoff = trim((string)$handoff);
293
294 if(!preg_match('/^([0-9]{10,12})\.([a-f0-9]{64})$/D', $handoff, $matches))
295 return false;
296
297 $expires = (int)$matches[1];
298 $signature = (string)$matches[2];
299 $payload = self::paypal_checkout_return_handoff_payload($postvars);
300
301 if($payload === false || time() > $expires)
302 return false;
303
304 $expected = hash_hmac('sha256', $expires.'|'.$payload, self::paypal_checkout_return_handoff_key());
305 return hash_equals($expected, $signature);
306 }
307 /**
308 * Generates a PayPal Proxy Key, for simulated IPN responses.
309 *
310 * @package s2Member\PayPal
311 * @since 3.5
312 *
313 * @return string A Proxy Key. It's an MD5 Hash, 32 chars, URL-safe.
314 */
315 public static function paypal_proxy_key_gen()
316 {
317 global /* Multisite Networking. */ $current_site, $current_blog;
318
319 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
320 do_action("ws_plugin__s2member_before_paypal_proxy_key_gen", get_defined_vars());
321 unset($__refs, $__v);
322
323 if(is_multisite() && !is_main_site())
324 $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(strtolower($current_blog->domain.$current_blog->path), false, false));
325
326 else {
327 $host = ($GLOBALS['WS_PLUGIN__']['s2member']['o']['skip_ipn_domain_validation']) ? parse_url(home_url('/'), PHP_URL_HOST) : $_SERVER["HTTP_HOST"]; //250917
328 $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(preg_replace("/\:[0-9]+$/", "", strtolower((string) $host)), false, false));
329 }
330
331 return apply_filters("ws_plugin__s2member_paypal_proxy_key_gen", $key, get_defined_vars());
332 }
333 /**
334 * Acquires a short-lived dedupe lock.
335 *
336 * @package s2Member\PayPal
337 * @since 260406
338 *
339 * @param string $lock_option Dedupe lock option name.
340 * @param integer $lock_timeout Optional. Lock timeout in seconds.
341 *
342 * @return bool TRUE if lock acquired; else FALSE.
343 */
344 public static function dedupe_lock_acquire($lock_option, $lock_timeout = 900)
345 {
346 if(!$lock_option || !is_string($lock_option))
347 return FALSE;
348
349 if(add_option($lock_option, time(), '', 'no'))
350 return TRUE;
351
352 $lock_time = (int)get_option($lock_option, 0);
353
354 if($lock_time > 0 && (time() - $lock_time) >= abs($lock_timeout))
355 {
356 delete_option($lock_option);
357
358 if(add_option($lock_option, time(), '', 'no'))
359 return TRUE;
360 }
361 return FALSE;
362 }
363 /**
364 * Releases a short-lived dedupe lock.
365 *
366 * @package s2Member\PayPal
367 * @since 260406
368 *
369 * @param string $lock_option Dedupe lock option name.
370 *
371 * @return void
372 */
373 public static function dedupe_lock_release($lock_option)
374 {
375 if($lock_option && is_string($lock_option))
376 delete_option($lock_option);
377 }
378 /**
379 * Gets a dedupe done-marker time and expires it lazily when needed.
380 *
381 * @package s2Member\PayPal
382 * @since 260406
383 *
384 * @param string $done_option Dedupe done-marker option name.
385 * @param integer $done_ttl Optional. Marker TTL in seconds.
386 *
387 * @return integer UNIX timestamp if still valid; else 0.
388 */
389 public static function dedupe_done_time_get($done_option, $done_ttl = 0)
390 {
391 if(!$done_option || !is_string($done_option))
392 return 0;
393
394 $done_time = (int)get_option($done_option, 0);
395
396 if($done_time > 0 && $done_ttl > 0 && (time() - $done_time) >= abs($done_ttl))
397 {
398 delete_option($done_option);
399 return 0;
400 }
401 return $done_time;
402 }
403 /**
404 * Marks a dedupe done-marker as done.
405 *
406 * @package s2Member\PayPal
407 * @since 260406
408 *
409 * @param string $done_option Dedupe done-marker option name.
410 *
411 * @return void
412 */
413 public static function dedupe_done_mark($done_option)
414 {
415 if($done_option && is_string($done_option))
416 {
417 if(!add_option($done_option, time(), '', 'no'))
418 update_option($done_option, time(), false);
419 }
420 }
421 /**
422 * Occasionally cleans up expired dedupe markers.
423 *
424 * @package s2Member\PayPal
425 * @since 260406
426 *
427 * @param string $cleanup_transient Cleanup throttle transient name.
428 * @param array $markers Array of arrays, each with `prefix` and `ttl` keys.
429 * @param integer $throttle_ttl Optional. Cleanup throttle TTL in seconds.
430 *
431 * @return void
432 */
433 public static function dedupe_markers_cleanup($cleanup_transient, $markers = array(), $throttle_ttl = 21600)
434 {
435 if(!$cleanup_transient || !is_string($cleanup_transient) || !is_array($markers) || empty($markers))
436 return;
437
438 if(get_transient($cleanup_transient))
439 return;
440
441 global $wpdb;
442
443 foreach($markers as $marker)
444 if(!empty($marker['prefix']) && isset($marker['ttl']) && is_string($marker['prefix']))
445 {
446 $cutoff = (string)(time() - abs((int)$marker['ttl']));
447
448 $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."'");
449 }
450
451 set_transient($cleanup_transient, time(), abs((int)$throttle_ttl));
452 }
453 /**
454 * Calls upon the PayPal API, and returns the response.
455 *
456 * @package s2Member\PayPal
457 * @since 3.5
458 *
459 * @param array $post_vars An array of variables to send through the PayPal API call.
460 * @return array An array of variables returned by the PayPal API.
461 *
462 * @todo Optimize this routine with ``empty()`` and ``isset()``.
463 * @todo Possibly integrate this API: {@link http://msdn.microsoft.com/en-us/library/ff512417.aspx}.
464 */
465 public static function paypal_api_response($post_vars = FALSE)
466 {
467 global /* For Multisite support. */ $current_site, $current_blog;
468
469 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
470 do_action("ws_plugin__s2member_before_paypal_api_response", get_defined_vars());
471 unset($__refs, $__v);
472
473 $url = "https://".(($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "api-3t.sandbox.paypal.com" : "api-3t.paypal.com")."/nvp";
474
475 $post_vars = apply_filters("ws_plugin__s2member_paypal_api_post_vars", $post_vars, get_defined_vars());
476 $post_vars = (is_array($post_vars)) ? $post_vars : array();
477
478 $post_vars["VERSION"] = /* Configure the PayPal API version. */ "71.0";
479 $post_vars["USER"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_api_username"];
480 $post_vars["PWD"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_api_password"];
481 $post_vars["SIGNATURE"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_api_signature"];
482
483 foreach($post_vars as $_key => &$_value /* We need to clean these up. */)
484 $_value = c_ws_plugin__s2member_paypal_utilities::paypal_api_nv_cleanup($_key, $_value);
485 unset($_key, $_value);
486
487 $input_time = /* Record input/nvp for logging. */ date("D M j, Y g:i:s a T");
488
489 $nvp = trim(c_ws_plugin__s2member_utils_urls::remote($url, $post_vars, array("timeout" => 20)));
490
491 $output_time = /* Now record after output time. */ date("D M j, Y g:i:s a T");
492
493 wp_parse_str /* Parse NVP response. */($nvp, $response);
494 $response = c_ws_plugin__s2member_utils_strings::trim_deep($response);
495
496 if(!$response["ACK"] || !preg_match("/^(Success|SuccessWithWarning)$/i", $response["ACK"]))
497 {
498 if(strlen($response["L_ERRORCODE0"]) || $response["L_SHORTMESSAGE0"] || $response["L_LONGMESSAGE0"])
499 /* 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`. */
500 $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"], "."));
501
502 else // Else, generate an error messsage - so something is reported back to the Customer.
503 $response["__error"] = _x("Error. Please contact Support for assistance.", "s2member-front", "s2member");
504 }
505 $logt = c_ws_plugin__s2member_utilities::time_details ();
506 $logv = c_ws_plugin__s2member_utilities::ver_details();
507 $logm = c_ws_plugin__s2member_utilities::mem_details();
508 $log4 = $_SERVER["HTTP_HOST"].$_SERVER["REQUEST_URI"]."\nUser-Agent: ".@$_SERVER["HTTP_USER_AGENT"];
509 $log4 = (is_multisite() && !is_main_site()) ? ($_log4 = $current_blog->domain.$current_blog->path)."\n".$log4 : $log4;
510 $log2 = (is_multisite() && !is_main_site()) ? "paypal-api-4-".trim(preg_replace("/[^a-z0-9]/i", "-", $_log4), "-").".log" : "paypal-api.log";
511
512 if(isset($post_vars["ACCT"]) && strlen($post_vars["ACCT"]) > 4)
513 $post_vars["ACCT"] = str_repeat("*", strlen($post_vars["ACCT"]) - 4).substr($post_vars["ACCT"], -4);
514
515 if($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["gateway_debug_logs"])
516 if(is_dir($logs_dir = $GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["logs_dir"]))
517 if(is_writable($logs_dir) && c_ws_plugin__s2member_utils_logs::archive_oversize_log_files())
518 if(($log = "-------- Input vars: ( ".$input_time." ) --------\n".var_export($post_vars, true)."\n"))
519 if(($log .= "-------- Output string/vars: ( ".$output_time." ) --------\n".$nvp."\n".var_export($response, true)))
520 file_put_contents($logs_dir."/".$log2,
521 "LOG ENTRY: ".$logt . "\n" . $logv."\n".$logm."\n".$log4."\n".
522 c_ws_plugin__s2member_utils_logs::conceal_private_info($log)."\n\n",
523 FILE_APPEND);
524
525 return apply_filters("ws_plugin__s2member_paypal_api_response", c_ws_plugin__s2member_paypal_utilities::_paypal_api_response_filters($response), get_defined_vars());
526 }
527 /**
528 * A sort of callback function that Filters PayPal responses.
529 *
530 * Provides alternative explanations in some cases that require special attention.
531 *
532 * @package s2Member\PayPal
533 * @since 3.5
534 *
535 * @param array $response Expects an array of response variables returned by the PayPal API.
536 * @return array An array of variables returned by the PayPal API, after ``$response["__error"]`` is Filtered.
537 */
538 public static function _paypal_api_response_filters($response = FALSE)
539 {
540 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
541 do_action("_ws_plugin__s2member_before_paypal_api_response_filters", get_defined_vars());
542 unset($__refs, $__v);
543
544 if(!empty($response["__error"]) && !empty($response["L_ERRORCODE0"]))
545 {
546 if((int)$response["L_ERRORCODE0"] === 10422)
547 $response["__error"] = sprintf(_x("Error #%s. Transaction declined. Please use an alternate funding source.", "s2member-front", "s2member"), $response["L_ERRORCODE0"]);
548
549 else if((int)$response["L_ERRORCODE0"] === 10435)
550 $response["__error"] = sprintf(_x("Error #%s. Transaction declined. Express Checkout was NOT confirmed.", "s2member-front", "s2member"), $response["L_ERRORCODE0"]);
551
552 else if((int)$response["L_ERRORCODE0"] === 10417)
553 $response["__error"] = sprintf(_x("Error #%s. Transaction declined. Please use an alternate funding source.", "s2member-front", "s2member"), $response["L_ERRORCODE0"]);
554 }
555 return /* Filters already applied with: ``ws_plugin__s2member_paypal_api_response``. */ $response;
556 }
557 /**
558 * Cleans up values passed through PayPal NVP strings.
559 *
560 * @package s2Member\PayPal
561 * @since 121202
562 *
563 * @param string $key Expects a string value.
564 * @param string $value Expects a string value.
565 * @return string Cleaned string value.
566 */
567 public static function paypal_api_nv_cleanup($key = FALSE, $value = FALSE)
568 {
569 $value = (string)$value;
570 $value = preg_replace('/"/', "'", $value);
571
572 if(($key === "DESC" || $key === "BA_DESC" #
573 || preg_match("/^L_NAME[0-9]+$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_DESC$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_NAME[0-9]+$/", $key) #
574 || preg_match("/^L_BILLINGAGREEMENTDESCRIPTION[0-9]+$/", $key)) && strlen($value) > 60)
575 $value = substr($value, 0, 57)."...";
576
577 return apply_filters("ws_plugin__s2member_paypal_api_nv_cleanup", $value, get_defined_vars());
578 }
579 /**
580 * Calls upon the PayPal PayFlow API, and returns the response.
581 *
582 * @package s2Member\PayPal
583 * @since 120514
584 *
585 * @param array $post_vars An array of variables to send through the PayPal PayFlow API call.
586 * @return array An array of variables returned by the PayPal PayFlow API.
587 */
588 public static function paypal_payflow_api_response($post_vars = FALSE)
589 {
590 global /* For Multisite support. */ $current_site, $current_blog;
591
592 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
593 do_action("ws_plugin__s2member_before_paypal_payflow_api_response", get_defined_vars());
594 unset($__refs, $__v);
595
596 $url = "https://".(($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "pilot-payflowpro.paypal.com" : "payflowpro.paypal.com");
597
598 $post_vars = apply_filters("ws_plugin__s2member_paypal_payflow_api_post_vars", $post_vars, get_defined_vars());
599 $post_vars = (is_array($post_vars)) ? $post_vars : array();
600
601 $post_vars["VERBOSITY"] = "HIGH";
602 $post_vars["USER"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_username"];
603 $post_vars["PARTNER"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_partner"];
604 $post_vars["VENDOR"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_vendor"];
605 $post_vars["PWD"] = $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_payflow_api_password"];
606
607 foreach($post_vars as $_key => &$_value /* We need to clean these up. */)
608 $_value = c_ws_plugin__s2member_paypal_utilities::paypal_payflow_api_nv_cleanup($_key, $_value);
609 unset($_key, $_value);
610
611 $input_time = /* Record input/nvp for logging. */ date("D M j, Y g:i:s a T");
612
613 $nvp_post_vars = /* Initialize this to an empty string. */ "";
614 foreach($post_vars as $_key => $_value /* A ridiculous `text/namevalue` format. */)
615 $nvp_post_vars .= (($nvp_post_vars) ? "&" : "").$_key."[".strlen($_value)."]=".$_value;
616 unset($_key, $_value);
617
618 $nvp = trim(c_ws_plugin__s2member_utils_urls::remote($url, $nvp_post_vars, array("timeout" => 20, "headers" => array("Content-Type" => "text/namevalue"))));
619
620 $output_time = /* Now record after output time. */ date("D M j, Y g:i:s a T");
621
622 wp_parse_str /* Parse NVP response. */($nvp, $response);
623 $response = c_ws_plugin__s2member_utils_strings::trim_deep($response);
624
625 if($response["RESULT"] !== "0")
626 {
627 if(strlen($response["RESPMSG"]))
628 /* 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`. */
629 $response["__error"] = sprintf(_x('Error #%1$s. %2$s.', "s2member-front", "s2member"), $response["RESULT"], rtrim($response["RESPMSG"], "."));
630
631 else $response["__error"] = _x("Error. Please contact Support for assistance.", "s2member-front", "s2member");
632 }
633 else if(isset($response["TRXRESULT"]) && $response["TRXRESULT"] !== "0")
634 {
635 if(strlen($response["TRXRESPMSG"]))
636 /* 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`. */
637 $response["__error"] = sprintf(_x('Error #%1$s. %2$s.', "s2member-front", "s2member"), $response["TRXRESULT"], rtrim($response["TRXRESPMSG"], "."));
638
639 else $response["__error"] = _x("Error. Please contact Support for assistance.", "s2member-front", "s2member");
640 }
641
642 $logt = c_ws_plugin__s2member_utilities::time_details ();
643 $logv = c_ws_plugin__s2member_utilities::ver_details();
644 $logm = c_ws_plugin__s2member_utilities::mem_details();
645 $log4 = $_SERVER["HTTP_HOST"].$_SERVER["REQUEST_URI"]."\nUser-Agent: ".@$_SERVER["HTTP_USER_AGENT"];
646 $log4 = (is_multisite() && !is_main_site()) ? ($_log4 = $current_blog->domain.$current_blog->path)."\n".$log4 : $log4;
647 $log2 = (is_multisite() && !is_main_site()) ? "paypal-payflow-api-4-".trim(preg_replace("/[^a-z0-9]/i", "-", $_log4), "-").".log" : "paypal-payflow-api.log";
648
649 if(isset($post_vars["ACCT"]) && strlen($post_vars["ACCT"]) > 4)
650 $post_vars["ACCT"] = str_repeat("*", strlen($post_vars["ACCT"]) - 4).substr($post_vars["ACCT"], -4);
651
652 if($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["gateway_debug_logs"])
653 if(is_dir($logs_dir = $GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["logs_dir"]))
654 if(is_writable($logs_dir) && c_ws_plugin__s2member_utils_logs::archive_oversize_log_files())
655 if(($log = "-------- Input vars: ( ".$input_time." ) --------\n".$nvp_post_vars."\n".var_export($post_vars, true)."\n"))
656 if(($log .= "-------- Output string/vars: ( ".$output_time." ) --------\n".$nvp."\n".var_export($response, true)))
657 file_put_contents($logs_dir."/".$log2,
658 "LOG ENTRY: ".$logt . "\n" . $logv."\n".$logm."\n".$log4."\n".
659 c_ws_plugin__s2member_utils_logs::conceal_private_info($log)."\n\n",
660 FILE_APPEND);
661
662 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());
663 }
664 /**
665 * A sort of callback function that Filters Payflow responses.
666 *
667 * Provides alternative explanations in some cases that require special attention.
668 *
669 * @package s2Member\PayPal
670 * @since 120514
671 *
672 * @param array $response Expects an array of response variables returned by the Payflow API.
673 * @return array An array of variables returned by the Payflow API, after ``$response["__error"]`` is Filtered.
674 */
675 public static function _paypal_payflow_api_response_filters($response = FALSE)
676 {
677 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
678 do_action("_ws_plugin__s2member_before_paypal_payflow_api_response_filters", get_defined_vars());
679 unset($__refs, $__v);
680
681 if(!empty($response["__error"]) && !empty($response["HOSTCODE"]))
682 {
683 if((int)$response["HOSTCODE"] === 11452)
684 $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");
685 }
686
687 return /* Filters already applied with: ``ws_plugin__s2member_paypal_payflow_api_response``. */ $response;
688 }
689 /**
690 * Cleans up values passed through PayPal text/namevalue strings.
691 *
692 * @package s2Member\PayPal
693 * @since 121202
694 *
695 * @param string $key Expects a string value.
696 * @param string $value Expects a string value.
697 * @return string Cleaned string value.
698 */
699 public static function paypal_payflow_api_nv_cleanup($key = FALSE, $value = FALSE)
700 {
701 $value = (string)$value;
702 $value = preg_replace('/"/', "'", $value);
703
704 if(($key === "DESC" || $key === "ORDERDESC" || $key === "BA_DESC" || $key === "BA_CUSTOM" #
705 || preg_match("/^L_NAME[0-9]+$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_DESC$/", $key) || preg_match("/^PAYMENTREQUEST_[0-9]+_NAME[0-9]+$/", $key) #
706 || preg_match("/^L_BILLINGAGREEMENTDESCRIPTION[0-9]+$/", $key)) && strlen($value) > 60)
707 $value = substr($value, 0, 57)."...";
708
709 return apply_filters("ws_plugin__s2member_paypal_payflow_api_nv_cleanup", $value, get_defined_vars());
710 }
711 /**
712 * Converts a term `D|W|M|Y` into PayPal Pro format.
713 *
714 * @package s2Member\PayPal
715 * @since 3.5
716 *
717 * @param string $term Expects one of `D|W|M|Y`.
718 * @return bool|str A full singular description of the term *( i.e., `Day|Week|Month|Year` )*, else false.
719 */
720 public static function paypal_pro_term($term = FALSE)
721 {
722 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
723 do_action("ws_plugin__s2member_before_paypal_pro_term", get_defined_vars());
724 unset($__refs, $__v);
725
726 $paypal_pro_terms = array("D" => "Day", "W" => "Week", "M" => "Month", "Y" => "Year");
727
728 $pro_term = (!empty($paypal_pro_terms[strtoupper($term)])) ? $paypal_pro_terms[strtoupper($term)] : false;
729
730 return apply_filters("ws_plugin__s2member_paypal_pro_term", $pro_term, get_defined_vars());
731 }
732 /**
733 * Converts a term `D|W|M|Y` into Payflow format.
734 *
735 * @package s2Member\PayPal
736 * @since 120514
737 *
738 * @param string $term Expects one of `D|W|M|Y`.
739 * @param string $period Expects a numeric value.
740 * @return bool|str A full singular description of the term *( i.e., `DAY|WEEK|BIWK|MONT|QTER|SMYR|YEAR` )*, else false.
741 *
742 * @note Payflow unfortunately does NOT support daily and/or bi-monthly billing.
743 */
744 public static function paypal_payflow_term($term = FALSE, $period = FALSE)
745 {
746 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
747 do_action("ws_plugin__s2member_before_paypal_payflow_term", get_defined_vars());
748 unset($__refs, $__v);
749
750 $paypal_payflow_terms = array("D" => "DAY", "W" => "WEEK", "M" => "MONT", "Y" => "YEAR");
751
752 $payflow_term = (!empty($paypal_payflow_terms[strtoupper($term)])) ? $paypal_payflow_terms[strtoupper($term)] : false;
753
754 if($payflow_term === "WEEK" && $period === "2")
755 $payflow_term = "BIWK";
756
757 else if($payflow_term === "MONT" && $period === "3")
758 $payflow_term = "QTER";
759
760 else if($payflow_term === "MONT" && $period === "6")
761 $payflow_term = "SMYR";
762
763 return apply_filters("ws_plugin__s2member_paypal_payflow_term", $payflow_term, get_defined_vars());
764 }
765 /**
766 * Converts a term `Day|Week|Month|Year` into PayPal Standard format.
767 *
768 * @package s2Member\PayPal
769 * @since 3.5
770 *
771 * @param string $term Expects one of `Day|Week|Month|Year`.
772 * @return bool|str A term code *( i.e., `D|W|M|Y` )*, else false.
773 */
774 public static function paypal_std_term($term = FALSE)
775 {
776 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
777 do_action("ws_plugin__s2member_before_paypal_std_term", get_defined_vars());
778 unset($__refs, $__v);
779
780 $paypal_std_terms = array("DAY" => "D", "WEEK" => "W", "MONTH" => "M", "YEAR" => "Y");
781
782 $std_term = (!empty($paypal_std_terms[strtoupper($term)])) ? $paypal_std_terms[strtoupper($term)] : false;
783
784 return apply_filters("ws_plugin__s2member_paypal_std_term", $std_term, get_defined_vars());
785 }
786 /**
787 * Get `subscr_id` from either an array with `recurring_payment_id|subscr_id`, or use an existing string.
788 *
789 * @package s2Member\PayPal
790 * @since 3.5
791 *
792 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
793 * @return str|bool A `subscr_id` string if non-empty, else false.
794 */
795 public static function paypal_pro_subscr_id($array_or_string = FALSE)
796 {
797 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
798 do_action("ws_plugin__s2member_before_paypal_pro_subscr_id", get_defined_vars());
799 unset($__refs, $__v);
800
801 if(is_array($array = $array_or_string) && !empty($array["subscr_id"]))
802 $subscr_id = trim($array["subscr_id"]);
803
804 else if(is_array($array = $array_or_string) && !empty($array["recurring_payment_id"]))
805 $subscr_id = trim($array["recurring_payment_id"]);
806
807 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
808 && ($ipn_signup_var_subscr_id = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("subscr_id", FALSE, $array["mp_id"])))
809 $subscr_id = trim($ipn_signup_var_subscr_id); // Found w/ a Billing Agreement ID.
810
811 else if(is_string($string = $array_or_string) && !empty($string)) $subscr_id = trim($string);
812
813 return apply_filters("ws_plugin__s2member_paypal_pro_subscr_id", ((!empty($subscr_id)) ? $subscr_id : false), get_defined_vars());
814 }
815 /**
816 * Get `item_number` from either an array with `PROFILEREFERENCE|rp_invoice_id|item_number1|item_number`, or use an existing string.
817 *
818 * @package s2Member\PayPal
819 * @since 3.5
820 *
821 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
822 * If it's a string, we make sure it is a valid `level:ccaps:eotper` or `sp:ids:expiration` combination.
823 * @return str|bool An `item_number` string if non-empty, else false.
824 */
825 public static function paypal_pro_item_number($array_or_string = FALSE)
826 {
827 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
828 do_action("ws_plugin__s2member_before_paypal_pro_item_number", get_defined_vars());
829 unset($__refs, $__v);
830
831 if(is_array($array_or_string) && isset($array_or_string["PROFILENAME"]) /* Payflow. */)
832 $array_or_string["PROFILEREFERENCE"] = $array_or_string["PROFILENAME"];
833
834 if(is_array($array = $array_or_string) && !empty($array["item_number"]))
835 $_item_number = trim($array["item_number"]);
836
837 else if(is_array($array = $array_or_string) && !empty($array["item_number1"]))
838 $_item_number = trim($array["item_number1"]);
839
840 else if(is_array($array = $array_or_string) && (!empty($array["PROFILEREFERENCE"]) || !empty($array["rp_invoice_id"])))
841 list($_reference, $_domain, $_item_number) = array_map("trim", preg_split("/~/", ((!empty($array["PROFILEREFERENCE"])) ? $array["PROFILEREFERENCE"] : $array["rp_invoice_id"]), 3));
842
843 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
844 && ($ipn_signup_var_item_number = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("item_number", FALSE, $array["mp_id"])))
845 $_item_number = trim($ipn_signup_var_item_number); // Found w/ a Billing Agreement ID.
846
847 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit item_number on cancellations).
848 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
849 && ($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"]))))
850 $_item_number = trim($ipn_signup_var_item_number); // Found w/ a Subscription ID.
851
852 else if(is_string($string = $array_or_string) && !empty($string)) $_item_number = trim($string);
853
854 if(!empty($_item_number) && preg_match($GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["membership_item_number_w_or_wo_level_regex"], $_item_number))
855 $item_number = $_item_number;
856
857 else if(!empty($_item_number) && preg_match($GLOBALS["WS_PLUGIN__"]["s2member"]["c"]["sp_access_item_number_regex"], $_item_number))
858 $item_number = $_item_number;
859
860 return apply_filters("ws_plugin__s2member_paypal_pro_item_number", ((!empty($item_number)) ? $item_number : false), get_defined_vars());
861 }
862 /**
863 * Get `item_name` from either an array with `product_name|item_name1|item_name`, or use an existing string.
864 *
865 * @package s2Member\PayPal
866 * @since 3.5
867 *
868 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
869 * @return str|bool An `item_name` string if non-empty, else false.
870 */
871 public static function paypal_pro_item_name($array_or_string = FALSE)
872 {
873 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
874 do_action("ws_plugin__s2member_before_paypal_pro_item_name", get_defined_vars());
875 unset($__refs, $__v);
876
877 if(is_array($array = $array_or_string) && !empty($array["item_name"]))
878 $item_name = trim($array["item_name"]);
879
880 else if(is_array($array = $array_or_string) && !empty($array["item_name1"]))
881 $item_name = trim($array["item_name1"]);
882
883 else if(is_array($array = $array_or_string) && !empty($array["product_name"]))
884 $item_name = trim($array["product_name"]);
885
886 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
887 && ($ipn_signup_var_item_name = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("item_name", FALSE, $array["mp_id"])))
888 $item_name = trim($ipn_signup_var_item_name); // Found w/ a Billing Agreement ID.
889
890 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit item_name on cancellations).
891 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
892 && ($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"]))))
893 $item_name = trim($ipn_signup_var_item_name); // Found w/ a Subscription ID.
894
895 else if(is_string($string = $array_or_string) && !empty($string)) $item_name = trim($string);
896
897 return apply_filters("ws_plugin__s2member_paypal_pro_item_name", ((!empty($item_name)) ? $item_name : false), get_defined_vars());
898 }
899 /**
900 * Get `period1` from either an array with `PROFILEREFERENCE|rp_invoice_id|period1`, or use an existing string.
901 *
902 * This will also convert `1 Day`, into `1 D`, and so on.
903 * This will also convert `1 SemiMonth`, into `2 W`, and so on.
904 *
905 * @package s2Member\PayPal
906 * @since 3.5
907 *
908 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
909 * If it's a string, we make sure it is a valid `period term` combination.
910 * @param string $default Optional. Value if unavailable. Defaults to `0 D`.
911 * @return string A `period1` string if possible, or defaults to `0 D`.
912 */
913 public static function paypal_pro_period1($array_or_string = FALSE, $default = "0 D")
914 {
915 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
916 do_action("ws_plugin__s2member_before_paypal_pro_period1", get_defined_vars());
917 unset($__refs, $__v);
918
919 if(is_array($array_or_string) && isset($array_or_string["PROFILENAME"]) /* Payflow. */)
920 $array_or_string["PROFILEREFERENCE"] = $array_or_string["PROFILENAME"];
921
922 if(is_array($array = $array_or_string) && !empty($array["period1"])) $_period1 = trim($array["period1"]);
923
924 else if(is_array($array = $array_or_string) && (!empty($array["PROFILEREFERENCE"]) || !empty($array["rp_invoice_id"])))
925 {
926 list($_reference, $_domain, $_item_number) = array_map("trim", preg_split("/~/", ((!empty($array["PROFILEREFERENCE"])) ? $array["PROFILEREFERENCE"] : $array["rp_invoice_id"]), 3));
927 list($_start_time, $_period1, $_period3) = array_map("trim", preg_split("/\:/", $_reference, 3));
928 }
929 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
930 && ($ipn_signup_var_period1 = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("period1", FALSE, $array["mp_id"])))
931 $_period1 = trim($ipn_signup_var_period1); // Found w/ a Billing Agreement ID.
932
933 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit period1 on cancellations).
934 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
935 && ($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"]))))
936 $_period1 = trim($ipn_signup_var_period1); // Found w/ a Subscription ID.
937
938 else if(is_string($string = $array_or_string) && !empty($string)) $_period1 = trim($string);
939
940 if /* Were we able to get a `period1` string? */(!empty($_period1))
941 {
942 list($num, $span) = array_map("trim", preg_split("/ /", $_period1, 2));
943
944 if(strtoupper($span) === "SEMIMONTH" && is_numeric($num) && $num >= 1)
945 { $num = "2"; $span = "W"; }
946
947 if /* To Standard format. */(strlen($span) !== 1)
948 $span = c_ws_plugin__s2member_paypal_utilities::paypal_std_term($span);
949
950 $span = (preg_match("/^[DWMY]$/i", $span)) ? $span : "";
951 $num = ($span && is_numeric($num) && $num >= 0) ? $num : "";
952
953 $period1 = ($num && $span) ? $num." ".strtoupper($span) : $default;
954
955 return apply_filters("ws_plugin__s2member_paypal_pro_period1", $period1, get_defined_vars());
956 }
957 else return apply_filters("ws_plugin__s2member_paypal_pro_period1", $default, get_defined_vars());
958 }
959 /**
960 * Get `period3` from either an array with `PROFILEREFERENCE|rp_invoice_id|period3`, or use an existing string.
961 *
962 * This will also convert `1 Day`, into `1 D`, and so on.
963 * This will also convert `1 SemiMonth`, into `2 W`, and so on.
964 * The Regular Period can never be less than 1 day ( `1 D` ).
965 *
966 * @package s2Member\PayPal
967 * @since 3.5
968 *
969 * @param string|array $array_or_string Either an array of PayPal post vars, or a string.
970 * If it's a string, we make sure it is a valid `period term` combination.
971 * @param string $default Optional. Value if unavailable. Defaults to `1 D`.
972 * @return string A `period3` string if possible, or defaults to `1 D`.
973 */
974 public static function paypal_pro_period3($array_or_string = FALSE, $default = "1 D")
975 {
976 foreach(array_keys(get_defined_vars())as$__v)$__refs[$__v]=&$$__v;
977 do_action("ws_plugin__s2member_before_paypal_pro_period3", get_defined_vars());
978 unset($__refs, $__v);
979
980 if(is_array($array_or_string) && isset($array_or_string["PROFILENAME"]) /* Payflow. */)
981 $array_or_string["PROFILEREFERENCE"] = $array_or_string["PROFILENAME"];
982
983 if(is_array($array = $array_or_string) && !empty($array["period3"])) $_period3 = trim($array["period3"]);
984
985 else if(is_array($array = $array_or_string) && (!empty($array["PROFILEREFERENCE"]) || !empty($array["rp_invoice_id"])))
986 {
987 list($_reference, $_domain, $_item_number) = array_map("trim", preg_split("/~/", ((!empty($array["PROFILEREFERENCE"])) ? $array["PROFILEREFERENCE"] : $array["rp_invoice_id"]), 3));
988 list($_start_time, $_period1, $_period3) = array_map("trim", preg_split("/\:/", $_reference, 3));
989 }
990 else if(is_array($array = $array_or_string) && !empty($array["mp_id"])
991 && ($ipn_signup_var_period3 = c_ws_plugin__s2member_utils_users::get_user_ipn_signup_var("period3", FALSE, $array["mp_id"])))
992 $_period3 = trim($ipn_signup_var_period3); // Found w/ a Billing Agreement ID.
993
994 //260213 Backfill from stored IPN Signup Vars using recurring_payment_id/subscr_id (PayPal may omit period3 on cancellations).
995 else if(is_array($array = $array_or_string) && (!empty($array["recurring_payment_id"]) || !empty($array["subscr_id"]))
996 && ($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"]))))
997 $_period3 = trim($ipn_signup_var_period3); // Found w/ a Subscription ID.
998
999 else if(is_string($string = $array_or_string) && !empty($string)) $_period3 = trim($string);
1000
1001 if /* Were we able to get a `period3` string? */(!empty($_period3))
1002 {
1003 list($num, $span) = array_map("trim", preg_split("/ /", $_period3, 2));
1004
1005 if(strtoupper($span) === "SEMIMONTH" && is_numeric($num) && $num >= 1)
1006 { $num = "2"; $span = "W"; }
1007
1008 if /* To Standard format. */(strlen($span) !== 1)
1009 $span = c_ws_plugin__s2member_paypal_utilities::paypal_std_term($span);
1010
1011 $span = (preg_match("/^[DWMY]$/i", $span)) ? $span : "";
1012 $num = ($span && is_numeric($num) && $num >= 0) ? $num : "";
1013
1014 $period3 = ($num && $span) ? $num." ".strtoupper($span) : $default;
1015
1016 return apply_filters("ws_plugin__s2member_paypal_pro_period3", $period3, get_defined_vars());
1017 }
1018 else return apply_filters("ws_plugin__s2member_paypal_pro_period3", $default, get_defined_vars());
1019 }
1020
1021 //260106 PayPal Checkout
1022 /**
1023 * Returns true when PayPal Checkout is enabled and required credentials exist.
1024 *
1025 * @since 260106
1026 *
1027 * @return bool
1028 */
1029 public static function paypal_checkout_is_enabled()
1030 {
1031 if(empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_enable']))
1032 return false;
1033
1034 if(self::paypal_checkout_is_sandbox())
1035 return (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id'])
1036 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_secret']));
1037
1038 return (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'])
1039 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_secret']));
1040 }
1041
1042 /**
1043 * Returns true when PayPal Checkout webhook processing can operate.
1044 *
1045 * This is intentionally decoupled from `paypal_checkout_enable` so that:
1046 * - sites can switch new sales back to PayPal Standard
1047 * - while still processing webhooks for existing Checkout subscriptions
1048 *
1049 * @since 260218
1050 *
1051 * @return bool
1052 */
1053 public static function paypal_checkout_webhook_processing_is_enabled()
1054 {
1055 // Full Checkout enabled? Then yes.
1056 if(self::paypal_checkout_is_enabled())
1057 return true;
1058
1059 // Otherwise: allow webhook processing when creds + webhook id exist (either env).
1060 $live_ready = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'])
1061 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_secret'])
1062 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_webhook_id']));
1063
1064 $sandbox_ready = (!empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id'])
1065 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_secret'])
1066 && !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_webhook_id']));
1067
1068 return ($live_ready || $sandbox_ready);
1069 }
1070
1071 /**
1072 * Returns true when PayPal Checkout is in sandbox mode.
1073 *
1074 * @since 260101
1075 *
1076 * @return bool
1077 */
1078 public static function paypal_checkout_is_sandbox()
1079 {
1080 return !empty($GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox']);
1081 }
1082
1083 /**
1084 * Returns the PayPal REST API base URL for the active environment.
1085 *
1086 * @since 260101
1087 *
1088 * @return string
1089 */
1090 public static function paypal_checkout_api_base()
1091 {
1092 return (self::paypal_checkout_is_sandbox())
1093 ? 'https://api-m.sandbox.paypal.com'
1094 : 'https://api-m.paypal.com';
1095 }
1096
1097 /**
1098 * Returns PayPal Checkout REST credentials for the active environment.
1099 *
1100 * @since 260101
1101 *
1102 * @return array{client_id:string,secret:string}
1103 */
1104 public static function paypal_checkout_creds()
1105 {
1106 if(self::paypal_checkout_is_sandbox())
1107 return array(
1108 'client_id' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id'],
1109 'secret' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_secret'],
1110 );
1111
1112 return array(
1113 'client_id' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'],
1114 'secret' => (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_secret'],
1115 );
1116 }
1117
1118 /**
1119 * Returns a stable short id derived from the PayPal Client ID (per env).
1120 *
1121 * Used to bucket caches in:
1122 * - $options['paypal_checkout_cache'][$cred_id][...]
1123 *
1124 * @since 260127
1125 *
1126 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
1127 *
1128 * @return string 12-char hash prefix or empty string.
1129 */
1130 public static function paypal_checkout_cred_id($env = '')
1131 {
1132 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1133
1134 $client_id = ($env === 'sandbox')
1135 ? (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox_client_id']
1136 : (string)$GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_client_id'];
1137
1138 $client_id = trim($client_id);
1139 if(!$client_id)
1140 return '';
1141
1142 return substr(md5(strtolower($client_id)), 0, 12);
1143 }
1144
1145 /**
1146 * Returns a cached PayPal REST access token (fetches a new one when needed).
1147 *
1148 * Stored in a transient keyed by environment.
1149 *
1150 * @since 260101
1151 *
1152 * @return string Access token or empty string on failure.
1153 */
1154 public static function paypal_checkout_access_token()
1155 {
1156 $transient = self::paypal_checkout_is_sandbox() ? 's2m_ppco_at_sandbox' : 's2m_ppco_at_live';
1157
1158 if(($cached = get_transient($transient)) && is_array($cached) && !empty($cached['access_token']))
1159 return $cached['access_token'];
1160
1161 $creds = self::paypal_checkout_creds();
1162 $client_id = (string)$creds['client_id'];
1163 $secret = (string)$creds['secret'];
1164 $client_len_hash = strlen($client_id).'_'.substr(hash('sha256', $client_id), 0, 16);
1165 $secret_len_hash = strlen($secret).'_'.substr(hash('sha256', $secret), 0, 16);
1166
1167 if(!$client_id || !$secret)
1168 return '';
1169
1170 $url = self::paypal_checkout_api_base().'/v1/oauth2/token';
1171 $body = 'grant_type=client_credentials';
1172
1173 $args = array(
1174 'timeout' => 20,
1175 'headers' => array(
1176 'Authorization' => 'Basic '.base64_encode($client_id.':'.$secret),
1177 'Content-Type' => 'application/x-www-form-urlencoded',
1178 'Accept' => 'application/json',
1179 'Accept-Language' => 'en_US',
1180 ),
1181 );
1182
1183 $r = c_ws_plugin__s2member_utils_urls::remote($url, $body, $args, true);
1184
1185 if(!is_array($r))
1186 $r = array('code' => 0, 'message' => 'request_failed', 'headers' => array(), 'body' => '');
1187
1188 if(!isset($r['code']) || (int)$r['code'] !== 200)
1189 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1190 'ppco' => 'oauth',
1191 'event' => 'token_failed',
1192 'env_setting' => self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live',
1193 'client_len_hash' => $client_len_hash,
1194 'secret_len_hash' => $secret_len_hash,
1195 'url' => $url,
1196 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
1197 'message' => !empty($r['message']) ? (string)$r['message'] : '',
1198 'body' => !empty($r['body']) ? $r['body'] : '',
1199 ));
1200
1201 $data = array();
1202 if(!empty($r['body']) && is_string($r['body']))
1203 $data = json_decode($r['body'], true);
1204
1205 if(!empty($data['access_token']) && !empty($data['expires_in']))
1206 {
1207 $ttl = max(60, (int)$data['expires_in'] - 60);
1208 set_transient($transient, array('access_token' => $data['access_token']), $ttl);
1209
1210 return $data['access_token'];
1211 }
1212 return '';
1213 }
1214
1215 /**
1216 * Tests PayPal Checkout REST credentials for the selected environment.
1217 *
1218 * Forces a real access token request (clears cached token transient first).
1219 * Intended for admin UI diagnostics during beta/QA.
1220 *
1221 * @since 260115
1222 *
1223 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
1224 *
1225 * @return bool True if an access token was obtained; otherwise false.
1226 */
1227 public static function paypal_checkout_creds_test($env = '')
1228 {
1229 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1230
1231 $orig_sandbox = self::paypal_checkout_is_sandbox();
1232 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = ($env === 'sandbox') ? '1' : '0';
1233
1234 // Force a real token request (ignore cached transient).
1235 $transient = self::paypal_checkout_is_sandbox() ? 's2m_ppco_at_sandbox' : 's2m_ppco_at_live';
1236 delete_transient($transient);
1237
1238 $token = self::paypal_checkout_access_token();
1239 $ok = ($token) ? true : false;
1240
1241 $creds = self::paypal_checkout_creds();
1242 $client_len_hash = strlen((string)$creds['client_id']).'_'.substr(hash('sha256', (string)$creds['client_id']), 0, 16);
1243 $secret_len_hash = strlen((string)$creds['secret']).'_'.substr(hash('sha256', (string)$creds['secret']), 0, 16);
1244
1245 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1246 'ppco' => 'checkout',
1247 'event' => $ok ? 'creds_test_ok' : 'creds_test_failed',
1248 'env_setting' => $env,
1249 'client_len_hash' => $client_len_hash,
1250 'secret_len_hash' => $secret_len_hash,
1251 ));
1252
1253 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1254 return $ok;
1255 }
1256
1257 /**
1258 * Clears PayPal Checkout plan/product caches (per environment) and the cached access token.
1259 *
1260 * Cache storage:
1261 * - $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']
1262 * - $options['paypal_checkout_cache'][$cred_id][$env]['product_ids']
1263 *
1264 * Intended for QA and for situations where a cached plan/product id becomes stale
1265 * due to changes in billing attributes.
1266 *
1267 * @since 260127
1268 *
1269 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
1270 *
1271 * @return bool
1272 */
1273 public static function paypal_checkout_clear_cache($env = '')
1274 {
1275 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1276
1277 $orig_sandbox = self::paypal_checkout_is_sandbox();
1278 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = ($env === 'sandbox') ? '1' : '0';
1279
1280 $cred_id = self::paypal_checkout_cred_id($env);
1281
1282 $options = get_option('ws_plugin__s2member_options');
1283 if(!is_array($options))
1284 $options = array();
1285
1286 // New cache format: $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids'|'product_ids'].
1287 if($cred_id && !empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])
1288 && !empty($options['paypal_checkout_cache'][$cred_id]) && is_array($options['paypal_checkout_cache'][$cred_id])
1289 && !empty($options['paypal_checkout_cache'][$cred_id][$env]) && is_array($options['paypal_checkout_cache'][$cred_id][$env]))
1290 {
1291 if(isset($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']))
1292 unset($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']);
1293
1294 if(isset($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']))
1295 unset($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']);
1296
1297 if(empty($options['paypal_checkout_cache'][$cred_id][$env]))
1298 unset($options['paypal_checkout_cache'][$cred_id][$env]);
1299
1300 if(empty($options['paypal_checkout_cache'][$cred_id]))
1301 unset($options['paypal_checkout_cache'][$cred_id]);
1302 }
1303
1304 // Delete legacy cache keys (no migration; just remove).
1305 if(isset($options['paypal_checkout_plan_ids']))
1306 unset($options['paypal_checkout_plan_ids']);
1307
1308 if(isset($options['paypal_checkout_product_ids']))
1309 unset($options['paypal_checkout_product_ids']);
1310
1311 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
1312
1313 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
1314
1315 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]['paypal_checkout_cache'] = (!empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])) ? $options['paypal_checkout_cache'] : array();
1316
1317 // Clear cached access token for this env too.
1318 $transient = self::paypal_checkout_is_sandbox() ? 's2m_ppco_at_sandbox' : 's2m_ppco_at_live';
1319 delete_transient($transient);
1320
1321 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1322 'ppco' => 'checkout',
1323 'event' => 'cleared_cache',
1324 'env_setting' => $env,
1325 'cred_id' => $cred_id,
1326 ));
1327
1328 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
1329 return true;
1330 }
1331
1332 /**
1333 * Performs a PayPal REST API request using the current environment access token.
1334 *
1335 * @since 260101
1336 *
1337 * @param string $method HTTP method.
1338 * @param string $path API path beginning with '/'.
1339 * @param mixed $body Array/object body or raw string; null for no body.
1340 * @param array $headers Additional headers.
1341 *
1342 * @return array Response array from c_ws_plugin__s2member_utils_urls::remote().
1343 */
1344 public static function paypal_checkout_api_request($method = 'GET', $path = '/', $body = null, $headers = array())
1345 {
1346 $method = strtoupper((string)$method);
1347 $url = self::paypal_checkout_api_base().$path;
1348
1349 $args = array(
1350 'timeout' => 20,
1351 'method' => $method,
1352 'headers' => array_merge(array(
1353 'Authorization' => 'Bearer '.self::paypal_checkout_access_token(),
1354 'Content-Type' => 'application/json',
1355 'Accept' => 'application/json',
1356 ), (array)$headers),
1357 );
1358
1359 if($body !== null)
1360 {
1361 $encoded = is_string($body) ? $body : wp_json_encode($body);
1362 $args['body'] = ($encoded !== false) ? $encoded : '{}';
1363 }
1364
1365 $r = c_ws_plugin__s2member_utils_urls::remote($url, false, $args, true);
1366
1367 if(!is_array($r))
1368 $r = array('code' => 0, 'message' => 'request_failed', 'headers' => array(), 'body' => '');
1369
1370 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1371 'ppco' => 'api_request',
1372 'env_setting' => self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live',
1373 'method' => $method,
1374 'path' => $path,
1375 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
1376 'message' => !empty($r['message']) ? (string)$r['message'] : '',
1377 'body' => !empty($r['body']) ? $r['body'] : '',
1378 ));
1379
1380 return $r;
1381 }
1382
1383 /**
1384 * Retrieves a PayPal Checkout order for validation or capture recovery.
1385 *
1386 * @since 260817
1387 *
1388 * @param string $order_id PayPal Checkout order id.
1389 *
1390 * @return array Decoded order response, with __code/__body added; __error on failure.
1391 */
1392 public static function paypal_checkout_order_details($order_id = '')
1393 {
1394 $order_id = trim((string)$order_id);
1395
1396 if(!$order_id)
1397 return array('__error' => 'missing_order_id', '__code' => 0, '__body' => '');
1398
1399 $r = self::paypal_checkout_api_request('GET', '/v2/checkout/orders/'.rawurlencode($order_id));
1400
1401 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1402 $body = !empty($r['body']) ? (string)$r['body'] : '';
1403 $data = ($body) ? json_decode($body, true) : array();
1404 $data = is_array($data) ? $data : array();
1405
1406 $data['__code'] = $code;
1407 $data['__body'] = $body;
1408
1409 if(!($code >= 200 && $code <= 299) || empty($data['id']))
1410 $data['__error'] = 'order_details_failed';
1411
1412 return $data;
1413 }
1414 /**
1415 * Validates a PayPal Checkout order against the server-side purchase token.
1416 *
1417 * @since 260817
1418 *
1419 * @param array $order PayPal order representation.
1420 * @param string $order_id Expected PayPal order id.
1421 * @param array $token Signed/validated purchase token.
1422 *
1423 * @return string Empty string if valid; otherwise a stable error code.
1424 */
1425 public static function paypal_checkout_order_validation_error($order = array(), $order_id = '', $token = array())
1426 {
1427 if(!is_array($order) || empty($order['id']))
1428 return 'order_missing';
1429 if($order_id && (string)$order['id'] !== (string)$order_id)
1430 return 'order_id_mismatch';
1431 if(empty($order['intent']) || strtoupper((string)$order['intent']) !== 'CAPTURE')
1432 return 'order_intent_mismatch';
1433 if(empty($order['purchase_units'][0]) || !is_array($order['purchase_units'][0]))
1434 return 'order_purchase_unit_missing';
1435
1436 $pu = $order['purchase_units'][0];
1437 $invoice = isset($pu['invoice_id']) ? (string)$pu['invoice_id'] : '';
1438 $amount = isset($pu['amount']['value']) ? (string)$pu['amount']['value'] : '';
1439 $cc = isset($pu['amount']['currency_code']) ? strtoupper((string)$pu['amount']['currency_code']) : '';
1440
1441 if(!empty($token['invoice']) && $invoice !== (string)$token['invoice'])
1442 return 'order_invoice_mismatch';
1443 if(!empty($token['amount']) && (!$amount || number_format((float)$amount, 2, '.', '') !== number_format((float)$token['amount'], 2, '.', '')))
1444 return 'order_amount_mismatch';
1445 if(!empty($token['cc']) && $cc !== strtoupper((string)$token['cc']))
1446 return 'order_currency_mismatch';
1447
1448 $custom = !empty($token['custom']) ? (string)$token['custom'] : '';
1449 if($custom && strlen($custom) <= 127 && (!isset($pu['custom_id']) || (string)$pu['custom_id'] !== $custom))
1450 return 'order_custom_mismatch';
1451
1452 return '';
1453 }
1454 /**
1455 * Validates that a PayPal Checkout order contains a completed capture for the purchase token.
1456 *
1457 * @since 260817
1458 *
1459 * @param array $order PayPal order representation.
1460 * @param string $order_id Expected PayPal order id.
1461 * @param array $token Signed/validated purchase token.
1462 *
1463 * @return string Empty string if complete and valid; otherwise a stable error code.
1464 */
1465 public static function paypal_checkout_order_completion_error($order = array(), $order_id = '', $token = array())
1466 {
1467 if(($error = self::paypal_checkout_order_validation_error($order, $order_id, $token)))
1468 return $error;
1469 if(empty($order['status']) || strtoupper((string)$order['status']) !== 'COMPLETED')
1470 return 'order_not_completed';
1471
1472 $capture = (!empty($order['purchase_units'][0]['payments']['captures'][0]) && is_array($order['purchase_units'][0]['payments']['captures'][0])) ? $order['purchase_units'][0]['payments']['captures'][0] : array();
1473 if(empty($capture['id']) || empty($capture['status']) || strtoupper((string)$capture['status']) !== 'COMPLETED')
1474 return 'capture_missing_fields';
1475
1476 $amount = !empty($capture['amount']['value']) ? (string)$capture['amount']['value'] : '';
1477 $cc = !empty($capture['amount']['currency_code']) ? strtoupper((string)$capture['amount']['currency_code']) : '';
1478
1479 if(!empty($token['amount']) && (!$amount || number_format((float)$amount, 2, '.', '') !== number_format((float)$token['amount'], 2, '.', '')))
1480 return 'capture_amount_mismatch';
1481 if(!empty($token['cc']) && $cc !== strtoupper((string)$token['cc']))
1482 return 'capture_currency_mismatch';
1483 if(empty($order['payer']['email_address']))
1484 return 'capture_missing_fields';
1485
1486 return '';
1487 }
1488
1489 /**
1490 * Creates a PayPal Checkout order for one-time (Buy Now) purchases.
1491 *
1492 * This must be server-side to prevent client-side manipulation of amount, item_number,
1493 * custom fields, etc. The resulting order id is returned to the JS SDK or used for
1494 * redirect-mode approval.
1495 *
1496 * @since 260101
1497 *
1498 * @param array $token Signed/validated purchase token.
1499 *
1500 * @return array API request result array from paypal_checkout_api_request().
1501 */
1502 public static function paypal_checkout_order_create($token = array())
1503 {
1504 // token: invoice, custom, item_name, item_number, amount, cc, ns, return, cancel.
1505 $invoice = (string)$token['invoice'];
1506 $custom = (string)$token['custom'];
1507 $amount = (string)$token['amount'];
1508 $cc = strtoupper((string)$token['cc']);
1509
1510 $item_name = trim((string)$token['item_name']);
1511 if(!$item_name)
1512 $item_name = 's2Member Purchase';
1513
1514 // PayPal limits various fields; keep item name within common limits.
1515 if(strlen($item_name) > 127)
1516 $item_name = substr($item_name, 0, 127);
1517
1518 $item_sku = trim((string)$token['item_number']);
1519 if(strlen($item_sku) > 127)
1520 $item_sku = substr($item_sku, 0, 127);
1521
1522 //260817.2119 Keep normal Checkout pricing unchanged; only split subtotal/tax when a Pro-Form token supplies a breakdown that reconciles exactly to the charged total.
1523 $item_amount = $amount;
1524 $tax_amount = '';
1525 if(isset($token['sub_total'], $token['tax']) && is_numeric($token['sub_total']) && is_numeric($token['tax'])
1526 && number_format((float)$token['sub_total'] + (float)$token['tax'], 2, '.', '') === number_format((float)$amount, 2, '.', ''))
1527 {
1528 $item_amount = (string)$token['sub_total'];
1529 $tax_amount = (string)$token['tax'];
1530 }
1531
1532 $purchase_unit = array(
1533 'invoice_id' => $invoice,
1534 'amount' => array(
1535 'currency_code' => $cc,
1536 'value' => $amount,
1537 'breakdown' => array(
1538 'item_total' => array(
1539 'currency_code' => $cc,
1540 'value' => $item_amount,
1541 ),
1542 ),
1543 ),
1544 'description' => $item_name,
1545 'items' => array(
1546 array(
1547 'name' => $item_name,
1548 'quantity' => '1',
1549 'unit_amount' => array(
1550 'currency_code' => $cc,
1551 'value' => $item_amount,
1552 ),
1553 ),
1554 ),
1555 );
1556
1557 if($tax_amount !== '' && (float)$tax_amount > 0)
1558 {
1559 $purchase_unit['amount']['breakdown']['tax_total'] = array(
1560 'currency_code' => $cc,
1561 'value' => $tax_amount,
1562 );
1563 $purchase_unit['items'][0]['tax'] = array(
1564 'currency_code' => $cc,
1565 'value' => $tax_amount,
1566 );
1567 }
1568
1569 if($item_sku)
1570 $purchase_unit['items'][0]['sku'] = $item_sku;
1571
1572 // PayPal limits custom_id length; keep it short/consistent.
1573 if($custom && strlen($custom) <= 127)
1574 $purchase_unit['custom_id'] = $custom;
1575
1576 $body = array(
1577 'intent' => 'CAPTURE',
1578 'purchase_units' => array($purchase_unit),
1579 'application_context' => array(
1580 'user_action' => 'PAY_NOW',
1581 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1582 'return_url' => (string)$token['return'],
1583 'cancel_url' => (string)$token['cancel'],
1584 ),
1585 );
1586
1587 // Idempotency: stable per invoice for create-order retries.
1588 $headers = array(
1589 'PayPal-Request-Id' => 's2m-ppco-order-'.md5($invoice),
1590 );
1591
1592 $data = array();
1593 for($attempt = 0; $attempt < 2; $attempt++)
1594 {
1595 $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1596 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1597 $response_body = !empty($r['body']) ? (string)$r['body'] : '';
1598 $data = ($response_body) ? json_decode($response_body, true) : array();
1599 $data = is_array($data) ? $data : array();
1600
1601 if($code >= 200 && $code <= 299 && !empty($data['id']))
1602 break;
1603
1604 $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
1605 if(!$ambiguous)
1606 break;
1607 }
1608
1609 if($code >= 200 && $code <= 299 && !empty($data['id']))
1610 {
1611 //260817 Bind the invoice and expected payment data to the PayPal order before the browser can request capture.
1612 set_transient('s2m_ppco_order_bind_'.md5($invoice), array(
1613 'order_id' => (string)$data['id'],
1614 'invoice' => $invoice,
1615 'amount' => $amount,
1616 'cc' => $cc,
1617 'custom' => $custom,
1618 ), 3 * HOUR_IN_SECONDS);
1619 }
1620
1621 return $data;
1622 }
1623
1624 /**
1625 * Retrieves PayPal Checkout subscription details via the Subscriptions REST API.
1626 *
1627 * @since 260517
1628 *
1629 * @param string $subscription_id PayPal subscription id (I-...).
1630 *
1631 * @return array Decoded subscription response, with __code/__body added; __error on failure.
1632 */
1633 public static function paypal_checkout_subscription_details($subscription_id = '')
1634 {
1635 $subscription_id = trim((string)$subscription_id);
1636
1637 if(!$subscription_id)
1638 return array('__error' => 'missing_subscription_id', '__code' => 0, '__body' => '');
1639
1640 $r = self::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
1641
1642 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1643 $body = !empty($r['body']) ? (string)$r['body'] : '';
1644 $data = ($body) ? json_decode($body, true) : array();
1645 $data = is_array($data) ? $data : array();
1646
1647 $data['__code'] = $code;
1648 $data['__body'] = $body;
1649
1650 if(!($code >= 200 && $code <= 299) || empty($data['id']))
1651 $data['__error'] = 'subscription_details_failed';
1652
1653 return $data;
1654 }
1655
1656 /**
1657 * Cancels a PayPal Checkout subscription via the Subscriptions REST API.
1658 *
1659 * Used by the optional on-site cancellation flow (logged-in users).
1660 *
1661 * @since 260114
1662 *
1663 * @param string $subscription_id PayPal subscription id (I-...).
1664 * @param string $reason Short human readable reason (PayPal limit applies).
1665 *
1666 * @return array API request result array from paypal_checkout_api_request().
1667 */
1668 public static function paypal_checkout_subscription_cancel($subscription_id = '', $reason = '')
1669 {
1670 $subscription_id = trim((string)$subscription_id);
1671 $reason = trim((string)$reason);
1672
1673 if(!$subscription_id)
1674 return array('code' => 0, 'message' => 'missing_subscription_id', 'body' => '');
1675
1676 // PayPal docs: reason 1..128 chars.
1677 $reason = substr(preg_replace('/\s+/', ' ', strip_tags($reason)), 0, 128);
1678 if(!$reason)
1679 $reason = 'Cancelled by subscriber.';
1680
1681 $body = array('reason' => $reason);
1682
1683 return self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions/'.rawurlencode($subscription_id).'/cancel', $body);
1684 }
1685
1686 /**
1687 * Cancels a PayPal Standard/legacy recurring profile via the classic NVP API.
1688 *
1689 * This is used by cross-gateway replacement flows when the old subscription appears
1690 * to be a PayPal Standard recurring profile. //260407
1691 *
1692 * @since 260407
1693 *
1694 * @param string $profile_id PayPal recurring profile id.
1695 * @param string $action Optional status action. Defaults to `Cancel`.
1696 *
1697 * @return array API response array from paypal_api_response().
1698 */
1699 public static function paypal_standard_subscription_cancel($profile_id = '', $action = 'Cancel')
1700 {
1701 $profile_id = trim((string)$profile_id);
1702 $action = trim((string)$action);
1703
1704 if(!$profile_id)
1705 return array('__error' => 'missing_profile_id');
1706
1707 if(!$action)
1708 $action = 'Cancel';
1709
1710 //260407 This still goes through the existing authenticated NVP helper, so current PayPal API credentials are required.
1711 return self::paypal_api_response(array(
1712 'METHOD' => 'ManageRecurringPaymentsProfileStatus',
1713 'ACTION' => $action,
1714 'PROFILEID' => $profile_id,
1715 ));
1716 }
1717
1718 /**
1719 * Captures a PayPal Checkout order (server-side) after buyer approval.
1720 *
1721 * Used by the JS SDK onApprove callback (capture_order op) and by redirect-mode
1722 * return handling. On success, the capture details are proxied into the legacy
1723 * s2Member PayPal notify/return handlers.
1724 *
1725 * @since 260101
1726 *
1727 * @param string $order_id PayPal Checkout order id.
1728 * @param array $token Signed/validated purchase token.
1729 *
1730 * @return array API request result array from paypal_checkout_api_request().
1731 */
1732 public static function paypal_checkout_order_capture($order_id = '', $token = array())
1733 {
1734 $order_id = trim((string)$order_id);
1735 if(!$order_id)
1736 return array('__error' => 'missing_order_id');
1737
1738 $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1739 $binding_name = $invoice ? 's2m_ppco_order_bind_'.md5($invoice) : '';
1740 $binding = $binding_name ? get_transient($binding_name) : false;
1741
1742 if(is_array($binding))
1743 {
1744 $binding_matches = (!empty($binding['order_id']) && (string)$binding['order_id'] === $order_id
1745 && isset($binding['invoice']) && (string)$binding['invoice'] === $invoice
1746 && isset($binding['amount']) && number_format((float)$binding['amount'], 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
1747 && isset($binding['cc']) && strtoupper((string)$binding['cc']) === strtoupper((string)$token['cc'])
1748 && isset($binding['custom']) && (string)$binding['custom'] === (string)$token['custom']);
1749
1750 if(!$binding_matches)
1751 return array('__error' => 'order_binding_mismatch');
1752 }
1753
1754 $capture_lock = 's2m_ppco_capture_lock_'.md5($order_id);
1755 if(!self::dedupe_lock_acquire($capture_lock, 300))
1756 return array('__error' => 'capture_in_progress');
1757
1758 try
1759 {
1760 //260817 If the short-lived local binding is gone, verify PayPal's order before attempting capture.
1761 if(!is_array($binding))
1762 {
1763 $details = self::paypal_checkout_order_details($order_id);
1764 if(!empty($details['__error']))
1765 return $details;
1766
1767 if(($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
1768 return array('__error' => $validation_error);
1769
1770 if(!empty($details['status']) && strtoupper((string)$details['status']) === 'COMPLETED')
1771 {
1772 if(($completion_error = self::paypal_checkout_order_completion_error($details, $order_id, $token)))
1773 return array('__error' => $completion_error);
1774
1775 return $details;
1776 }
1777 if(empty($details['status']) || strtoupper((string)$details['status']) !== 'APPROVED')
1778 return array('__error' => 'order_not_approved');
1779 }
1780
1781 // Idempotency: stable per order capture retries.
1782 $headers = array(
1783 'PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id),
1784 'Prefer' => 'return=representation',
1785 );
1786
1787 $r = array();
1788 $data = array();
1789 for($attempt = 0; $attempt < 2; $attempt++)
1790 {
1791 $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
1792 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1793 $body = !empty($r['body']) ? (string)$r['body'] : '';
1794 $data = ($body) ? json_decode($body, true) : array();
1795 $data = is_array($data) ? $data : array();
1796
1797 if($code >= 200 && $code <= 299)
1798 break;
1799
1800 $ambiguous = ($code === 0 || $code === 408 || $code >= 500);
1801 if(!$ambiguous)
1802 break;
1803 }
1804
1805 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1806 if($code >= 200 && $code <= 299 && !($completion_error = self::paypal_checkout_order_completion_error($data, $order_id, $token)))
1807 {
1808 if($binding_name)
1809 delete_transient($binding_name);
1810 return $data;
1811 }
1812
1813 //260817 Recover from an ambiguous or incomplete capture response by reading PayPal's final order state.
1814 $details = self::paypal_checkout_order_details($order_id);
1815 if(empty($details['__error']) && !($completion_error = self::paypal_checkout_order_completion_error($details, $order_id, $token)))
1816 {
1817 if($binding_name)
1818 delete_transient($binding_name);
1819 return $details;
1820 }
1821
1822 if($code >= 200 && $code <= 299 && !empty($completion_error))
1823 return array('__error' => $completion_error);
1824 if(!empty($details['__error']))
1825 return $details;
1826 return array('__error' => 'order_capture_failed', '__code' => $code, '__body' => !empty($r['body']) ? (string)$r['body'] : '');
1827 }
1828 finally
1829 {
1830 self::dedupe_lock_release($capture_lock);
1831 }
1832 }
1833
1834 /**
1835 * Sends PayPal Checkout fulfillment through s2Member's existing PayPal Notify handler once.
1836 *
1837 * @since 260817
1838 *
1839 * @param array $paypal PayPal-style transaction variables.
1840 * @param string $done_option Local fulfillment done-marker option name.
1841 * @param string $proxy_use Optional proxy-use routing value.
1842 * @param array $extra Optional additional server-side Notify variables.
1843 *
1844 * @return array Result with ok/processed/duplicate/error and response details.
1845 */
1846 public static function paypal_checkout_notify_once($paypal = array(), $done_option = '', $proxy_use = 'paypal_checkout', $extra = array())
1847 {
1848 if(!is_array($paypal) || !$paypal || !$done_option || !is_string($done_option))
1849 return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_invalid_args');
1850
1851 //260818.0603 This helper now coordinates one-time and subscription fulfillment markers.
1852 self::dedupe_markers_cleanup('s2m_ppco_notify_cleanup_throttle', array(
1853 array('prefix' => 's2m_ppco_capture_done_', 'ttl' => DAY_IN_SECONDS),
1854 array('prefix' => 's2m_ppco_subscr_done_', 'ttl' => DAY_IN_SECONDS),
1855 array('prefix' => 's2m_ppco_notify_lock_', 'ttl' => HOUR_IN_SECONDS),
1856 array('prefix' => 's2m_ppco_capture_lock_', 'ttl' => HOUR_IN_SECONDS),
1857 ));
1858
1859 $result_transient = 's2m_ppco_notify_result_'.md5($done_option);
1860 if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
1861 {
1862 $cached_result = get_transient($result_transient);
1863 return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
1864 }
1865
1866 $lock_option = 's2m_ppco_notify_lock_'.md5($done_option);
1867 if(!self::dedupe_lock_acquire($lock_option, 900))
1868 {
1869 if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
1870 {
1871 $cached_result = get_transient($result_transient);
1872 return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
1873 }
1874
1875 return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_in_progress');
1876 }
1877
1878 try
1879 {
1880 if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
1881 {
1882 $cached_result = get_transient($result_transient);
1883 return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
1884 }
1885
1886 //260818.0617 Allow Pro to prepare account-specific fulfillment inside the shared Notify lock and enrich fallback context.
1887 $notify_context = apply_filters('ws_plugin__s2member_paypal_checkout_notify_context', array(
1888 'paypal' => $paypal,
1889 'proxy_use' => (string)$proxy_use,
1890 'extra' => is_array($extra) ? $extra : array(),
1891 ), $done_option);
1892
1893 if(is_wp_error($notify_context))
1894 return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_context_failed', 'context_error' => (string)$notify_context->get_error_code());
1895
1896 if(!is_array($notify_context) || empty($notify_context['paypal']) || !is_array($notify_context['paypal']))
1897 return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_context_invalid');
1898
1899 $paypal = $notify_context['paypal'];
1900 $proxy_use = isset($notify_context['proxy_use']) ? (string)$notify_context['proxy_use'] : (string)$proxy_use;
1901 $extra = !empty($notify_context['extra']) && is_array($notify_context['extra']) ? $notify_context['extra'] : array();
1902
1903 $notify_url = home_url('/?s2member_paypal_notify=1');
1904 $notify_post = array_merge($paypal, $extra, array(
1905 's2member_paypal_proxy' => 'paypal',
1906 's2member_paypal_proxy_use' => $proxy_use,
1907 's2member_paypal_proxy_verification' => self::paypal_proxy_key_gen(),
1908 ));
1909 $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
1910
1911 if(!is_array($notify_r))
1912 $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
1913
1914 $code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
1915 $message = !empty($notify_r['message']) ? (string)$notify_r['message'] : '';
1916 $body = !empty($notify_r['body']) ? (string)$notify_r['body'] : '';
1917
1918 if($code >= 200 && $code <= 299)
1919 {
1920 $result = array('code' => $code, 'message' => $message, 'body' => $body);
1921 set_transient($result_transient, $result, DAY_IN_SECONDS); // Preserve the Notify result for safe duplicate/retry returns, including future Pro success URLs.
1922 self::dedupe_done_mark($done_option);
1923
1924 //260818.1752 Run account-specific post-Notify work only after fulfillment is durably marked complete.
1925 do_action('ws_plugin__s2member_paypal_checkout_notify_processed', $notify_context, $done_option, $result);
1926
1927 return array_merge(array('ok' => true, 'processed' => true, 'duplicate' => false, 'error' => ''), $result);
1928 }
1929
1930 return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_proxy_failed', 'code' => $code, 'message' => $message, 'body' => $body);
1931 }
1932 finally
1933 {
1934 self::dedupe_lock_release($lock_option);
1935 }
1936 }
1937
1938 /**
1939 * Creates a PayPal Checkout subscription (server-side) when using redirect-mode approval.
1940 *
1941 * In JS SDK button mode, subscriptions are created client-side using plan_id and
1942 * then confirmed server-side. Redirect-mode requires server-side creation.
1943 *
1944 * @since 260114
1945 *
1946 * @param array $token Signed/validated purchase token.
1947 *
1948 * @return array API request result array from paypal_checkout_api_request().
1949 */
1950 public static function paypal_checkout_subscription_create($token = array())
1951 {
1952 if(!is_array($token))
1953 return array();
1954
1955 $invoice = (string)$token['invoice'];
1956
1957 $plan_id = self::paypal_checkout_plan_get_id($token);
1958 if(!$plan_id)
1959 return array();
1960
1961 $brand_name = get_bloginfo('name');
1962 $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
1963
1964 $body = array(
1965 'plan_id' => $plan_id,
1966 'custom_id' => $invoice,
1967 'application_context' => array(
1968 'brand_name' => $brand_name,
1969 'return_url' => (string)$token['return'],
1970 'cancel_url' => (string)$token['cancel'],
1971 'user_action' => 'SUBSCRIBE_NOW',
1972 'shipping_preference' => 'NO_SHIPPING',
1973 ),
1974 );
1975
1976 // Idempotency: stable per invoice for create-subscription retries.
1977 $headers = array(
1978 'PayPal-Request-Id' => 's2m-ppco-sub-'.md5($invoice),
1979 );
1980
1981 $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
1982
1983 $data = array();
1984 if(!empty($r['body']) && is_string($r['body']))
1985 $data = json_decode($r['body'], true);
1986
1987 return is_array($data) ? $data : array();
1988 }
1989
1990 /**
1991 * Returns a PayPal Checkout Plan ID for a subscription token (creates product/plan if needed).
1992 *
1993 * Plan/product creation is cached in ws_plugin__s2member_options to avoid duplicates.
1994 * Cache key is derived from plan-affecting attributes (currency, billing cycles, trial).
1995 *
1996 * @since 260101
1997 *
1998 * @param array $token Signed/validated purchase token from shortcode flow.
1999 *
2000 * @return string PayPal plan id (P-...) or empty string on failure.
2001 */
2002 public static function paypal_checkout_plan_get_id($token = array())
2003 {
2004 if(!is_array($token))
2005 return '';
2006
2007 $cc = !empty($token['cc']) ? strtoupper(trim((string)$token['cc'])) : '';
2008 $rr = isset($token['rr']) ? strtoupper(trim((string)$token['rr'])) : '';
2009 $ra = isset($token['amount']) ? (string)$token['amount'] : '';
2010 $rp = !empty($token['rp']) ? (int)$token['rp'] : 0;
2011 $rt = !empty($token['rt']) ? strtoupper(trim((string)$token['rt'])) : '';
2012
2013 $is_pro_form = !empty($token['s2member_paypal_proxy_use']) && strpos((string)$token['s2member_paypal_proxy_use'], 'pro-emails') !== false;
2014 $rrt = !empty($token['rrt']) ? (int)$token['rrt'] : 0;
2015 $rra = isset($token['rra']) ? (int)$token['rra'] : ($is_pro_form ? 2 : 1);
2016
2017 //260827.1950 Pro-Forms define rra as the exact Max Failed Payments value for any recurring profile;
2018 // Framework buttons retain their legacy PayPal Standard retry semantics. rrt remains rr="1" only.
2019 if($rr !== '1')
2020 $rrt = 0;
2021
2022 $ta = isset($token['ta']) ? (string)$token['ta'] : '';
2023 $tp = !empty($token['tp']) ? (int)$token['tp'] : 0;
2024 $tt = !empty($token['tt']) ? strtoupper(trim((string)$token['tt'])) : '';
2025
2026 if(!$cc || $rr === '' || $rr === 'BN' || $rp < 1 || !$rt)
2027 return '';
2028
2029 $env = self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
2030 $cred_id = self::paypal_checkout_cred_id($env);
2031 if(!$cred_id)
2032 return '';
2033
2034 $plan_key = md5(serialize(array(
2035 'env' => $env,
2036 'cc' => $cc,
2037 'rr' => $rr,
2038 'ra' => (string)$ra,
2039 'rp' => (int)$rp,
2040 'rt' => (string)$rt,
2041
2042 'rrt' => (int)$rrt,
2043 'rra' => (int)$rra,
2044 //260827.2129 !!! TO-DO: Standardize Pro-Form and Framework rrt/rra semantics in a future gateway abstraction; keep Plan caches separate until both contracts match.
2045 'pro_form' => (int)$is_pro_form,
2046
2047 'ta' => (string)$ta,
2048 'tp' => (int)$tp,
2049 'tt' => (string)$tt,
2050 'item_number' => !empty($token['item_number']) ? (string)$token['item_number'] : '',
2051 'item_name' => !empty($token['item_name']) ? (string)$token['item_name'] : '',
2052 )));
2053
2054 $ppco_opt = !empty($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"]) ? $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] : array();
2055 if(!is_array($ppco_opt))
2056 $ppco_opt = array();
2057
2058 $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();
2059
2060 if(!empty($plan_ids[$plan_key]) && is_string($plan_ids[$plan_key]))
2061 return $plan_ids[$plan_key];
2062
2063 $product_id = self::paypal_checkout_product_get_id();
2064 if(!$product_id)
2065 return '';
2066
2067 $unit_map = array('D' => 'DAY', 'W' => 'WEEK', 'M' => 'MONTH', 'Y' => 'YEAR');
2068 $rt_unit = !empty($unit_map[$rt]) ? $unit_map[$rt] : 'MONTH';
2069 $tt_unit = !empty($unit_map[$tt]) ? $unit_map[$tt] : $rt_unit;
2070
2071 $rp = max(1, (int)$rp);
2072 $tp = max(0, (int)$tp);
2073
2074 $ra_v = number_format((float)$ra, 2, '.', '');
2075 $ta_v = number_format((float)$ta, 2, '.', '');
2076
2077 $regular_total_cycles = 0; // 0 = infinite.
2078
2079 //260827.2129 Legacy Pro-Forms without an initial term charge once at checkout and define rrt as additional payments.
2080 // PPCO regular cycles include the checkout payment, while Framework buttons retain total-installment rrt semantics.
2081 if($rr === '1' && $rrt > 0)
2082 {
2083 $regular_total_cycles = (int)$rrt + (($is_pro_form && $tp === 0) ? 1 : 0);
2084 if($regular_total_cycles > 999) // PayPal cannot represent the legacy Pro-Form result; fail instead of silently reducing the number of charges.
2085 return '';
2086 }
2087 else if($rr === '0')
2088 $regular_total_cycles = 1;
2089
2090 //260827.1950 Preserve the Pro-Form's documented exact rra value; Framework buttons keep legacy Standard boolean retry behavior.
2091 $payment_failure_threshold = $is_pro_form ? max(0, (int)$rra) : (($rr === '1' && $rra) ? 2 : 1);
2092
2093 $billing_cycles = array();
2094 $seq = 1;
2095
2096 if($tp > 0)
2097 {
2098 $billing_cycles[] = array(
2099 'frequency' => array(
2100 'interval_unit' => $tt_unit,
2101 'interval_count' => $tp,
2102 ),
2103 'tenure_type' => 'TRIAL',
2104 'sequence' => $seq++,
2105 'total_cycles' => 1,
2106 'pricing_scheme' => array(
2107 'fixed_price' => array(
2108 'value' => $ta_v,
2109 'currency_code' => $cc,
2110 ),
2111 ),
2112 );
2113 }
2114
2115 $billing_cycles[] = array(
2116 'frequency' => array(
2117 'interval_unit' => $rt_unit,
2118 'interval_count' => $rp,
2119 ),
2120 'tenure_type' => 'REGULAR',
2121 'sequence' => $seq++,
2122 'total_cycles' => $regular_total_cycles,
2123 'pricing_scheme' => array(
2124 'fixed_price' => array(
2125 'value' => $ra_v,
2126 'currency_code' => $cc,
2127 ),
2128 ),
2129 );
2130
2131 $plan_name = !empty($token['item_name']) ? (string)$token['item_name'] : 's2Member Plan';
2132 $plan_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($plan_name))), 0, 127);
2133
2134 $plan_desc = $plan_name;
2135 if(!empty($token['rr']) && $token['rr'] !== 'BN' && !empty($token['rp']) && !empty($token['rt']))
2136 {
2137 $plan_desc .= ' (recurring)';
2138 }
2139 $plan_desc = substr(preg_replace('/\s+/', ' ', trim(strip_tags($plan_desc))), 0, 127);
2140
2141 $body = array(
2142 'product_id' => $product_id,
2143 'name' => $plan_name,
2144 'description' => $plan_desc,
2145 'status' => 'ACTIVE',
2146 'billing_cycles' => $billing_cycles,
2147 'payment_preferences' => array(
2148 'auto_bill_outstanding' => true,
2149 'setup_fee' => array('value' => '0.00', 'currency_code' => $cc),
2150 'setup_fee_failure_action' => 'CONTINUE',
2151 'payment_failure_threshold' => $payment_failure_threshold,
2152 ),
2153 );
2154
2155 $headers = array(
2156 'PayPal-Request-Id' => 's2m-ppco-plan-'.md5($env.'|'.$plan_key.'|'.md5((string)wp_json_encode($body))),
2157 );
2158
2159 $r = self::paypal_checkout_api_request('POST', '/v1/billing/plans', $body, $headers);
2160
2161 $data = array();
2162 if(!empty($r['body']) && is_string($r['body']))
2163 $data = json_decode($r['body'], true);
2164
2165 $plan_id = !empty($data['id']) ? (string)$data['id'] : '';
2166 if(!$plan_id)
2167 {
2168 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2169 'ppco' => 'plan',
2170 'event' => 'plan_create_failed',
2171 'env_setting' => $env,
2172 'plan_key' => $plan_key,
2173 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2174 'message' => !empty($r['message']) ? (string)$r['message'] : '',
2175 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2176 'request' => $body,
2177 ));
2178 return '';
2179 }
2180
2181 $plan_ids[$plan_key] = $plan_id;
2182
2183 $options = get_option('ws_plugin__s2member_options');
2184 if(!is_array($options))
2185 $options = array();
2186
2187 if(empty($options['paypal_checkout_cache']) || !is_array($options['paypal_checkout_cache']))
2188 $options['paypal_checkout_cache'] = array();
2189
2190 if(empty($options['paypal_checkout_cache'][$cred_id]) || !is_array($options['paypal_checkout_cache'][$cred_id]))
2191 $options['paypal_checkout_cache'][$cred_id] = array();
2192
2193 if(empty($options['paypal_checkout_cache'][$cred_id][$env]) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]))
2194 $options['paypal_checkout_cache'][$cred_id][$env] = array();
2195
2196 if(empty($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]['plan_ids']))
2197 $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids'] = array();
2198
2199 $options['paypal_checkout_cache'][$cred_id][$env]['plan_ids'] = $plan_ids;
2200
2201 // Delete legacy cache keys (no migration; just remove).
2202 if(isset($options['paypal_checkout_plan_ids']))
2203 unset($options['paypal_checkout_plan_ids']);
2204
2205 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
2206
2207 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
2208 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] = (!empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])) ? $options['paypal_checkout_cache'] : array();
2209
2210 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2211 'ppco' => 'plan',
2212 'event' => 'plan_cached',
2213 'env_setting' => $env,
2214 'cred_id' => $cred_id,
2215 'plan_key' => $plan_key,
2216 'plan_id' => $plan_id,
2217 ));
2218
2219 return $plan_id;
2220 }
2221
2222 /**
2223 * Returns a PayPal Catalog Product ID (creates and caches if needed).
2224 *
2225 * Cached under:
2226 * - $options['paypal_checkout_cache'][$cred_id][$env]['product_ids'][$product_key]
2227 *
2228 * @since 260101
2229 *
2230 * @return string PayPal product id (PROD-...) or empty string on failure.
2231 */
2232 public static function paypal_checkout_product_get_id()
2233 {
2234 $env = self::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
2235 $cred_id = self::paypal_checkout_cred_id($env);
2236 if(!$cred_id)
2237 return '';
2238
2239 $product_key = 'default';
2240
2241 $ppco_opt = !empty($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"]) ? $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] : array();
2242 if(!is_array($ppco_opt))
2243 $ppco_opt = array();
2244
2245 $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();
2246
2247 if(!empty($product_ids[$product_key]) && is_string($product_ids[$product_key]))
2248 return $product_ids[$product_key];
2249
2250 $name = get_bloginfo('name');
2251 $url = home_url('/');
2252
2253 $name = substr(preg_replace('/\s+/', ' ', trim(strip_tags((string)$name))), 0, 127);
2254 if(!$name)
2255 $name = 's2Member';
2256
2257 $body = array(
2258 'name' => $name.' Membership',
2259 'description' => 'Membership billing product (created by s2Member).',
2260 'type' => 'SERVICE',
2261 'category' => 'SOFTWARE',
2262 'home_url' => $url,
2263 );
2264
2265 $headers = array(
2266 'PayPal-Request-Id' => 's2m-ppco-prod-'.md5($env),
2267 );
2268
2269 $r = self::paypal_checkout_api_request('POST', '/v1/catalogs/products', $body, $headers);
2270
2271 $data = array();
2272 if(!empty($r['body']) && is_string($r['body']))
2273 $data = json_decode($r['body'], true);
2274
2275 $product_id = !empty($data['id']) ? (string)$data['id'] : '';
2276 if(!$product_id)
2277 {
2278 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2279 'ppco' => 'product',
2280 'event' => 'product_create_failed',
2281 'env_setting' => $env,
2282 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2283 'message' => !empty($r['message']) ? (string)$r['message'] : '',
2284 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2285 ));
2286 return '';
2287 }
2288
2289 $product_ids[$product_key] = $product_id;
2290
2291 $options = get_option('ws_plugin__s2member_options');
2292 if(!is_array($options))
2293 $options = array();
2294
2295 if(empty($options['paypal_checkout_cache']) || !is_array($options['paypal_checkout_cache']))
2296 $options['paypal_checkout_cache'] = array();
2297
2298 if(empty($options['paypal_checkout_cache'][$cred_id]) || !is_array($options['paypal_checkout_cache'][$cred_id]))
2299 $options['paypal_checkout_cache'][$cred_id] = array();
2300
2301 if(empty($options['paypal_checkout_cache'][$cred_id][$env]) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]))
2302 $options['paypal_checkout_cache'][$cred_id][$env] = array();
2303
2304 if(empty($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']) || !is_array($options['paypal_checkout_cache'][$cred_id][$env]['product_ids']))
2305 $options['paypal_checkout_cache'][$cred_id][$env]['product_ids'] = array();
2306
2307 $options['paypal_checkout_cache'][$cred_id][$env]['product_ids'] = $product_ids;
2308
2309 // Delete legacy cache keys (no migration; just remove).
2310 if(isset($options['paypal_checkout_product_ids']))
2311 unset($options['paypal_checkout_product_ids']);
2312
2313 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
2314
2315 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
2316 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_cache"] = (!empty($options['paypal_checkout_cache']) && is_array($options['paypal_checkout_cache'])) ? $options['paypal_checkout_cache'] : array();
2317
2318 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2319 'ppco' => 'product',
2320 'event' => 'product_cached',
2321 'env_setting' => $env,
2322 'cred_id' => $cred_id,
2323 'product_key' => $product_key,
2324 'product_id' => $product_id,
2325 ));
2326
2327 return $product_id;
2328 }
2329
2330 /**
2331 * Returns the stored PayPal webhook id for the active environment.
2332 *
2333 * @since 260101
2334 *
2335 * @return string Webhook id or empty string.
2336 */
2337 public static function paypal_checkout_webhook_id()
2338 {
2339 return self::paypal_checkout_is_sandbox()
2340 ? (string)$GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_sandbox_webhook_id"]
2341 : (string)$GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_webhook_id"];
2342 }
2343
2344 /**
2345 * Verifies a PayPal webhook signature via PayPal's verify-webhook-signature API.
2346 *
2347 * @since 260115
2348 *
2349 * @param mixed $event Decoded event array (or raw JSON string in $raw_body).
2350 * @param string $raw_body Raw webhook body.
2351 * @param array $headers Request headers (lowercase keys expected).
2352 *
2353 * @return bool True if signature verifies; otherwise false.
2354 */
2355 public static function paypal_checkout_verify_webhook_signature($event, $raw_body, $headers = array())
2356 {
2357 $tx_id = !empty($headers['paypal-transmission-id']) ? $headers['paypal-transmission-id'] : '';
2358 $tx_time = !empty($headers['paypal-transmission-time']) ? $headers['paypal-transmission-time'] : '';
2359 $tx_sig = !empty($headers['paypal-transmission-sig']) ? $headers['paypal-transmission-sig'] : '';
2360 $cert = !empty($headers['paypal-cert-url']) ? $headers['paypal-cert-url'] : '';
2361 $algo = !empty($headers['paypal-auth-algo']) ? $headers['paypal-auth-algo'] : '';
2362
2363 if(!$tx_id || !$tx_time || !$tx_sig || !$cert || !$algo)
2364 return false;
2365
2366 //260205 Detect sandbox vs live from the cert URL.
2367 $orig_sandbox = self::paypal_checkout_is_sandbox();
2368 $cert_is_sandbox = (strpos((string)$cert, 'sandbox') !== false);
2369
2370 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $cert_is_sandbox ? '1' : '0';
2371
2372 $webhook_id = self::paypal_checkout_webhook_id();
2373 if(!$webhook_id)
2374 {
2375 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2376 return false;
2377 }
2378
2379 $body = array(
2380 'transmission_id' => $tx_id,
2381 'transmission_time' => $tx_time,
2382 'cert_url' => $cert,
2383 'auth_algo' => $algo,
2384 'transmission_sig' => $tx_sig,
2385 'webhook_id' => $webhook_id,
2386 'webhook_event' => is_array($event) ? $event : json_decode((string)$raw_body, true),
2387 );
2388
2389 $r = self::paypal_checkout_api_request('POST', '/v1/notifications/verify-webhook-signature', $body);
2390 if(empty($r['code']) || (int)$r['code'] !== 200 || empty($r['body']))
2391 {
2392 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2393 return false;
2394 }
2395
2396 if(!is_string($r['body']))
2397 {
2398 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2399 return false;
2400 }
2401
2402 $data = json_decode($r['body'], true);
2403
2404 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2405 return !empty($data['verification_status']) && $data['verification_status'] === 'SUCCESS';
2406 }
2407
2408 /**
2409 * Returns the PayPal Checkout webhook event names processed by s2Member.
2410 *
2411 * These events are used for:
2412 * - Subscription activation fallback and lifecycle changes.
2413 * - Recurring payment bookkeeping, refunds, and reversals.
2414 *
2415 * @since 260115
2416 *
2417 * @return array<string> Event type names.
2418 */
2419 public static function paypal_checkout_webhook_event_names()
2420 {
2421 //260820.0218 Keep automatic webhook registration aligned with the events handled by s2Member and listed in PayPal Checkout setup help.
2422 return array(
2423 'PAYMENT.SALE.COMPLETED',
2424 'PAYMENT.CAPTURE.COMPLETED',
2425 'PAYMENT.SALE.REFUNDED',
2426 'PAYMENT.CAPTURE.REFUNDED',
2427 'PAYMENT.SALE.REVERSED',
2428 'PAYMENT.CAPTURE.REVERSED',
2429
2430 //260824.1727 Treat a newly opened PayPal dispute as a chargeback/reversal through s2Member's existing EOT policy.
2431 'CUSTOMER.DISPUTE.CREATED',
2432
2433 'BILLING.SUBSCRIPTION.CREATED',
2434 'BILLING.SUBSCRIPTION.ACTIVATED',
2435 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
2436 'BILLING.SUBSCRIPTION.UPDATED',
2437 'BILLING.SUBSCRIPTION.CANCELLED',
2438 'BILLING.SUBSCRIPTION.SUSPENDED',
2439 'BILLING.SUBSCRIPTION.EXPIRED',
2440 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
2441 );
2442 }
2443
2444 /**
2445 * Creates or updates a PayPal Checkout webhook for the current site URL and required events.
2446 *
2447 * Used by the admin "Create/Update Webhook Automatically" buttons.
2448 * Persists the webhook id into ws_plugin__s2member_options for the selected environment.
2449 *
2450 * @since 260115
2451 *
2452 * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
2453 * @param bool $existing_only If true, update only a webhook whose ID is already stored; never create/adopt one.
2454 *
2455 * @return array Result array on success with keys:
2456 * - id (string) webhook id
2457 * - op (string) 'created'|'updated'|'adopted'
2458 * - env (string) 'live'|'sandbox'
2459 * Empty array on failure.
2460 */
2461 public static function paypal_checkout_webhook_upsert($env = '', $existing_only = false)
2462 {
2463 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
2464
2465 $orig_sandbox = self::paypal_checkout_is_sandbox();
2466 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = ($env === 'sandbox') ? '1' : '0';
2467
2468 $url = add_query_arg('s2member_paypal_webhook', '1', home_url('/', 'https'));
2469
2470 $event_types = array();
2471 foreach(self::paypal_checkout_webhook_event_names() as $name)
2472 $event_types[] = array('name' => $name);
2473
2474 $existing_id = self::paypal_checkout_webhook_id();
2475
2476 if($existing_id)
2477 {
2478 $patch = array(
2479 array('op' => 'replace', 'path' => '/url', 'value' => $url),
2480 array('op' => 'replace', 'path' => '/event_types', 'value' => $event_types),
2481 );
2482 $r = self::paypal_checkout_api_request('PATCH', '/v1/notifications/webhooks/'.rawurlencode($existing_id), $patch);
2483
2484 if(!empty($r['code']) && (int)$r['code'] === 200)
2485 {
2486 self::paypal_checkout_webhook_store_id($existing_id);
2487
2488 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2489 'ppco' => 'webhook',
2490 'event' => 'updated_webhook',
2491 'env_setting' => $env,
2492 'id' => $existing_id,
2493 'url' => $url,
2494 'code' => (int)$r['code'],
2495 ));
2496
2497 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2498 return array('id' => $existing_id, 'op' => 'updated', 'env' => $env);
2499 }
2500
2501 //260205 PayPal may return 400 when there is no change; treat as success.
2502 $no_change = false;
2503 if(!empty($r['body']) && is_string($r['body']))
2504 {
2505 $d = json_decode($r['body'], true);
2506 $no_change = !empty($d['name']) && $d['name'] === 'WEBHOOK_PATCH_REQUEST_NO_CHANGE';
2507 }
2508 if($no_change)
2509 {
2510 self::paypal_checkout_webhook_store_id($existing_id);
2511
2512 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2513 'ppco' => 'webhook',
2514 'event' => 'updated_webhook_no_change',
2515 'env_setting' => $env,
2516 'id' => $existing_id,
2517 'url' => $url,
2518 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2519 ));
2520
2521 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2522 return array('id' => $existing_id, 'op' => 'updated', 'env' => $env);
2523 }
2524
2525 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2526 'ppco' => 'webhook',
2527 'event' => 'update_webhook_failed',
2528 'env_setting' => $env,
2529 'id' => $existing_id,
2530 'url' => $url,
2531 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2532 'message' => !empty($r['message']) ? (string)$r['message'] : '',
2533 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2534 ));
2535 }
2536
2537 //260820.0313 Upgrade reconciliation must never create or adopt a webhook the site owner did not already store.
2538 if($existing_only)
2539 {
2540 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2541 return array();
2542 }
2543
2544 $body = array(
2545 'url' => $url,
2546 'event_types' => $event_types,
2547 );
2548 $r = self::paypal_checkout_api_request('POST', '/v1/notifications/webhooks', $body);
2549
2550 $id = '';
2551 if(!empty($r['code']) && (int)$r['code'] === 201 && !empty($r['body']) && is_string($r['body']))
2552 {
2553 $data = json_decode($r['body'], true);
2554 if(!empty($data['id']))
2555 $id = (string)$data['id'];
2556 }
2557
2558 $adopted_existing = false;
2559
2560 //260205 If URL already exists, lookup existing webhook by URL and adopt its ID.
2561 if(!$id && !empty($r['code']) && (int)$r['code'] === 400 && !empty($r['body']) && is_string($r['body']))
2562 {
2563 $d = json_decode($r['body'], true);
2564 if(!empty($d['name']) && $d['name'] === 'WEBHOOK_URL_ALREADY_EXISTS')
2565 {
2566 $lr = self::paypal_checkout_api_request('GET', '/v1/notifications/webhooks');
2567 if(!empty($lr['code']) && (int)$lr['code'] === 200 && !empty($lr['body']) && is_string($lr['body']))
2568 {
2569 $ld = json_decode($lr['body'], true);
2570 if(!empty($ld['webhooks']) && is_array($ld['webhooks']))
2571 {
2572 foreach($ld['webhooks'] as $_wh)
2573 if(!empty($_wh['url']) && (string)$_wh['url'] === $url && !empty($_wh['id']))
2574 {
2575 $id = (string)$_wh['id'];
2576 $adopted_existing = true;
2577 break;
2578 }
2579 }
2580 }
2581 }
2582 }
2583
2584 //260820.0313 A same-app webhook found by this exact s2Member URL is safe to adopt, but first reconcile its required events.
2585 if($id && $adopted_existing)
2586 {
2587 $patch = array(
2588 array('op' => 'replace', 'path' => '/url', 'value' => $url),
2589 array('op' => 'replace', 'path' => '/event_types', 'value' => $event_types),
2590 );
2591 $ur = self::paypal_checkout_api_request('PATCH', '/v1/notifications/webhooks/'.rawurlencode($id), $patch);
2592 $adopt_update_ok = (!empty($ur['code']) && (int)$ur['code'] === 200);
2593
2594 if(!$adopt_update_ok && !empty($ur['body']) && is_string($ur['body']))
2595 {
2596 $ud = json_decode($ur['body'], true);
2597 $adopt_update_ok = !empty($ud['name']) && $ud['name'] === 'WEBHOOK_PATCH_REQUEST_NO_CHANGE';
2598 }
2599 if(!$adopt_update_ok)
2600 {
2601 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2602 'ppco' => 'webhook',
2603 'event' => 'update_adopted_webhook_failed',
2604 'env_setting' => $env,
2605 'id' => $id,
2606 'url' => $url,
2607 'code' => !empty($ur['code']) ? (int)$ur['code'] : 0,
2608 'message' => !empty($ur['message']) ? (string)$ur['message'] : '',
2609 'body' => !empty($ur['body']) ? (string)$ur['body'] : '',
2610 ));
2611 $id = '';
2612 }
2613 }
2614
2615 if($id)
2616 {
2617 self::paypal_checkout_webhook_store_id($id);
2618
2619 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2620 'ppco' => 'webhook',
2621 'event' => $adopted_existing ? 'adopted_webhook' : 'created_webhook',
2622 'env_setting' => $env,
2623 'id' => $id,
2624 'url' => $url,
2625 'code' => $adopted_existing ? 200 : (int)$r['code'],
2626 ));
2627
2628 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2629 return array('id' => $id, 'op' => $adopted_existing ? 'adopted' : 'created', 'env' => $env);
2630 }
2631
2632 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
2633 'ppco' => 'webhook',
2634 'event' => 'create_webhook_failed',
2635 'env_setting' => $env,
2636 'url' => $url,
2637 'code' => !empty($r['code']) ? (int)$r['code'] : 0,
2638 'message' => !empty($r['message']) ? (string)$r['message'] : '',
2639 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2640 ));
2641
2642 $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
2643 return array();
2644 }
2645
2646 /**
2647 * Clears a resolved PayPal Checkout webhook upgrade notice.
2648 *
2649 * @since 260824.0507
2650 *
2651 * @param string $env 'live' or 'sandbox'.
2652 *
2653 * @return void
2654 */
2655 protected static function paypal_checkout_webhook_upgrade_notice_clear($env = '')
2656 {
2657 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
2658 $env_label = ($env === 'sandbox') ? 'Sandbox' : 'Live';
2659 $marker = 's2member-ppco-webhook-upgrade-notice-'.$env;
2660 $legacy_message = 'Your '.$env_label.' webhook could not be updated automatically with the latest required events.';
2661
2662 $notices = (array)get_option('ws_plugin__s2member_notices');
2663 $changed = FALSE;
2664
2665 foreach($notices as $notice_key => $notice)
2666 if(is_array($notice) && !empty($notice['notice']) && (strpos((string)$notice['notice'], $marker) !== FALSE || strpos((string)$notice['notice'], $legacy_message) !== FALSE))
2667 {
2668 unset($notices[$notice_key]);
2669 $changed = TRUE;
2670 }
2671
2672 if($changed)
2673 update_option('ws_plugin__s2member_notices', array_values($notices));
2674 }
2675
2676 /**
2677 * Stores a PayPal Checkout webhook id into ws_plugin__s2member_options for the current env.
2678 *
2679 * @since 260115
2680 *
2681 * @param string $webhook_id Webhook id returned by PayPal.
2682 *
2683 * @return void
2684 */
2685 protected static function paypal_checkout_webhook_store_id($webhook_id)
2686 {
2687 //260820.0427 Preserve the selected environment before option normalization resets the global Checkout environment.
2688 $is_sandbox = self::paypal_checkout_is_sandbox();
2689
2690 $options = get_option('ws_plugin__s2member_options');
2691 if(!is_array($options))
2692 $options = array();
2693
2694 if($is_sandbox)
2695 $options['paypal_checkout_sandbox_webhook_id'] = (string)$webhook_id;
2696 else
2697 $options['paypal_checkout_webhook_id'] = (string)$webhook_id;
2698
2699 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
2700
2701 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
2702
2703 if($is_sandbox)
2704 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_sandbox_webhook_id"] = (string)$webhook_id;
2705 else
2706 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_webhook_id"] = (string)$webhook_id;
2707
2708 //260824.0507 A successful create/update or no-change verification resolves any queued upgrade warning for this environment.
2709 self::paypal_checkout_webhook_upgrade_notice_clear($is_sandbox ? 'sandbox' : 'live');
2710 }
2711 }
2712 }
2713