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

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