PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 261001
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v261001
261001 260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 All 191 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 261001, at src/includes/classes/paypal-utilities.inc.php

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