PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 261001
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v261001
261001 260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 All 191 releases
← All changes | src/includes/classes/paypal-utilities.inc.php +1353 -117 260814 → 261001 View file →
@@ -73,8 +73,40 @@
73 73 return apply_filters("ws_plugin__s2member_paypal_postvars", $postvars, get_defined_vars());
74 74 }
75 75 else return false;
76 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 + }
77 109 else if(!empty($_REQUEST) && is_array($postvars = stripslashes_deep($_REQUEST)))
78 110 {
79 111 foreach($postvars as $key => $value)
80 112 if(preg_match("/^s2member_/", $key))
@@ -88,9 +120,14 @@
88 120
89 121 $postvars = self::paypal_postvars_utf8($postvars);
90 122 $endpoint = ($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "www.sandbox.paypal.com" : "www.paypal.com";
91 123
92 - if(!empty($_REQUEST["s2member_paypal_proxy"]) && !empty($_REQUEST["s2member_paypal_proxy_verification"]) && $_REQUEST["s2member_paypal_proxy_verification"] === c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen())
124 + //260927.2250 Browser PayPal Returns must use the transaction-bound Checkout handoff above; never let the reusable server-to-server proxy credential authenticate them.
125 + if(!empty($_GET["s2member_paypal_return"]) && !empty($_REQUEST["s2member_paypal_proxy"]) && is_string($_REQUEST["s2member_paypal_proxy"]) && $_REQUEST["s2member_paypal_proxy"] === "paypal")
126 + return false;
127 +
128 + //260909.0411 Normalize proxy verification input types and use the standard constant-time comparison helper.
129 + else if(!empty($_REQUEST["s2member_paypal_proxy"]) && is_string($_REQUEST["s2member_paypal_proxy"]) && !empty($_REQUEST["s2member_paypal_proxy_verification"]) && is_string($_REQUEST["s2member_paypal_proxy_verification"]) && is_string($proxy_verification_key = c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen()) && hash_equals($proxy_verification_key, $_REQUEST["s2member_paypal_proxy_verification"]))
93 130 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_REQUEST["s2member_paypal_proxy"])), get_defined_vars());
94 131
95 132 else if(empty($_POST) && !empty($_GET["s2member_paypal_proxy"]) && !empty($_GET["s2member_paypal_proxy_verification"]) && c_ws_plugin__s2member_utils_urls::s2member_sig_ok($_SERVER["REQUEST_URI"], false, false, "s2member_paypal_proxy_verification"))
96 133 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_GET["s2member_paypal_proxy"])), get_defined_vars());
@@ -174,8 +211,106 @@
174 211
175 212 return $postvars; // w/ back. compat keys.
176 213 }
177 214 /**
215 + * Normalizes PayPal Checkout browser-return variables for handoff signing.
216 + *
217 + * @package s2Member\PayPal
218 + * @since 260817
219 + *
220 + * @param array $postvars Browser-return variables.
221 + *
222 + * @return string|bool Canonical payload string, else false.
223 + */
224 + public static function paypal_checkout_return_handoff_payload($postvars)
225 + {
226 + if(!is_array($postvars) || !$postvars)
227 + return false;
228 +
229 + $normalized = array();
230 + foreach($postvars as $key => $value)
231 + {
232 + $key = (string)$key;
233 +
234 + if($key === 's2member_paypal_checkout_handoff')
235 + continue;
236 + if(!is_scalar($value) && $value !== null)
237 + return false;
238 +
239 + $key = preg_replace('/\r\n|\r|\n/', "\r\n", $key);
240 + $value = preg_replace('/\r\n|\r|\n/', "\r\n", (string)$value);
241 + $normalized[$key] = $value;
242 + }
243 + if(!$normalized)
244 + return false;
245 +
246 + ksort($normalized, SORT_STRING);
247 + return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
248 + }
249 + /**
250 + * Generates the private signing key for PayPal Checkout browser-return handoffs.
251 + *
252 + * @package s2Member\PayPal
253 + * @since 260817
254 + *
255 + * @return string Private signing key.
256 + */
257 + public static function paypal_checkout_return_handoff_key()
258 + {
259 + return hash_hmac('sha256', 's2member_paypal_checkout_return_handoff|'.self::paypal_proxy_key_gen(), c_ws_plugin__s2member_utils_encryption::key());
260 + }
261 + /**
262 + * Creates a short-lived PayPal Checkout browser-return handoff.
263 + *
264 + * @package s2Member\PayPal
265 + * @since 260817
266 + *
267 + * @param array $postvars Verified browser-return variables.
268 + *
269 + * @return string Signed handoff token, else an empty string on failure.
270 + */
271 + public static function paypal_checkout_return_handoff_create($postvars)
272 + {
273 + $payload = self::paypal_checkout_return_handoff_payload($postvars);
274 +
275 + if($payload === false)
276 + return '';
277 +
278 + $expires = time() + HOUR_IN_SECONDS;
279 + $signature = hash_hmac('sha256', $expires.'|'.$payload, self::paypal_checkout_return_handoff_key());
280 +
281 + // The browser gets only a transaction-scoped signature; reusable server-side secrets remain private.
282 + return $expires.'.'.$signature;
283 + }
284 + /**
285 + * Verifies a PayPal Checkout browser-return handoff.
286 + *
287 + * @package s2Member\PayPal
288 + * @since 260817
289 + *
290 + * @param string $handoff Signed handoff token.
291 + * @param array $postvars Browser-return variables received by POST.
292 + *
293 + * @return bool TRUE if valid; else FALSE.
294 + */
295 + public static function paypal_checkout_return_handoff_verify($handoff, $postvars)
296 + {
297 + $handoff = trim((string)$handoff);
298 +
299 + if(!preg_match('/^([0-9]{10,12})\.([a-f0-9]{64})$/D', $handoff, $matches))
300 + return false;
301 +
302 + $expires = (int)$matches[1];
303 + $signature = (string)$matches[2];
304 + $payload = self::paypal_checkout_return_handoff_payload($postvars);
305 +
306 + if($payload === false || time() > $expires)
307 + return false;
308 +
309 + $expected = hash_hmac('sha256', $expires.'|'.$payload, self::paypal_checkout_return_handoff_key());
310 + return hash_equals($expected, $signature);
311 + }
312 + /**
178 313 * Generates a PayPal Proxy Key, for simulated IPN responses.
179 314 *
180 315 * @package s2Member\PayPal
181 316 * @since 3.5
@@ -193,10 +328,15 @@
193 328 if(is_multisite() && !is_main_site())
194 329 $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(strtolower($current_blog->domain.$current_blog->path), false, false));
195 330
196 331 else {
197 - $host = ($GLOBALS['WS_PLUGIN__']['s2member']['o']['skip_ipn_domain_validation']) ? parse_url(home_url('/'), PHP_URL_HOST) : $_SERVER["HTTP_HOST"]; //250917
198 - $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(preg_replace("/\:[0-9]+$/", "", strtolower((string) $host)), false, false));
332 + //260909.0217 Normalize host selection so proxy verification behaves consistently across different server configurations.
333 + $site_host = preg_replace("/\:[0-9]+$/", "", strtolower((string)parse_url(home_url('/'), PHP_URL_HOST)));
334 + $request_host = (!empty($_SERVER["HTTP_HOST"]) && is_string($_SERVER["HTTP_HOST"])) ? preg_replace("/\:[0-9]+$/", "", strtolower($_SERVER["HTTP_HOST"])) : '';
335 + $host = ($GLOBALS['WS_PLUGIN__']['s2member']['o']['skip_ipn_domain_validation']) ? $site_host : $request_host;
336 + $host = strlen($host) ? $host : $site_host;
337 + $host = strlen($host) ? $host : 's2member-paypal-proxy'; //260909.0338 Provide a stable final fallback when no usable site host is available.
338 + $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt($host, false, false));
199 339 }
200 340
201 341 return apply_filters("ws_plugin__s2member_paypal_proxy_key_gen", $key, get_defined_vars());
202 342 }
@@ -1250,8 +1390,218 @@
1250 1390 return $r;
1251 1391 }
1252 1392
1253 1393 /**
1394 + * Retrieves a PayPal Checkout order for validation or capture recovery.
1395 + *
1396 + * @since 260817
1397 + *
1398 + * @param string $order_id PayPal Checkout order id.
1399 + *
1400 + * @return array Decoded order response, with __code/__body added; __error on failure.
1401 + */
1402 + public static function paypal_checkout_order_details($order_id = '')
1403 + {
1404 + $order_id = trim((string)$order_id);
1405 +
1406 + if(!$order_id)
1407 + return array('__error' => 'missing_order_id', '__code' => 0, '__body' => '');
1408 +
1409 + $r = self::paypal_checkout_api_request('GET', '/v2/checkout/orders/'.rawurlencode($order_id));
1410 +
1411 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
1412 + $body = !empty($r['body']) ? (string)$r['body'] : '';
1413 + $data = ($body) ? json_decode($body, true) : array();
1414 + $data = is_array($data) ? $data : array();
1415 +
1416 + $data['__code'] = $code;
1417 + $data['__body'] = $body;
1418 +
1419 + if(!($code >= 200 && $code <= 299) || empty($data['id']))
1420 + $data['__error'] = 'order_details_failed';
1421 +
1422 + return $data;
1423 + }
1424 + /**
1425 + * Validates a PayPal Checkout order against the server-side purchase token.
1426 + *
1427 + * @since 260817
1428 + *
1429 + * @param array $order PayPal order representation.
1430 + * @param string $order_id Expected PayPal order id.
1431 + * @param array $token Signed/validated purchase token.
1432 + *
1433 + * @return string Empty string if valid; otherwise a stable error code.
1434 + */
1435 + public static function paypal_checkout_order_validation_error($order = array(), $order_id = '', $token = array())
1436 + {
1437 + if(!is_array($order) || empty($order['id']))
1438 + return 'order_missing';
1439 + if($order_id && (string)$order['id'] !== (string)$order_id)
1440 + return 'order_id_mismatch';
1441 + if(empty($order['intent']) || strtoupper((string)$order['intent']) !== 'CAPTURE')
1442 + return 'order_intent_mismatch';
1443 + if(empty($order['purchase_units'][0]) || !is_array($order['purchase_units'][0]))
1444 + return 'order_purchase_unit_missing';
1445 +
1446 + $pu = $order['purchase_units'][0];
1447 + $invoice = isset($pu['invoice_id']) ? (string)$pu['invoice_id'] : '';
1448 + $amount = isset($pu['amount']['value']) ? (string)$pu['amount']['value'] : '';
1449 + $cc = isset($pu['amount']['currency_code']) ? strtoupper((string)$pu['amount']['currency_code']) : '';
1450 +
1451 + if(!empty($token['invoice']) && $invoice !== (string)$token['invoice'])
1452 + return 'order_invoice_mismatch';
1453 + if(!empty($token['amount']) && (!$amount || number_format((float)$amount, 2, '.', '') !== number_format((float)$token['amount'], 2, '.', '')))
1454 + return 'order_amount_mismatch';
1455 + if(!empty($token['cc']) && $cc !== strtoupper((string)$token['cc']))
1456 + return 'order_currency_mismatch';
1457 +
1458 + $custom = !empty($token['custom']) ? (string)$token['custom'] : '';
1459 + if($custom && strlen($custom) <= 127 && (!isset($pu['custom_id']) || (string)$pu['custom_id'] !== $custom))
1460 + return 'order_custom_mismatch';
1461 +
1462 + return '';
1463 + }
1464 + /**
1465 + * Validates that a PayPal Checkout order contains a completed capture for the purchase token.
1466 + *
1467 + * @since 260817
1468 + *
1469 + * @param array $order PayPal order representation.
1470 + * @param string $order_id Expected PayPal order id.
1471 + * @param array $token Signed/validated purchase token.
1472 + *
1473 + * @return string Empty string if complete and valid; otherwise a stable error code.
1474 + */
1475 + public static function paypal_checkout_order_completion_error($order = array(), $order_id = '', $token = array())
1476 + {
1477 + if(($error = self::paypal_checkout_order_validation_error($order, $order_id, $token)))
1478 + return $error;
1479 + if(empty($order['status']) || strtoupper((string)$order['status']) !== 'COMPLETED')
1480 + return 'order_not_completed';
1481 +
1482 + $capture = (!empty($order['purchase_units'][0]['payments']['captures'][0]) && is_array($order['purchase_units'][0]['payments']['captures'][0])) ? $order['purchase_units'][0]['payments']['captures'][0] : array();
1483 + if(empty($capture['id']) || empty($capture['status']) || strtoupper((string)$capture['status']) !== 'COMPLETED')
1484 + return 'capture_missing_fields';
1485 +
1486 + $amount = !empty($capture['amount']['value']) ? (string)$capture['amount']['value'] : '';
1487 + $cc = !empty($capture['amount']['currency_code']) ? strtoupper((string)$capture['amount']['currency_code']) : '';
1488 +
1489 + if(!empty($token['amount']) && (!$amount || number_format((float)$amount, 2, '.', '') !== number_format((float)$token['amount'], 2, '.', '')))
1490 + return 'capture_amount_mismatch';
1491 + if(!empty($token['cc']) && $cc !== strtoupper((string)$token['cc']))
1492 + return 'capture_currency_mismatch';
1493 + if(empty($order['payer']['email_address']))
1494 + return 'capture_missing_fields';
1495 +
1496 + return '';
1497 + }
1498 +
1499 + /**
1500 + * Returns the first PayPal capture ID/status from an order representation.
1501 + *
1502 + * @since 260902.0635
1503 + *
1504 + * @param array $order PayPal order representation.
1505 + *
1506 + * @return array Capture snapshot with id/status.
1507 + */
1508 + public static function paypal_checkout_order_capture_snapshot($order = array())
1509 + {
1510 + $capture = (!empty($order['purchase_units'][0]['payments']['captures'][0]) && is_array($order['purchase_units'][0]['payments']['captures'][0])) ? $order['purchase_units'][0]['payments']['captures'][0] : array();
1511 +
1512 + return array(
1513 + 'id' => !empty($capture['id']) ? (string)$capture['id'] : '',
1514 + 'status' => !empty($capture['status']) ? strtoupper((string)$capture['status']) : '',
1515 + );
1516 + }
1517 +
1518 + /**
1519 + * Extracts a Gateway Checkout ID from a modern PayPal Checkout Pro-Form invoice.
1520 + *
1521 + * @since 260902.0635
1522 + *
1523 + * @param string $invoice Membership (`s2mpf-`) or Specific Post/Page (`s2msp-`) invoice.
1524 + *
1525 + * @return string Gateway Checkout ID, else an empty string.
1526 + */
1527 + public static function paypal_checkout_gateway_checkout_id_from_invoice($invoice = '')
1528 + {
1529 + $invoice = (string)$invoice;
1530 + $gateway_checkout_id = '';
1531 +
1532 + if(strpos($invoice, 's2mpf-') === 0)
1533 + $gateway_checkout_id = substr($invoice, strlen('s2mpf-'));
1534 + else if(strpos($invoice, 's2msp-') === 0)
1535 + $gateway_checkout_id = substr($invoice, strlen('s2msp-'));
1536 + else if(strpos($invoice, 's2mb-') === 0) //260928.1515 Standalone Framework buttons use their own invoice namespace, separate from Pro-Form account preparation.
1537 + $gateway_checkout_id = substr($invoice, strlen('s2mb-'));
1538 +
1539 + return c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id) ? $gateway_checkout_id : '';
1540 + }
1541 +
1542 + /**
1543 + * Starts or resumes a standalone Framework PayPal Checkout button using shared durable state.
1544 + *
1545 + * @since 260928.1520
1546 + *
1547 + * @param array $token Verified, signed standalone button purchase token.
1548 + * @param bool $create_allowed True only before starting provider work.
1549 + * @return array Operation result containing ok and error.
1550 + */
1551 + public static function paypal_checkout_button_gateway_checkout_prepare($token = array(), $create_allowed = FALSE)
1552 + {
1553 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1554 + if(strpos($invoice, 's2mb-') !== 0)
1555 + return array('ok' => TRUE, 'coordinator' => FALSE, 'error' => ''); // Existing in-flight button tokens and Pro-Forms use their established paths.
1556 +
1557 + $id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
1558 + $browser_token = !empty($token['gateway_checkout_token']) ? (string)$token['gateway_checkout_token'] : '';
1559 + if(!$id || empty($token['gateway_checkout_id']) || !hash_equals($id, (string)$token['gateway_checkout_id'])
1560 + || !c_ws_plugin__s2member_gateway_checkouts::browser_token_verify($id, $browser_token))
1561 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_identity_invalid');
1562 +
1563 + $operation = (!empty($token['rr']) && strtoupper((string)$token['rr']) !== 'BN') ? 'subscription' : 'payment';
1564 + $purchase_terms = (array)$token;
1565 + unset($purchase_terms['exp'], $purchase_terms['gateway_checkout_token']); //260928.1520 Token renewal does not alter the underlying purchase contract.
1566 + $fingerprint = c_ws_plugin__s2member_gateway_checkouts::purchase_fingerprint($purchase_terms);
1567 +
1568 + if($create_allowed)
1569 + {
1570 + //260928.1705 Do not rewrite a bound option on every retry: create_or_resume() may otherwise overwrite provider/fulfillment updates committed concurrently by a webhook.
1571 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state_uncached($id);
1572 + if(!$state)
1573 + $state = c_ws_plugin__s2member_gateway_checkouts::create_or_resume('paypal_checkout', $operation, $id, $browser_token, $fingerprint, get_current_user_id());
1574 + else if(!empty($state['user_id']) && (int)$state['user_id'] !== (int)get_current_user_id())
1575 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_user_mismatch');
1576 + }
1577 + else
1578 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state_uncached($id);
1579 +
1580 + //260928.1520 Reject a checkout returned under a replacement identity: the verified button token and PayPal invoice must keep pointing to the same durable record.
1581 + if(!$state || !hash_equals($id, (string)$state['id']) || (string)$state['gateway'] !== 'paypal_checkout'
1582 + || (string)$state['operation'] !== $operation || !hash_equals($fingerprint, (string)$state['purchase_fingerprint']))
1583 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_mismatch');
1584 +
1585 + $private = c_ws_plugin__s2member_gateway_checkouts::private_context_get($id);
1586 + if($private === FALSE)
1587 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_private_context_invalid');
1588 +
1589 + if(empty($private['paypal_checkout']['token']))
1590 + {
1591 + if(!$create_allowed)
1592 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_purchase_context_missing');
1593 + $private = (array)$private;
1594 + $private['paypal_checkout'] = !empty($private['paypal_checkout']) && is_array($private['paypal_checkout']) ? $private['paypal_checkout'] : array();
1595 + //260928.1520 The first provider operation durably stores the authenticated purchase token for webhook-only fulfillment. No password/card data is stored.
1596 + $private['paypal_checkout']['token'] = $token;
1597 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($id, $private))
1598 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_purchase_context_save_failed');
1599 + }
1600 + return array('ok' => TRUE, 'coordinator' => TRUE, 'error' => '', 'gateway_checkout_id' => $id);
1601 + }
1602 +
1603 + /**
1254 1604 * Creates a PayPal Checkout order for one-time (Buy Now) purchases.
1255 1605 *
1256 1606 * This must be server-side to prevent client-side manipulation of amount, item_number,
1257 1607 * custom fields, etc. The resulting order id is returned to the JS SDK or used for
@@ -1264,81 +1614,194 @@
1264 1614 * @return array API request result array from paypal_checkout_api_request().
1265 1615 */
1266 1616 public static function paypal_checkout_order_create($token = array())
1267 1617 {
1618 + if(!is_array($token))
1619 + return array('__error' => 'invalid_token');
1620 +
1268 1621 // token: invoice, custom, item_name, item_number, amount, cc, ns, return, cancel.
1269 - $invoice = (string)$token['invoice'];
1270 - $custom = (string)$token['custom'];
1271 - $amount = (string)$token['amount'];
1272 - $cc = strtoupper((string)$token['cc']);
1622 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1623 + $custom = isset($token['custom']) ? (string)$token['custom'] : '';
1624 + $amount = isset($token['amount']) ? (string)$token['amount'] : '';
1625 + $cc = !empty($token['cc']) ? strtoupper((string)$token['cc']) : '';
1626 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
1627 + $gateway_checkout_lock = '';
1273 1628
1274 - $item_name = trim((string)$token['item_name']);
1275 - if(!$item_name)
1276 - $item_name = 's2Member Purchase';
1629 + if($gateway_checkout_id)
1630 + {
1631 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1632 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1633 + return array('__error' => 'gateway_checkout_invalid');
1277 1634
1278 - // PayPal limits various fields; keep item name within common limits.
1279 - if(strlen($item_name) > 127)
1280 - $item_name = substr($item_name, 0, 127);
1635 + //260902.0635 Return an already-persisted PayPal order before another provider create; a lost browser response can therefore resume the same logical purchase.
1636 + if(!empty($gateway_checkout['gateway_ids']['order_id']))
1637 + return array('id' => (string)$gateway_checkout['gateway_ids']['order_id'], 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '');
1281 1638
1282 - $item_sku = trim((string)$token['item_number']);
1283 - if(strlen($item_sku) > 127)
1284 - $item_sku = substr($item_sku, 0, 127);
1639 + //260907.1820 Lock the logical checkout and then re-read it; concurrent browser requests can both arrive before either has observed the PayPal order ID persisted by the other.
1640 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
1641 + if(!$gateway_checkout_lock)
1642 + return array('__error' => 'gateway_checkout_busy');
1285 1643
1286 - $purchase_unit = array(
1287 - 'invoice_id' => $invoice,
1288 - 'amount' => array(
1289 - 'currency_code' => $cc,
1290 - 'value' => $amount,
1291 - 'breakdown' => array(
1292 - 'item_total' => array(
1293 - 'currency_code' => $cc,
1294 - 'value' => $amount,
1295 - ),
1644 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1645 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1646 + {
1647 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1648 + return array('__error' => 'gateway_checkout_invalid');
1649 + }
1650 + if(!empty($gateway_checkout['gateway_ids']['order_id']))
1651 + {
1652 + $order_id = (string)$gateway_checkout['gateway_ids']['order_id'];
1653 + $status = !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '';
1654 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1655 + return array('id' => $order_id, 'status' => $status);
1656 + }
1657 + }
1658 +
1659 + try
1660 + {
1661 + $item_name = !empty($token['item_name']) ? trim((string)$token['item_name']) : '';
1662 + if(!$item_name)
1663 + $item_name = 's2Member Purchase';
1664 + if(strlen($item_name) > 127)
1665 + $item_name = substr($item_name, 0, 127);
1666 +
1667 + $item_sku = !empty($token['item_number']) ? trim((string)$token['item_number']) : '';
1668 + if(strlen($item_sku) > 127)
1669 + $item_sku = substr($item_sku, 0, 127);
1670 +
1671 + //260817.2119 Keep normal Checkout pricing unchanged; only split subtotal/tax when a Pro-Form token supplies a breakdown that reconciles exactly to the charged total.
1672 + $item_amount = $amount;
1673 + $tax_amount = '';
1674 + if(isset($token['sub_total'], $token['tax']) && is_numeric($token['sub_total']) && is_numeric($token['tax'])
1675 + && number_format((float)$token['sub_total'] + (float)$token['tax'], 2, '.', '') === number_format((float)$amount, 2, '.', ''))
1676 + {
1677 + $item_amount = (string)$token['sub_total'];
1678 + $tax_amount = (string)$token['tax'];
1679 + }
1680 +
1681 + $purchase_unit = array(
1682 + 'invoice_id' => $invoice,
1683 + 'amount' => array(
1684 + 'currency_code' => $cc,
1685 + 'value' => $amount,
1686 + 'breakdown' => array('item_total' => array('currency_code' => $cc, 'value' => $item_amount)),
1296 1687 ),
1297 - ),
1298 - 'description' => $item_name,
1299 - 'items' => array(
1300 - array(
1301 - 'name' => $item_name,
1302 - 'quantity' => '1',
1303 - 'unit_amount' => array(
1304 - 'currency_code' => $cc,
1305 - 'value' => $amount,
1306 - ),
1688 + 'description' => $item_name,
1689 + 'items' => array(array('name' => $item_name, 'quantity' => '1', 'unit_amount' => array('currency_code' => $cc, 'value' => $item_amount))),
1690 + );
1691 + if($tax_amount !== '' && (float)$tax_amount > 0)
1692 + {
1693 + $purchase_unit['amount']['breakdown']['tax_total'] = array('currency_code' => $cc, 'value' => $tax_amount);
1694 + $purchase_unit['items'][0]['tax'] = array('currency_code' => $cc, 'value' => $tax_amount);
1695 + }
1696 + if($item_sku)
1697 + $purchase_unit['items'][0]['sku'] = $item_sku;
1698 + if($custom && strlen($custom) <= 127)
1699 + $purchase_unit['custom_id'] = $custom;
1700 +
1701 + $body = array(
1702 + 'intent' => 'CAPTURE',
1703 + 'purchase_units' => array($purchase_unit),
1704 + 'application_context' => array(
1705 + 'user_action' => 'PAY_NOW',
1706 + 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1707 + 'return_url' => !empty($token['return']) ? (string)$token['return'] : '',
1708 + 'cancel_url' => !empty($token['cancel']) ? (string)$token['cancel'] : '',
1307 1709 ),
1308 - ),
1309 - );
1710 + );
1310 1711
1311 - if($item_sku)
1312 - $purchase_unit['items'][0]['sku'] = $item_sku;
1712 + //260907.1820 Derive PayPal-Request-Id from durable logical-checkout identity, not a browser request, so reloads and immediate ambiguous retries address the same provider create operation.
1713 + $request_id = $gateway_checkout_id ? 's2m-ppco-order-'.str_replace('-', '', $gateway_checkout_id) : 's2m-ppco-order-'.md5($invoice);
1714 + $headers = array('PayPal-Request-Id' => $request_id);
1313 1715
1314 - // PayPal limits custom_id length; keep it short/consistent.
1315 - if($custom && strlen($custom) <= 127)
1316 - $purchase_unit['custom_id'] = $custom;
1716 + if($gateway_checkout_id)
1717 + {
1718 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
1719 + if($private_context === FALSE)
1720 + return array('__error' => 'gateway_checkout_private_context_failed');
1721 + $private_context = (array)$private_context;
1722 + $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array();
1723 + //260902.0635 Save the validated token before contacting PayPal so a later capture webhook has enough trusted server-side context to finish an interrupted browser checkout.
1724 + //260928.1615 An anchor/url checkout temporarily substitutes PayPal's internal approval-return URL for provider creation; keep the canonical, previously validated button token so a capture webhook returns the buyer to the original success page.
1725 + if(strpos($invoice, 's2mb-') !== 0 || empty($private_context['paypal_checkout']['token']))
1726 + $private_context['paypal_checkout']['token'] = $token;
1727 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
1728 + return array('__error' => 'gateway_checkout_private_context_failed');
1317 1729
1318 - $body = array(
1319 - 'intent' => 'CAPTURE',
1320 - 'purchase_units' => array($purchase_unit),
1321 - 'application_context' => array(
1322 - 'user_action' => 'PAY_NOW',
1323 - 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1324 - 'return_url' => (string)$token['return'],
1325 - 'cancel_url' => (string)$token['cancel'],
1326 - ),
1327 - );
1730 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1731 + $create_started_at = !empty($context['paypal_order_create_started_at']) ? (int)$context['paypal_order_create_started_at'] : 0;
1732 + //260902.0635 PayPal normally retains Orders request IDs for six hours; if no order ID ever came back, the unknown order never reached browser approval and a fresh create is safe after that window.
1733 + if($create_started_at && $create_started_at <= time() - (6 * HOUR_IN_SECONDS))
1734 + {
1735 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1736 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
1737 + if(!$gateway_checkout)
1738 + return array('__error' => 'gateway_checkout_save_failed');
1739 + $create_started_at = 0;
1740 + }
1741 + if(!$create_started_at)
1742 + {
1743 + $context['paypal_order_create_started_at'] = time();
1744 + $context['paypal_order_request_id'] = $request_id;
1745 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CREATE_PENDING', 'context' => $context));
1746 + if(!$gateway_checkout)
1747 + return array('__error' => 'gateway_checkout_save_failed');
1748 + }
1749 + }
1328 1750
1329 - // Idempotency: stable per invoice for create-order retries.
1330 - $headers = array(
1331 - 'PayPal-Request-Id' => 's2m-ppco-order-'.md5($invoice),
1332 - );
1751 + $data = array();
1752 + $code = 0;
1753 + $ambiguous = FALSE;
1754 + //260907.1820 Retry only an ambiguous transport/provider result, always with the same PayPal-Request-Id; deterministic rejection must not be treated as a possibly-created order.
1755 + for($attempt = 0; $attempt < 2; $attempt++)
1756 + {
1757 + $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1758 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
1759 + $response_body = !empty($r['body']) ? (string)$r['body'] : '';
1760 + $data = $response_body ? json_decode($response_body, true) : array();
1761 + $data = is_array($data) ? $data : array();
1762 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
1333 1763
1334 - $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1764 + if($code >= 200 && $code <= 299 && !empty($data['id']))
1765 + break;
1766 + if(!$ambiguous)
1767 + break;
1768 + }
1335 1769
1336 - $data = array();
1337 - if(!empty($r['body']) && is_string($r['body']))
1338 - $data = json_decode($r['body'], true);
1770 + if($code >= 200 && $code <= 299 && !empty($data['id']))
1771 + {
1772 + set_transient('s2m_ppco_order_bind_'.md5($invoice), array('order_id' => (string)$data['id'], 'invoice' => $invoice, 'amount' => $amount, 'cc' => $cc, 'custom' => $custom), 3 * HOUR_IN_SECONDS);
1339 1773
1340 - return is_array($data) ? $data : array();
1774 + if($gateway_checkout_id)
1775 + {
1776 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
1777 + $gateway_ids['order_id'] = (string)$data['id'];
1778 + $status = !empty($data['status']) ? 'ORDER_'.strtoupper((string)$data['status']) : 'ORDER_CREATED';
1779 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1780 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1781 + //260902.0635 Persist the PayPal order ID before returning it to the browser; a reload can then reuse it without a second provider create.
1782 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
1783 + return array('__error' => 'gateway_checkout_save_failed');
1784 + }
1785 + }
1786 + else if($gateway_checkout_id && !$ambiguous)
1787 + {
1788 + //260907.1820 A deterministic create failure proves no unknown-success recovery is needed; clear CREATE_PENDING breadcrumbs so a later validated attempt is not stranded behind stale ambiguity state.
1789 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1790 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1791 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
1792 + }
1793 +
1794 + if($gateway_checkout_id && $ambiguous && !($code >= 200 && $code <= 299 && !empty($data['id'])))
1795 + return array('__error' => 'order_create_unresolved');
1796 +
1797 + return $data;
1798 + }
1799 + finally
1800 + {
1801 + if($gateway_checkout_id && $gateway_checkout_lock)
1802 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1803 + }
1341 1804 }
1342 1805
1343 1806 /**
1344 1807 * Retrieves PayPal Checkout subscription details via the Subscriptions REST API.
@@ -1451,30 +1914,607 @@
1451 1914 public static function paypal_checkout_order_capture($order_id = '', $token = array())
1452 1915 {
1453 1916 $order_id = trim((string)$order_id);
1454 1917 if(!$order_id)
1455 - return array();
1918 + return array('__error' => 'missing_order_id');
1456 1919
1457 - // Idempotency: stable per order capture retries.
1458 - $headers = array(
1459 - 'PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id),
1920 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1921 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
1922 + $binding_name = $invoice ? 's2m_ppco_order_bind_'.md5($invoice) : '';
1923 + $binding = $binding_name ? get_transient($binding_name) : false;
1924 + $gateway_checkout_lock = '';
1925 +
1926 + if($gateway_checkout_id)
1927 + {
1928 + //260907.1820 For coordinator-backed captures, the order ID already persisted server-side is authoritative; never let a browser-supplied order ID rebind this logical checkout to another PayPal resource.
1929 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1930 + $expected_order_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '';
1931 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment' || !$expected_order_id || !hash_equals($expected_order_id, $order_id))
1932 + return array('__error' => 'gateway_checkout_order_mismatch');
1933 +
1934 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
1935 + if(!$gateway_checkout_lock)
1936 + return array('__error' => 'gateway_checkout_busy');
1937 + }
1938 + else if(is_array($binding))
1939 + {
1940 + $binding_matches = (!empty($binding['order_id']) && (string)$binding['order_id'] === $order_id
1941 + && isset($binding['invoice']) && (string)$binding['invoice'] === $invoice
1942 + && isset($binding['amount']) && number_format((float)$binding['amount'], 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
1943 + && isset($binding['cc']) && strtoupper((string)$binding['cc']) === strtoupper((string)$token['cc'])
1944 + && isset($binding['custom']) && (string)$binding['custom'] === (string)$token['custom']);
1945 + if(!$binding_matches)
1946 + return array('__error' => 'order_binding_mismatch');
1947 + }
1948 +
1949 + $capture_lock = $gateway_checkout_id ? '' : 's2m_ppco_capture_lock_'.md5($order_id);
1950 + if(!$gateway_checkout_id && !self::dedupe_lock_acquire($capture_lock, 300))
1951 + return array('__error' => 'capture_in_progress');
1952 +
1953 + try
1954 + {
1955 + if($gateway_checkout_id)
1956 + {
1957 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1958 + if(!$gateway_checkout || empty($gateway_checkout['gateway_ids']['order_id']) || !hash_equals((string)$gateway_checkout['gateway_ids']['order_id'], $order_id))
1959 + return array('__error' => 'gateway_checkout_order_mismatch');
1960 +
1961 + $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : '';
1962 + //260907.1820 Terminal capture failure is sticky for this logical checkout; recovery must start a fresh validated checkout instead of attempting another capture against the failed order.
1963 + if(in_array($gateway_status, array('CAPTURE_DENIED', 'CAPTURE_FAILED', 'CAPTURE_DECLINED'), TRUE))
1964 + return array('__error' => strtolower($gateway_status));
1965 + }
1966 +
1967 + //260902.0635 Once a capture is pending, do not POST another capture; read PayPal's current order state and let webhooks/browser recovery converge on the same capture.
1968 + $read_only = ($gateway_checkout_id && !empty($gateway_checkout['gateway_status']) && strtoupper((string)$gateway_checkout['gateway_status']) === 'CAPTURE_PENDING');
1969 + if(!is_array($binding) || $gateway_checkout_id || $read_only)
1970 + {
1971 + $details = self::paypal_checkout_order_details($order_id);
1972 + if(!empty($details['__error']))
1973 + return $details;
1974 + if(($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
1975 + return array('__error' => $validation_error);
1976 +
1977 + $snapshot = self::paypal_checkout_order_capture_snapshot($details);
1978 + if($snapshot['id'] && $snapshot['status'])
1979 + {
1980 + if($gateway_checkout_id)
1981 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
1982 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($details, $order_id, $token))
1983 + return $details;
1984 + if($snapshot['status'] === 'PENDING')
1985 + return array_merge($details, array('__error' => 'capture_pending'));
1986 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
1987 + return array_merge($details, array('__error' => 'capture_'.strtolower($snapshot['status'])));
1988 + }
1989 +
1990 + if($read_only)
1991 + return array_merge($details, array('__error' => 'capture_pending'));
1992 + if(!empty($details['status']) && strtoupper((string)$details['status']) === 'COMPLETED')
1993 + return array('__error' => self::paypal_checkout_order_completion_error($details, $order_id, $token));
1994 + if(empty($details['status']) || strtoupper((string)$details['status']) !== 'APPROVED')
1995 + return array('__error' => 'order_not_approved');
1996 + }
1997 +
1998 + if($gateway_checkout_id)
1999 + {
2000 + //260907.1820 Persist CAPTURE_PENDING before the provider POST; if PHP dies after PayPal receives the capture, the next request will recover/read the existing attempt instead of issuing a second capture.
2001 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2002 + $context['paypal_capture_started_at'] = !empty($context['paypal_capture_started_at']) ? (int)$context['paypal_capture_started_at'] : time();
2003 + $context['paypal_capture_request_id'] = 's2m-ppco-cap-'.md5($order_id);
2004 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CAPTURE_PENDING', 'context' => $context));
2005 + if(!$gateway_checkout)
2006 + return array('__error' => 'gateway_checkout_save_failed');
2007 + }
2008 +
2009 + //260907.1820 Immediate ambiguous capture retries reuse this same request ID; once a real PENDING capture is observed, later browser requests are read-only and do not POST capture again.
2010 + $headers = array('PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id), 'Prefer' => 'return=representation');
2011 + $r = array();
2012 + $data = array();
2013 + $ambiguous = FALSE;
2014 + for($attempt = 0; $attempt < 2; $attempt++)
2015 + {
2016 + $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
2017 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
2018 + $body = !empty($r['body']) ? (string)$r['body'] : '';
2019 + $data = $body ? json_decode($body, true) : array();
2020 + $data = is_array($data) ? $data : array();
2021 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500);
2022 + if($code >= 200 && $code <= 299)
2023 + break;
2024 + if(!$ambiguous)
2025 + break;
2026 + }
2027 +
2028 + if($code >= 200 && $code <= 299)
2029 + {
2030 + $snapshot = self::paypal_checkout_order_capture_snapshot($data);
2031 + if($snapshot['id'] && $snapshot['status'])
2032 + {
2033 + if($gateway_checkout_id)
2034 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
2035 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($data, $order_id, $token))
2036 + {
2037 + if($binding_name) delete_transient($binding_name);
2038 + return $data;
2039 + }
2040 + if($snapshot['status'] === 'PENDING')
2041 + return array_merge($data, array('__error' => 'capture_pending'));
2042 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
2043 + return array_merge($data, array('__error' => 'capture_'.strtolower($snapshot['status'])));
2044 + }
2045 + }
2046 +
2047 + //260902.0635 Resolve ambiguous/incomplete capture responses by reading PayPal's current order state; never issue a second capture after a known PENDING capture exists.
2048 + $details = self::paypal_checkout_order_details($order_id);
2049 + if(empty($details['__error']) && !($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
2050 + {
2051 + $snapshot = self::paypal_checkout_order_capture_snapshot($details);
2052 + if($snapshot['id'] && $snapshot['status'])
2053 + {
2054 + if($gateway_checkout_id)
2055 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
2056 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($details, $order_id, $token))
2057 + {
2058 + if($binding_name) delete_transient($binding_name);
2059 + return $details;
2060 + }
2061 + if($snapshot['status'] === 'PENDING')
2062 + return array_merge($details, array('__error' => 'capture_pending'));
2063 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
2064 + return array_merge($details, array('__error' => 'capture_'.strtolower($snapshot['status'])));
2065 + }
2066 + }
2067 +
2068 + if($gateway_checkout_id && $ambiguous)
2069 + return array('__error' => 'order_capture_unresolved');
2070 + if(!empty($details['__error']))
2071 + return $details;
2072 + return array('__error' => 'order_capture_failed', '__code' => !empty($r['code']) ? (int)$r['code'] : 0, '__body' => !empty($r['body']) ? (string)$r['body'] : '');
2073 + }
2074 + finally
2075 + {
2076 + if($gateway_checkout_id && $gateway_checkout_lock)
2077 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2078 + else if(!$gateway_checkout_id && $capture_lock)
2079 + self::dedupe_lock_release($capture_lock);
2080 + }
2081 + }
2082 +
2083 + /**
2084 + * Reconciles a one-time PayPal order/capture into Gateway Checkout state.
2085 + *
2086 + * @since 260902.0635
2087 + */
2088 + public static function paypal_checkout_order_gateway_checkout_recover($invoice = '', $order_id = '', $capture_id = '', $capture_status = '', $via = 'webhook', $gateway_checkout_lock = '')
2089 + {
2090 + $gateway_checkout_id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2091 + $order_id = trim((string)$order_id);
2092 + $capture_id = trim((string)$capture_id);
2093 + $capture_status = strtoupper(trim((string)$capture_status));
2094 + $owns_lock = FALSE;
2095 +
2096 + if(!$gateway_checkout_id || !$order_id)
2097 + return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout');
2098 +
2099 + if(!$gateway_checkout_lock)
2100 + {
2101 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60);
2102 + if(!$gateway_checkout_lock)
2103 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id);
2104 + $owns_lock = TRUE;
2105 + }
2106 +
2107 + try
2108 + {
2109 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2110 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2111 + return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout');
2112 +
2113 + //260907.1820 Provider identities are immutable once learned: browser/webhook reconciliation may advance status only for the same PayPal order/capture and must never rebind a checkout to conflicting IDs.
2114 + $existing_order_id = !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '';
2115 + $existing_capture_id = !empty($gateway_checkout['gateway_ids']['capture_id']) ? (string)$gateway_checkout['gateway_ids']['capture_id'] : '';
2116 + if($existing_order_id && !hash_equals($existing_order_id, $order_id))
2117 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_order_conflict', 'gateway_checkout_id' => $gateway_checkout_id);
2118 + if($existing_capture_id && $capture_id && !hash_equals($existing_capture_id, $capture_id))
2119 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_capture_conflict', 'gateway_checkout_id' => $gateway_checkout_id);
2120 +
2121 + $existing_gateway_status = strtoupper((string)$gateway_checkout['gateway_status']);
2122 + //260902.0646 Provider finality is monotonic; stale browser/webhook observations must never downgrade a capture that already completed or reached a terminal failure.
2123 + if(in_array($existing_gateway_status, array('CAPTURE_COMPLETED', 'CAPTURE_DENIED', 'CAPTURE_FAILED', 'CAPTURE_DECLINED'), TRUE))
2124 + return array('handled' => TRUE, 'ok' => TRUE, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'order_id' => $existing_order_id ? $existing_order_id : $order_id, 'capture_id' => $existing_capture_id ? $existing_capture_id : $capture_id, 'status' => $existing_gateway_status);
2125 +
2126 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2127 + $gateway_ids['order_id'] = $order_id;
2128 + if($capture_id)
2129 + $gateway_ids['capture_id'] = $capture_id;
2130 +
2131 + $status = $capture_status ? 'CAPTURE_'.$capture_status : (!empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : 'ORDER_CREATED');
2132 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2133 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
2134 + if($capture_status && $capture_status !== 'PENDING')
2135 + unset($context['paypal_capture_started_at'], $context['paypal_capture_request_id']);
2136 + if($via === 'webhook')
2137 + {
2138 + //260902.0635 Preserve a compact breadcrumb for the future admin diagnostics screen without retaining raw gateway payloads.
2139 + $context['paypal_capture_recovered_at'] = time();
2140 + $context['paypal_capture_recovered_via'] = 'webhook';
2141 + }
2142 +
2143 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
2144 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_save_failed', 'gateway_checkout_id' => $gateway_checkout_id);
2145 +
2146 + return array('handled' => TRUE, 'ok' => TRUE, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'order_id' => $order_id, 'capture_id' => $capture_id, 'status' => $status);
2147 + }
2148 + finally
2149 + {
2150 + if($owns_lock && $gateway_checkout_lock)
2151 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2152 + }
2153 + }
2154 +
2155 + /**
2156 + * Fulfills one completed coordinator-backed PayPal order and saves its browser result.
2157 + *
2158 + * @since 260902.0635
2159 + */
2160 + public static function paypal_checkout_order_fulfill($order = array(), $token = array())
2161 + {
2162 + $order_id = !empty($order['id']) ? (string)$order['id'] : '';
2163 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
2164 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2165 +
2166 + if(!$gateway_checkout_id || ($completion_error = self::paypal_checkout_order_completion_error($order, $order_id, $token)))
2167 + return array('ok' => FALSE, 'error' => $completion_error ? $completion_error : 'gateway_checkout_invalid');
2168 +
2169 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2170 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2171 + return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid');
2172 +
2173 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
2174 + if($private_context === FALSE)
2175 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed');
2176 + //260907.1820 Gateway Checkout's fulfilled result is the outer browser/webhook convergence checkpoint; paypal_checkout_notify_once() remains the inner transaction-level entitlement dedupe.
2177 + if((string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']))
2178 + return array_merge(array('ok' => TRUE, 'processed' => FALSE, 'duplicate' => TRUE), $private_context['paypal_checkout']['fulfillment_result']);
2179 +
2180 + $capture = $order['purchase_units'][0]['payments']['captures'][0];
2181 + $pu_cap_id = (string)$capture['id'];
2182 + $paypal = array(
2183 + 'txn_type' => 'web_accept', 'payment_status' => 'Completed', 'subscr_gateway' => 'paypal',
2184 + 'txn_id' => $pu_cap_id, 'subscr_id' => $pu_cap_id, 'subscr_baid' => $pu_cap_id, 'subscr_cid' => $pu_cap_id,
2185 + 'mc_gross' => (string)$capture['amount']['value'], 'mc_currency' => strtoupper((string)$capture['amount']['currency_code']),
2186 + 'invoice' => $invoice, 'custom' => isset($token['custom']) ? (string)$token['custom'] : '',
2187 + 'item_name' => isset($token['item_name']) ? (string)$token['item_name'] : '', 'item_number' => isset($token['item_number']) ? (string)$token['item_number'] : '',
2188 + 'payer_email' => !empty($order['payer']['email_address']) ? (string)$order['payer']['email_address'] : (!empty($token['payer_email']) ? (string)$token['payer_email'] : ''),
2189 + 'first_name' => !empty($order['payer']['name']['given_name']) ? (string)$order['payer']['name']['given_name'] : (!empty($token['first_name']) ? (string)$token['first_name'] : ''),
2190 + 'last_name' => !empty($order['payer']['name']['surname']) ? (string)$order['payer']['name']['surname'] : (!empty($token['last_name']) ? (string)$token['last_name'] : ''),
2191 + 'option_name1' => isset($token['on0']) ? (string)$token['on0'] : '', 'option_selection1' => isset($token['os0']) ? (string)$token['os0'] : '',
2192 + 'option_name2' => isset($token['on1']) ? (string)$token['on1'] : '', 'option_selection2' => isset($token['os1']) ? (string)$token['os1'] : '',
1460 2193 );
2194 + if(isset($token['tax']))
2195 + $paypal['tax'] = (string)$token['tax'];
1461 2196
1462 - $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
2197 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
2198 + $notify_extra = array();
2199 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
2200 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
2201 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
2202 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
1463 2203
1464 - $data = array();
1465 - if(!empty($r['body']) && is_string($r['body']))
1466 - $data = json_decode($r['body'], true);
2204 + //260907.1820 Keep the established PayPal Notify path authoritative for entitlement side effects, keyed by capture ID so simultaneous browser/webhook completion cannot process the same transaction twice.
2205 + $notify_result = self::paypal_checkout_notify_once($paypal, 's2m_ppco_capture_done_'.md5($pu_cap_id), $proxy_use, $notify_extra);
2206 + if(empty($notify_result['ok']))
2207 + return array('ok' => FALSE, 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed');
1467 2208
1468 - return is_array($data) ? $data : array();
2209 + $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', !empty($token['return']) ? (string)$token['return'] : home_url('/'));
2210 + $return_post = array_merge($paypal, array('s2member_paypal_proxy' => 'paypal', 's2member_paypal_proxy_use' => $proxy_use));
2211 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
2212 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
2213 +
2214 + $return_handoff = self::paypal_checkout_return_handoff_create($return_post);
2215 + if(!$return_handoff)
2216 + return array('ok' => FALSE, 'error' => 'return_handoff_failed');
2217 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
2218 +
2219 + $result = array('rtn_url' => $return_url, 'rtn_post' => $return_post, 'txn_id' => $pu_cap_id);
2220 + $private_context = (array)$private_context;
2221 + $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array();
2222 + //260907.1820 Persist the minimal browser handoff before marking fulfillment complete; if the final state write fails after Notify, notify_once still blocks duplicate entitlement work and this result remains recoverable. Passwords/card credentials never belong here.
2223 + $private_context['paypal_checkout']['fulfillment_result'] = $result;
2224 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
2225 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed');
2226 +
2227 + //260928.1705 Final fulfillment must patch the latest checkout version: a concurrent webhook/browser context write must not be lost or downgrade the terminal fulfilled state.
2228 + if(!c_ws_plugin__s2member_gateway_checkouts::patch($gateway_checkout_id, array('gateway_ids' => array('order_id' => $order_id, 'capture_id' => $pu_cap_id), 'gateway_status' => 'CAPTURE_COMPLETED', 'fulfillment_status' => 'fulfilled')))
2229 + return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed');
2230 +
2231 + return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result);
1469 2232 }
1470 2233
1471 2234 /**
1472 - * Creates a PayPal Checkout subscription (server-side) when using redirect-mode approval.
2235 + * Sends PayPal Checkout fulfillment through s2Member's existing PayPal Notify handler once.
1473 2236 *
1474 - * In JS SDK button mode, subscriptions are created client-side using plan_id and
1475 - * then confirmed server-side. Redirect-mode requires server-side creation.
2237 + * @since 260817
1476 2238 *
2239 + * @param array $paypal PayPal-style transaction variables.
2240 + * @param string $done_option Local fulfillment done-marker option name.
2241 + * @param string $proxy_use Optional proxy-use routing value.
2242 + * @param array $extra Optional additional server-side Notify variables.
2243 + *
2244 + * @return array Result with ok/processed/duplicate/error and response details.
2245 + */
2246 + public static function paypal_checkout_notify_once($paypal = array(), $done_option = '', $proxy_use = 'paypal_checkout', $extra = array())
2247 + {
2248 + if(!is_array($paypal) || !$paypal || !$done_option || !is_string($done_option))
2249 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_invalid_args');
2250 +
2251 + //260818.0603 This helper now coordinates one-time and subscription fulfillment markers.
2252 + self::dedupe_markers_cleanup('s2m_ppco_notify_cleanup_throttle', array(
2253 + array('prefix' => 's2m_ppco_capture_done_', 'ttl' => DAY_IN_SECONDS),
2254 + array('prefix' => 's2m_ppco_subscr_done_', 'ttl' => DAY_IN_SECONDS),
2255 + array('prefix' => 's2m_ppco_notify_lock_', 'ttl' => HOUR_IN_SECONDS),
2256 + array('prefix' => 's2m_ppco_capture_lock_', 'ttl' => HOUR_IN_SECONDS),
2257 + ));
2258 +
2259 + $result_transient = 's2m_ppco_notify_result_'.md5($done_option);
2260 + if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
2261 + {
2262 + $cached_result = get_transient($result_transient);
2263 + return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
2264 + }
2265 +
2266 + $lock_option = 's2m_ppco_notify_lock_'.md5($done_option);
2267 + if(!self::dedupe_lock_acquire($lock_option, 900))
2268 + {
2269 + if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
2270 + {
2271 + $cached_result = get_transient($result_transient);
2272 + return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
2273 + }
2274 +
2275 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_in_progress');
2276 + }
2277 +
2278 + try
2279 + {
2280 + if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
2281 + {
2282 + $cached_result = get_transient($result_transient);
2283 + return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
2284 + }
2285 +
2286 + //260818.0617 Allow Pro to prepare account-specific fulfillment inside the shared Notify lock and enrich fallback context.
2287 + $notify_context = apply_filters('ws_plugin__s2member_paypal_checkout_notify_context', array(
2288 + 'paypal' => $paypal,
2289 + 'proxy_use' => (string)$proxy_use,
2290 + 'extra' => is_array($extra) ? $extra : array(),
2291 + ), $done_option);
2292 +
2293 + if(is_wp_error($notify_context))
2294 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_context_failed', 'context_error' => (string)$notify_context->get_error_code());
2295 +
2296 + if(!is_array($notify_context) || empty($notify_context['paypal']) || !is_array($notify_context['paypal']))
2297 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_context_invalid');
2298 +
2299 + $paypal = $notify_context['paypal'];
2300 + $proxy_use = isset($notify_context['proxy_use']) ? (string)$notify_context['proxy_use'] : (string)$proxy_use;
2301 + $extra = !empty($notify_context['extra']) && is_array($notify_context['extra']) ? $notify_context['extra'] : array();
2302 +
2303 + $notify_url = home_url('/?s2member_paypal_notify=1');
2304 + $notify_post = array_merge($paypal, $extra, array(
2305 + 's2member_paypal_proxy' => 'paypal',
2306 + 's2member_paypal_proxy_use' => $proxy_use,
2307 + 's2member_paypal_proxy_verification' => self::paypal_proxy_key_gen(),
2308 + ));
2309 + $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
2310 +
2311 + if(!is_array($notify_r))
2312 + $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
2313 +
2314 + $code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
2315 + $message = !empty($notify_r['message']) ? (string)$notify_r['message'] : '';
2316 + $body = !empty($notify_r['body']) ? (string)$notify_r['body'] : '';
2317 +
2318 + if($code >= 200 && $code <= 299)
2319 + {
2320 + $result = array('code' => $code, 'message' => $message, 'body' => $body);
2321 + set_transient($result_transient, $result, DAY_IN_SECONDS); // Preserve the Notify result for safe duplicate/retry returns, including future Pro success URLs.
2322 + self::dedupe_done_mark($done_option);
2323 +
2324 + //260818.1752 Run account-specific post-Notify work only after fulfillment is durably marked complete.
2325 + do_action('ws_plugin__s2member_paypal_checkout_notify_processed', $notify_context, $done_option, $result);
2326 +
2327 + return array_merge(array('ok' => true, 'processed' => true, 'duplicate' => false, 'error' => ''), $result);
2328 + }
2329 +
2330 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_proxy_failed', 'code' => $code, 'message' => $message, 'body' => $body);
2331 + }
2332 + finally
2333 + {
2334 + self::dedupe_lock_release($lock_option);
2335 + }
2336 + }
2337 +
2338 + /**
2339 + * Recovers a coordinator-backed PayPal subscription ID/status from a verified webhook resource.
2340 + *
2341 + * @since 260902.0200
2342 + *
2343 + * @param string $invoice PayPal custom_id/invoice carrying the Gateway Checkout ID.
2344 + * @param string $subscription_id PayPal subscription ID.
2345 + * @param string $status PayPal subscription status, if known.
2346 + *
2347 + * @return array Recovery result with handled/ok/recovered/error details.
2348 + */
2349 + public static function paypal_checkout_subscription_gateway_checkout_recover($invoice = '', $subscription_id = '', $status = '')
2350 + {
2351 + $invoice = trim((string)$invoice);
2352 + $subscription_id = trim((string)$subscription_id);
2353 + $status = strtoupper(trim((string)$status));
2354 + $gateway_checkout_id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice); //260928.1515 Recover both Pro-Forms and standalone Framework button subscriptions by their signed invoice identity.
2355 +
2356 + if(!$subscription_id || !c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id))
2357 + return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2358 +
2359 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2360 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2361 + return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2362 +
2363 + $lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60);
2364 + if(!$lock)
2365 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id);
2366 +
2367 + try
2368 + {
2369 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2370 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2371 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_invalid', 'gateway_checkout_id' => $gateway_checkout_id);
2372 +
2373 + $existing_subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
2374 + if($existing_subscription_id && !hash_equals($existing_subscription_id, $subscription_id))
2375 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_subscription_conflict', 'gateway_checkout_id' => $gateway_checkout_id, 'subscription_id' => $existing_subscription_id);
2376 +
2377 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2378 + $gateway_ids['subscription_id'] = $subscription_id;
2379 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2380 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2381 +
2382 + if(!$existing_subscription_id)
2383 + {
2384 + //260902.0200 Record webhook repair for future diagnostics without treating CREATED as payment/fulfillment.
2385 + $context['paypal_subscription_recovered_at'] = time();
2386 + $context['paypal_subscription_recovered_via'] = 'webhook';
2387 + }
2388 +
2389 + $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : '';
2390 + if($status === 'ACTIVE' || ($status === 'APPROVED' && $gateway_status === 'APPROVAL_PENDING') || !$gateway_status || $gateway_status === 'CREATE_PENDING')
2391 + $gateway_status = $status ? $status : 'APPROVAL_PENDING';
2392 +
2393 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $gateway_status, 'context' => $context)))
2394 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_save_failed', 'gateway_checkout_id' => $gateway_checkout_id);
2395 +
2396 + return array('handled' => true, 'ok' => true, 'recovered' => !$existing_subscription_id, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'subscription_id' => $subscription_id, 'status' => $gateway_status);
2397 + }
2398 + finally
2399 + {
2400 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $lock);
2401 + }
2402 + }
2403 +
2404 + /**
2405 + * Completes an approved standalone Framework button subscription from the same
2406 + * authoritative PayPal resource whether invoked by browser or verified webhook.
2407 + *
2408 + * @since 260928.1530
2409 + */
2410 + public static function paypal_checkout_button_subscription_fulfill($subscription = array(), $token = array(), $via = 'webhook')
2411 + {
2412 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
2413 + $id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2414 + if(!$id || strpos($invoice, 's2mb-') !== 0 || empty($token['gateway_checkout_id']) || !hash_equals($id, (string)$token['gateway_checkout_id']))
2415 + return array('ok' => FALSE, 'error' => 'gateway_checkout_identity_invalid');
2416 +
2417 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state($id);
2418 + if(!$state || (string)$state['gateway'] !== 'paypal_checkout' || (string)$state['operation'] !== 'subscription')
2419 + return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid');
2420 +
2421 + $subscription_id = !empty($subscription['id']) ? (string)$subscription['id'] : '';
2422 + $status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
2423 + $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
2424 + if(!$subscription_id || !$custom_id || !hash_equals($invoice, $custom_id))
2425 + return array('ok' => FALSE, 'error' => 'subscription_purchase_identity_mismatch');
2426 +
2427 + $expected_plan = self::paypal_checkout_plan_get_id($token);
2428 + if(!$expected_plan || empty($subscription['plan_id']) || !hash_equals((string)$expected_plan, (string)$subscription['plan_id']))
2429 + return array('ok' => FALSE, 'error' => 'subscription_plan_mismatch');
2430 +
2431 + $is_single_cycle = isset($token['rr']) && (string)$token['rr'] === '0';
2432 + $last_payment_amount = isset($subscription['billing_info']['last_payment']['amount']['value']) ? (string)$subscription['billing_info']['last_payment']['amount']['value'] : '';
2433 + $last_payment_currency = !empty($subscription['billing_info']['last_payment']['amount']['currency_code']) ? strtoupper((string)$subscription['billing_info']['last_payment']['amount']['currency_code']) : '';
2434 + //260928.1703 An immediately EXPIRED single-cycle subscription is paid only when PayPal's reported last payment matches the signed price and currency, not merely when a payment field exists.
2435 + $last_paid = ($last_payment_amount !== '' && is_numeric($last_payment_amount) && isset($token['amount'])
2436 + && number_format((float)$last_payment_amount, 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
2437 + && !empty($token['cc']) && $last_payment_currency === strtoupper((string)$token['cc']));
2438 + if($status !== 'ACTIVE' && !($is_single_cycle && $status === 'EXPIRED' && $last_paid))
2439 + return in_array($status, array('APPROVAL_PENDING', 'APPROVED'), TRUE)
2440 + ? array('ok' => FALSE, 'pending_activation' => TRUE, 'error' => 'pending_activation', 'status' => $status)
2441 + : array('ok' => FALSE, 'error' => 'subscription_status_invalid', 'status' => $status);
2442 +
2443 + //260928.1530 Bind the real subscription ID before fulfillment so a second event/browser request cannot attach a different provider subscription to this purchase.
2444 + $recovery = self::paypal_checkout_subscription_gateway_checkout_recover($invoice, $subscription_id, $status);
2445 + if(empty($recovery['handled']) || empty($recovery['ok']))
2446 + {
2447 + //260928.1608 An independent CREATED/ACTIVATED webhook can own the coordinator lock briefly; the browser should poll rather than report a permanent checkout failure.
2448 + //260928.1703 A redirect return also competes with CREATED/ACTIVATED webhook recovery; let it retry the signed return instead of displaying a spurious failure.
2449 + if(in_array($via, array('browser', 'return'), TRUE) && !empty($recovery['error']) && $recovery['error'] === 'gateway_checkout_busy')
2450 + return array('ok' => FALSE, 'pending_activation' => TRUE, 'status' => $status, 'error' => 'gateway_checkout_busy');
2451 + return array('ok' => FALSE, 'error' => !empty($recovery['error']) ? $recovery['error'] : 'subscription_recovery_failed');
2452 + }
2453 +
2454 + $private = c_ws_plugin__s2member_gateway_checkouts::private_context_get($id);
2455 + if(!is_array($private) || empty($private['paypal_checkout']['token']) || !is_array($private['paypal_checkout']['token']))
2456 + return array('ok' => FALSE, 'error' => 'gateway_checkout_purchase_context_missing');
2457 + $stored_token = $private['paypal_checkout']['token'];
2458 + if(empty($stored_token['invoice']) || !hash_equals($invoice, (string)$stored_token['invoice']) || empty($stored_token['item_number']))
2459 + return array('ok' => FALSE, 'error' => 'gateway_checkout_purchase_context_mismatch');
2460 +
2461 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state($id);
2462 + if($state && (string)$state['fulfillment_status'] === 'fulfilled' && !empty($private['paypal_checkout']['fulfillment_result']))
2463 + return array_merge(array('ok' => TRUE, 'processed' => FALSE, 'duplicate' => TRUE), $private['paypal_checkout']['fulfillment_result']);
2464 +
2465 + $paypal = array(
2466 + 'txn_type' => 'subscr_signup', 'payment_status' => 'Completed', 'subscr_gateway' => 'paypal',
2467 + 'txn_id' => $subscription_id, 'subscr_id' => $subscription_id, 'subscr_baid' => $subscription_id, 'subscr_cid' => $subscription_id,
2468 + 'mc_gross' => (string)$stored_token['amount'], 'mc_currency' => strtoupper((string)$stored_token['cc']),
2469 + 'period1' => (!empty($stored_token['tp']) && !empty($stored_token['tt'])) ? ((string)$stored_token['tp'].' '.strtoupper((string)$stored_token['tt'])) : '0 D',
2470 + 'mc_amount1' => (!empty($stored_token['tp']) && !empty($stored_token['tt'])) ? (string)$stored_token['ta'] : '0.00',
2471 + 'period3' => ((string)$stored_token['rp'].' '.strtoupper((string)$stored_token['rt'])),
2472 + 'mc_amount3' => (string)$stored_token['amount'],
2473 + 'recurring' => ((isset($stored_token['rr']) && (string)$stored_token['rr'] === '1') ? '1' : '0'),
2474 + 'invoice' => $invoice, 'custom' => (string)$stored_token['custom'],
2475 + 'item_name' => (string)$stored_token['item_name'], 'item_number' => (string)$stored_token['item_number'],
2476 + 'payer_email' => !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '',
2477 + 'first_name' => !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '',
2478 + 'last_name' => !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '',
2479 + 'option_name1' => (string)$stored_token['on0'], 'option_selection1' => (string)$stored_token['os0'],
2480 + 'option_name2' => (string)$stored_token['on1'], 'option_selection2' => (string)$stored_token['os1'],
2481 + );
2482 +
2483 + $notify_result = self::paypal_checkout_notify_once($paypal, 's2m_ppco_subscr_done_'.md5($subscription_id));
2484 + if(empty($notify_result['ok']))
2485 + return array('ok' => FALSE, 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed');
2486 +
2487 + $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', (string)$stored_token['return']);
2488 + $return_post = array_merge($paypal, array('s2member_paypal_proxy' => 'paypal', 's2member_paypal_proxy_use' => 'paypal_checkout'));
2489 + $handoff = self::paypal_checkout_return_handoff_create($return_post);
2490 + if(!$handoff)
2491 + return array('ok' => FALSE, 'error' => 'return_handoff_failed');
2492 + $return_post['s2member_paypal_checkout_handoff'] = $handoff;
2493 + $result = array('rtn_url' => $return_url, 'rtn_post' => $return_post, 'subscription_id' => $subscription_id);
2494 +
2495 + $private['paypal_checkout']['fulfillment_result'] = $result;
2496 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($id, $private))
2497 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_save_failed');
2498 + if(!c_ws_plugin__s2member_gateway_checkouts::patch($id, array('gateway_ids' => array('subscription_id' => $subscription_id), 'gateway_status' => $status, 'fulfillment_status' => 'fulfilled')))
2499 + return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed');
2500 +
2501 + //260928.1530 Preserve button upgrade semantics: only the request that processed Notify may cancel the old subscription, never a duplicate callback.
2502 + $old_id = !empty($stored_token['old__subscr_id']) ? (string)$stored_token['old__subscr_id'] : '';
2503 + if(!empty($notify_result['processed']) && $old_id && $old_id !== $subscription_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', TRUE, array('old__subscr_id' => $old_id, 'subscr_id' => $subscription_id)))
2504 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription(!empty($stored_token['old__subscr_gateway']) ? (string)$stored_token['old__subscr_gateway'] : '', $old_id,
2505 + !empty($stored_token['old__subscr_baid']) ? (string)$stored_token['old__subscr_baid'] : '', !empty($stored_token['old__subscr_cid']) ? (string)$stored_token['old__subscr_cid'] : '',
2506 + !empty($stored_token['old__ipn_signup_vars']) && is_array($stored_token['old__ipn_signup_vars']) ? $stored_token['old__ipn_signup_vars'] : array());
2507 +
2508 + return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result);
2509 + }
2510 +
2511 + /**
2512 + * Creates a PayPal Checkout subscription server-side.
2513 + *
2514 + * Redirect-mode and coordinator-backed JS flows create here; legacy JS buttons may
2515 + * still create client-side using plan_id and then confirm server-side.
2516 + *
1477 2517 * @since 260114
1478 2518 *
1479 2519 * @param array $token Signed/validated purchase token.
1480 2520 *
@@ -1485,40 +2525,140 @@
1485 2525 if(!is_array($token))
1486 2526 return array();
1487 2527
1488 2528 $invoice = (string)$token['invoice'];
2529 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
2530 + $gateway_checkout_lock = '';
1489 2531
1490 - $plan_id = self::paypal_checkout_plan_get_id($token);
1491 - if(!$plan_id)
1492 - return array();
2532 + if($gateway_checkout_id)
2533 + {
2534 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2535 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2536 + return array('__error' => 'gateway_checkout_invalid');
1493 2537
1494 - $brand_name = get_bloginfo('name');
1495 - $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
2538 + //260901.2145 Return a previously persisted PayPal subscription before making another create request; this also recovers a browser reload after server-side creation succeeded.
2539 + if(!empty($gateway_checkout['gateway_ids']['subscription_id']))
2540 + return array('id' => (string)$gateway_checkout['gateway_ids']['subscription_id'], 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '');
1496 2541
1497 - $body = array(
1498 - 'plan_id' => $plan_id,
1499 - 'custom_id' => $invoice,
1500 - 'application_context' => array(
1501 - 'brand_name' => $brand_name,
1502 - 'return_url' => (string)$token['return'],
1503 - 'cancel_url' => (string)$token['cancel'],
1504 - 'user_action' => 'SUBSCRIBE_NOW',
1505 - 'shipping_preference' => 'NO_SHIPPING',
1506 - ),
1507 - );
2542 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
2543 + if(!$gateway_checkout_lock)
2544 + return array('__error' => 'gateway_checkout_busy');
1508 2545
1509 - // Idempotency: stable per invoice for create-subscription retries.
1510 - $headers = array(
1511 - 'PayPal-Request-Id' => 's2m-ppco-sub-'.md5($invoice),
1512 - );
2546 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2547 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2548 + {
2549 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2550 + return array('__error' => 'gateway_checkout_invalid');
2551 + }
2552 + if(!empty($gateway_checkout['gateway_ids']['subscription_id']))
2553 + {
2554 + $subscription_id = (string)$gateway_checkout['gateway_ids']['subscription_id'];
2555 + $status = !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '';
2556 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2557 + return array('id' => $subscription_id, 'status' => $status);
2558 + }
2559 + }
1513 2560
1514 - $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
2561 + try
2562 + {
2563 + $plan_id = self::paypal_checkout_plan_get_id($token);
2564 + if(!$plan_id)
2565 + return array('__error' => 'plan_create_failed');
1515 2566
1516 - $data = array();
1517 - if(!empty($r['body']) && is_string($r['body']))
1518 - $data = json_decode($r['body'], true);
2567 + $brand_name = get_bloginfo('name');
2568 + $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
1519 2569
1520 - return is_array($data) ? $data : array();
2570 + $body = array(
2571 + 'plan_id' => $plan_id,
2572 + 'custom_id' => $invoice,
2573 + 'application_context' => array(
2574 + 'brand_name' => $brand_name,
2575 + 'return_url' => (string)$token['return'],
2576 + 'cancel_url' => (string)$token['cancel'],
2577 + 'user_action' => 'SUBSCRIBE_NOW',
2578 + 'shipping_preference' => 'NO_SHIPPING',
2579 + ),
2580 + );
2581 +
2582 + //260901.2145 Coordinator-backed Pro-Forms use the logical checkout ID as PayPal's stable idempotency anchor; legacy callers retain the established invoice-derived key.
2583 + $request_id = $gateway_checkout_id ? 's2m-ppco-sub-'.str_replace('-', '', $gateway_checkout_id) : 's2m-ppco-sub-'.md5($invoice);
2584 + $headers = array('PayPal-Request-Id' => $request_id);
2585 +
2586 + if($gateway_checkout_id)
2587 + {
2588 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2589 + $create_started_at = !empty($context['paypal_subscription_create_started_at']) ? (int)$context['paypal_subscription_create_started_at'] : 0;
2590 +
2591 + if($create_started_at && $create_started_at <= time() - (3 * DAY_IN_SECONDS))
2592 + {
2593 + //260902.0200 An unresolved server-created subscription could never reach buyer approval without its ID reaching the browser; after PayPal's 72-hour idempotency window, start a fresh approval-pending create instead of permanently blocking the checkout.
2594 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2595 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
2596 + if(!$gateway_checkout)
2597 + return array('__error' => 'gateway_checkout_save_failed');
2598 + $create_started_at = 0;
2599 + }
2600 +
2601 + if(!$create_started_at)
2602 + {
2603 + $context['paypal_subscription_create_started_at'] = time();
2604 + $context['paypal_subscription_request_id'] = $request_id;
2605 + //260901.2145 Record an in-flight create before contacting PayPal so changed purchase terms cannot silently abandon an ambiguous subscription attempt.
2606 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CREATE_PENDING', 'context' => $context));
2607 + if(!$gateway_checkout)
2608 + return array('__error' => 'gateway_checkout_save_failed');
2609 + }
2610 + }
2611 +
2612 + $data = array();
2613 + $code = 0;
2614 + $ambiguous = FALSE;
2615 + for($attempt = 0; $attempt < 2; $attempt++)
2616 + {
2617 + $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
2618 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
2619 + $response_body = !empty($r['body']) ? (string)$r['body'] : '';
2620 + $data = $response_body ? json_decode($response_body, true) : array();
2621 + $data = is_array($data) ? $data : array();
2622 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
2623 +
2624 + if($code >= 200 && $code <= 299 && !empty($data['id']))
2625 + break;
2626 + if(!$ambiguous)
2627 + break;
2628 + }
2629 +
2630 + if($gateway_checkout_id && $code >= 200 && $code <= 299 && !empty($data['id']))
2631 + {
2632 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2633 + $gateway_ids['subscription_id'] = (string)$data['id'];
2634 + $status = !empty($data['status']) ? strtoupper((string)$data['status']) : 'APPROVAL_PENDING';
2635 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2636 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2637 +
2638 + //260901.2145 Persist the PayPal subscription ID before returning it to the browser; if persistence fails, retrying within PayPal's idempotency window recovers the same resource.
2639 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
2640 + return array('__error' => 'gateway_checkout_save_failed');
2641 + }
2642 + else if($gateway_checkout_id && !$ambiguous)
2643 + {
2644 + //260901.2145 A deterministic rejection did not create a subscription; clear the in-flight marker so a corrected attempt is not treated as an unresolved provider result.
2645 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2646 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2647 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
2648 + }
2649 +
2650 + //260902.0200 Preserve an ambiguous create as recoverable state so the browser can briefly wait for the independent CREATED webhook instead of repeatedly calling PayPal.
2651 + if($gateway_checkout_id && $ambiguous && !($code >= 200 && $code <= 299 && !empty($data['id'])))
2652 + return array('__error' => 'subscription_create_unresolved');
2653 +
2654 + return $data;
2655 + }
2656 + finally
2657 + {
2658 + if($gateway_checkout_id && $gateway_checkout_lock)
2659 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2660 + }
1521 2661 }
1522 2662
1523 2663 /**
1524 2664 * Returns a PayPal Checkout Plan ID for a subscription token (creates product/plan if needed).
@@ -1542,17 +2682,16 @@
1542 2682 $ra = isset($token['amount']) ? (string)$token['amount'] : '';
1543 2683 $rp = !empty($token['rp']) ? (int)$token['rp'] : 0;
1544 2684 $rt = !empty($token['rt']) ? strtoupper(trim((string)$token['rt'])) : '';
1545 2685
2686 + $is_pro_form = !empty($token['s2member_paypal_proxy_use']) && strpos((string)$token['s2member_paypal_proxy_use'], 'pro-emails') !== false;
1546 2687 $rrt = !empty($token['rrt']) ? (int)$token['rrt'] : 0;
1547 - $rra = isset($token['rra']) ? (int)$token['rra'] : 1;
2688 + $rra = isset($token['rra']) ? (int)$token['rra'] : ($is_pro_form ? 2 : 1);
1548 2689
1549 - // rrt/rra are only meaningful when rr="1" (recurring).
2690 + //260827.1950 Pro-Forms define rra as the exact Max Failed Payments value for any recurring profile;
2691 + // Framework buttons retain their legacy PayPal Standard retry semantics. rrt remains rr="1" only.
1550 2692 if($rr !== '1')
1551 - {
1552 2693 $rrt = 0;
1553 - $rra = 0;
1554 - }
1555 2694
1556 2695 $ta = isset($token['ta']) ? (string)$token['ta'] : '';
1557 2696 $tp = !empty($token['tp']) ? (int)$token['tp'] : 0;
1558 2697 $tt = !empty($token['tt']) ? strtoupper(trim((string)$token['tt'])) : '';
@@ -1574,8 +2713,10 @@
1574 2713 'rt' => (string)$rt,
1575 2714
1576 2715 'rrt' => (int)$rrt,
1577 2716 'rra' => (int)$rra,
2717 + //260827.2129 !!! TO-DO: Standardize Pro-Form and Framework rrt/rra semantics in a future gateway abstraction; keep Plan caches separate until both contracts match.
2718 + 'pro_form' => (int)$is_pro_form,
1578 2719
1579 2720 'ta' => (string)$ta,
1580 2721 'tp' => (int)$tp,
1581 2722 'tt' => (string)$tt,
@@ -1607,15 +2748,21 @@
1607 2748 $ta_v = number_format((float)$ta, 2, '.', '');
1608 2749
1609 2750 $regular_total_cycles = 0; // 0 = infinite.
1610 2751
1611 - // rrt = number of payments (limited recurring). Only applies to rr="1".
2752 + //260827.2129 Legacy Pro-Forms without an initial term charge once at checkout and define rrt as additional payments.
2753 + // PPCO regular cycles include the checkout payment, while Framework buttons retain total-installment rrt semantics.
1612 2754 if($rr === '1' && $rrt > 0)
1613 - $regular_total_cycles = min(999, max(1, (int)$rrt));
2755 + {
2756 + $regular_total_cycles = (int)$rrt + (($is_pro_form && $tp === 0) ? 1 : 0);
2757 + if($regular_total_cycles > 999) // PayPal cannot represent the legacy Pro-Form result; fail instead of silently reducing the number of charges.
2758 + return '';
2759 + }
1614 2760 else if($rr === '0')
1615 2761 $regular_total_cycles = 1;
1616 2762
1617 - $payment_failure_threshold = ($rr === '1' && $rra) ? 2 : 1;
2763 + //260827.1950 Preserve the Pro-Form's documented exact rra value; Framework buttons keep legacy Standard boolean retry behavior.
2764 + $payment_failure_threshold = $is_pro_form ? max(0, (int)$rra) : (($rr === '1' && $rra) ? 2 : 1);
1618 2765
1619 2766 $billing_cycles = array();
1620 2767 $seq = 1;
1621 2768
@@ -1934,10 +3081,10 @@
1934 3081 /**
1935 3082 * Returns the PayPal Checkout webhook event names processed by s2Member.
1936 3083 *
1937 3084 * These events are used for:
1938 - * - Recurring payment bookkeeping (completed payments).
1939 - * - Subscription lifecycle changes (cancel/suspend/expire/payment failed).
3085 + * - Subscription activation fallback and lifecycle changes.
3086 + * - Recurring payment bookkeeping, refunds, and reversals.
1940 3087 *
1941 3088 * @since 260115
1942 3089 *
1943 3090 * @return array<string> Event type names.
@@ -1943,16 +3090,30 @@
1943 3090 * @return array<string> Event type names.
1944 3091 */
1945 3092 public static function paypal_checkout_webhook_event_names()
1946 3093 {
3094 + //260820.0218 Keep automatic webhook registration aligned with the events handled by s2Member and listed in PayPal Checkout setup help.
1947 3095 return array(
3096 + 'PAYMENT.SALE.COMPLETED',
3097 + 'PAYMENT.CAPTURE.PENDING',
3098 + 'PAYMENT.CAPTURE.COMPLETED',
3099 + 'PAYMENT.CAPTURE.DENIED',
3100 + 'PAYMENT.SALE.REFUNDED',
3101 + 'PAYMENT.CAPTURE.REFUNDED',
3102 + 'PAYMENT.SALE.REVERSED',
3103 + 'PAYMENT.CAPTURE.REVERSED',
3104 +
3105 + //260824.1727 Treat a newly opened PayPal dispute as a chargeback/reversal through s2Member's existing EOT policy.
3106 + 'CUSTOMER.DISPUTE.CREATED',
3107 +
3108 + 'BILLING.SUBSCRIPTION.CREATED',
3109 + 'BILLING.SUBSCRIPTION.ACTIVATED',
3110 + 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
3111 + 'BILLING.SUBSCRIPTION.UPDATED',
1948 3112 'BILLING.SUBSCRIPTION.CANCELLED',
1949 3113 'BILLING.SUBSCRIPTION.SUSPENDED',
1950 3114 'BILLING.SUBSCRIPTION.EXPIRED',
1951 3115 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
1952 -
1953 - 'PAYMENT.SALE.COMPLETED',
1954 - 'PAYMENT.CAPTURE.COMPLETED',
1955 3116 );
1956 3117 }
1957 3118
1958 3119 /**
@@ -1962,17 +3123,18 @@
1962 3123 * Persists the webhook id into ws_plugin__s2member_options for the selected environment.
1963 3124 *
1964 3125 * @since 260115
1965 3126 *
1966 - * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
3127 + * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
3128 + * @param bool $existing_only If true, update only a webhook whose ID is already stored; never create/adopt one.
1967 3129 *
1968 3130 * @return array Result array on success with keys:
1969 3131 * - id (string) webhook id
1970 - * - op (string) 'created'|'updated'
3132 + * - op (string) 'created'|'updated'|'adopted'
1971 3133 * - env (string) 'live'|'sandbox'
1972 3134 * Empty array on failure.
1973 3135 */
1974 - public static function paypal_checkout_webhook_upsert($env = '')
3136 + public static function paypal_checkout_webhook_upsert($env = '', $existing_only = false)
1975 3137 {
1976 3138 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1977 3139
1978 3140 $orig_sandbox = self::paypal_checkout_is_sandbox();
@@ -2046,8 +3208,15 @@
2046 3208 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2047 3209 ));
2048 3210 }
2049 3211
3212 + //260820.0313 Upgrade reconciliation must never create or adopt a webhook the site owner did not already store.
3213 + if($existing_only)
3214 + {
3215 + $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
3216 + return array();
3217 + }
3218 +
2050 3219 $body = array(
2051 3220 'url' => $url,
2052 3221 'event_types' => $event_types,
2053 3222 );
@@ -2086,8 +3255,39 @@
2086 3255 }
2087 3256 }
2088 3257 }
2089 3258
3259 + //260820.0313 A same-app webhook found by this exact s2Member URL is safe to adopt, but first reconcile its required events.
3260 + if($id && $adopted_existing)
3261 + {
3262 + $patch = array(
3263 + array('op' => 'replace', 'path' => '/url', 'value' => $url),
3264 + array('op' => 'replace', 'path' => '/event_types', 'value' => $event_types),
3265 + );
3266 + $ur = self::paypal_checkout_api_request('PATCH', '/v1/notifications/webhooks/'.rawurlencode($id), $patch);
3267 + $adopt_update_ok = (!empty($ur['code']) && (int)$ur['code'] === 200);
3268 +
3269 + if(!$adopt_update_ok && !empty($ur['body']) && is_string($ur['body']))
3270 + {
3271 + $ud = json_decode($ur['body'], true);
3272 + $adopt_update_ok = !empty($ud['name']) && $ud['name'] === 'WEBHOOK_PATCH_REQUEST_NO_CHANGE';
3273 + }
3274 + if(!$adopt_update_ok)
3275 + {
3276 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
3277 + 'ppco' => 'webhook',
3278 + 'event' => 'update_adopted_webhook_failed',
3279 + 'env_setting' => $env,
3280 + 'id' => $id,
3281 + 'url' => $url,
3282 + 'code' => !empty($ur['code']) ? (int)$ur['code'] : 0,
3283 + 'message' => !empty($ur['message']) ? (string)$ur['message'] : '',
3284 + 'body' => !empty($ur['body']) ? (string)$ur['body'] : '',
3285 + ));
3286 + $id = '';
3287 + }
3288 + }
3289 +
2090 3290 if($id)
2091 3291 {
2092 3292 self::paypal_checkout_webhook_store_id($id);
2093 3293
@@ -2118,8 +3318,38 @@
2118 3318 return array();
2119 3319 }
2120 3320
2121 3321 /**
3322 + * Clears a resolved PayPal Checkout webhook upgrade notice.
3323 + *
3324 + * @since 260824.0507
3325 + *
3326 + * @param string $env 'live' or 'sandbox'.
3327 + *
3328 + * @return void
3329 + */
3330 + protected static function paypal_checkout_webhook_upgrade_notice_clear($env = '')
3331 + {
3332 + $env = ($env === 'sandbox') ? 'sandbox' : 'live';
3333 + $env_label = ($env === 'sandbox') ? 'Sandbox' : 'Live';
3334 + $marker = 's2member-ppco-webhook-upgrade-notice-'.$env;
3335 + $legacy_message = 'Your '.$env_label.' webhook could not be updated automatically with the latest required events.';
3336 +
3337 + $notices = (array)get_option('ws_plugin__s2member_notices');
3338 + $changed = FALSE;
3339 +
3340 + foreach($notices as $notice_key => $notice)
3341 + if(is_array($notice) && !empty($notice['notice']) && (strpos((string)$notice['notice'], $marker) !== FALSE || strpos((string)$notice['notice'], $legacy_message) !== FALSE))
3342 + {
3343 + unset($notices[$notice_key]);
3344 + $changed = TRUE;
3345 + }
3346 +
3347 + if($changed)
3348 + update_option('ws_plugin__s2member_notices', array_values($notices));
3349 + }
3350 +
3351 + /**
2122 3352 * Stores a PayPal Checkout webhook id into ws_plugin__s2member_options for the current env.
2123 3353 *
2124 3354 * @since 260115
2125 3355 *
@@ -2128,13 +3358,16 @@
2128 3358 * @return void
2129 3359 */
2130 3360 protected static function paypal_checkout_webhook_store_id($webhook_id)
2131 3361 {
3362 + //260820.0427 Preserve the selected environment before option normalization resets the global Checkout environment.
3363 + $is_sandbox = self::paypal_checkout_is_sandbox();
3364 +
2132 3365 $options = get_option('ws_plugin__s2member_options');
2133 3366 if(!is_array($options))
2134 3367 $options = array();
2135 3368
2136 - if(self::paypal_checkout_is_sandbox())
3369 + if($is_sandbox)
2137 3370 $options['paypal_checkout_sandbox_webhook_id'] = (string)$webhook_id;
2138 3371 else
2139 3372 $options['paypal_checkout_webhook_id'] = (string)$webhook_id;
2140 3373
@@ -2141,11 +3374,14 @@
2141 3374 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
2142 3375
2143 3376 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
2144 3377
2145 - if(self::paypal_checkout_is_sandbox())
3378 + if($is_sandbox)
2146 3379 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_sandbox_webhook_id"] = (string)$webhook_id;
2147 3380 else
2148 3381 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_webhook_id"] = (string)$webhook_id;
3382 +
3383 + //260824.0507 A successful create/update or no-change verification resolves any queued upgrade warning for this environment.
3384 + self::paypal_checkout_webhook_upgrade_notice_clear($is_sandbox ? 'sandbox' : 'live');
2149 3385 }
2150 3386 }
2151 3387 }