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

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

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