| @@ -120,9 +120,14 @@ | ||
| 120 | 120 | |
| 121 | 121 | $postvars = self::paypal_postvars_utf8($postvars); |
| 122 | 122 | $endpoint = ($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "www.sandbox.paypal.com" : "www.paypal.com"; |
| 123 | 123 | |
| 124 | - if(!empty($_REQUEST["s2member_paypal_proxy"]) && !empty($_REQUEST["s2member_paypal_proxy_verification"]) && $_REQUEST["s2member_paypal_proxy_verification"] === c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen()) | |
| 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"])) | |
| 125 | 130 | return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_REQUEST["s2member_paypal_proxy"])), get_defined_vars()); |
| 126 | 131 | |
| 127 | 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")) |
| 128 | 133 | return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_GET["s2member_paypal_proxy"])), get_defined_vars()); |
| @@ -323,10 +328,15 @@ | ||
| 323 | 328 | if(is_multisite() && !is_main_site()) |
| 324 | 329 | $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(strtolower($current_blog->domain.$current_blog->path), false, false)); |
| 325 | 330 | |
| 326 | 331 | else { |
| 327 | - $host = ($GLOBALS['WS_PLUGIN__']['s2member']['o']['skip_ipn_domain_validation']) ? parse_url(home_url('/'), PHP_URL_HOST) : $_SERVER["HTTP_HOST"]; //250917 | |
| 328 | - $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(preg_replace("/\:[0-9]+$/", "", strtolower((string) $host)), false, false)); | |
| 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)); | |
| 329 | 339 | } |
| 330 | 340 | |
| 331 | 341 | return apply_filters("ws_plugin__s2member_paypal_proxy_key_gen", $key, get_defined_vars()); |
| 332 | 342 | } |
| @@ -1522,13 +1532,76 @@ | ||
| 1522 | 1532 | if(strpos($invoice, 's2mpf-') === 0) |
| 1523 | 1533 | $gateway_checkout_id = substr($invoice, strlen('s2mpf-')); |
| 1524 | 1534 | else if(strpos($invoice, 's2msp-') === 0) |
| 1525 | 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-')); | |
| 1526 | 1538 | |
| 1527 | 1539 | return c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id) ? $gateway_checkout_id : ''; |
| 1528 | 1540 | } |
| 1529 | 1541 | |
| 1530 | 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 | + /** | |
| 1531 | 1604 | * Creates a PayPal Checkout order for one-time (Buy Now) purchases. |
| 1532 | 1605 | * |
| 1533 | 1606 | * This must be server-side to prevent client-side manipulation of amount, item_number, |
| 1534 | 1607 | * custom fields, etc. The resulting order id is returned to the JS SDK or used for |
| @@ -1554,9 +1627,9 @@ | ||
| 1554 | 1627 | $gateway_checkout_lock = ''; |
| 1555 | 1628 | |
| 1556 | 1629 | if($gateway_checkout_id) |
| 1557 | 1630 | { |
| 1558 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 1631 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 1559 | 1632 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment') |
| 1560 | 1633 | return array('__error' => 'gateway_checkout_invalid'); |
| 1561 | 1634 | |
| 1562 | 1635 | //260902.0635 Return an already-persisted PayPal order before another provider create; a lost browser response can therefore resume the same logical purchase. |
| @@ -1567,9 +1640,9 @@ | ||
| 1567 | 1640 | $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id); |
| 1568 | 1641 | if(!$gateway_checkout_lock) |
| 1569 | 1642 | return array('__error' => 'gateway_checkout_busy'); |
| 1570 | 1643 | |
| 1571 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 1644 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 1572 | 1645 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment') |
| 1573 | 1646 | { |
| 1574 | 1647 | c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock); |
| 1575 | 1648 | return array('__error' => 'gateway_checkout_invalid'); |
| @@ -1647,9 +1720,11 @@ | ||
| 1647 | 1720 | return array('__error' => 'gateway_checkout_private_context_failed'); |
| 1648 | 1721 | $private_context = (array)$private_context; |
| 1649 | 1722 | $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array(); |
| 1650 | 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. |
| 1651 | - $private_context['paypal_checkout']['token'] = $token; | |
| 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; | |
| 1652 | 1727 | if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context)) |
| 1653 | 1728 | return array('__error' => 'gateway_checkout_private_context_failed'); |
| 1654 | 1729 | |
| 1655 | 1730 | $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array(); |
| @@ -1850,9 +1925,9 @@ | ||
| 1850 | 1925 | |
| 1851 | 1926 | if($gateway_checkout_id) |
| 1852 | 1927 | { |
| 1853 | 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. |
| 1854 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 1929 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 1855 | 1930 | $expected_order_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : ''; |
| 1856 | 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)) |
| 1857 | 1932 | return array('__error' => 'gateway_checkout_order_mismatch'); |
| 1858 | 1933 | |
| @@ -1878,9 +1953,9 @@ | ||
| 1878 | 1953 | try |
| 1879 | 1954 | { |
| 1880 | 1955 | if($gateway_checkout_id) |
| 1881 | 1956 | { |
| 1882 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 1957 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 1883 | 1958 | if(!$gateway_checkout || empty($gateway_checkout['gateway_ids']['order_id']) || !hash_equals((string)$gateway_checkout['gateway_ids']['order_id'], $order_id)) |
| 1884 | 1959 | return array('__error' => 'gateway_checkout_order_mismatch'); |
| 1885 | 1960 | |
| 1886 | 1961 | $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : ''; |
| @@ -2030,9 +2105,9 @@ | ||
| 2030 | 2105 | } |
| 2031 | 2106 | |
| 2032 | 2107 | try |
| 2033 | 2108 | { |
| 2034 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 2109 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 2035 | 2110 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment') |
| 2036 | 2111 | return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout'); |
| 2037 | 2112 | |
| 2038 | 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. |
| @@ -2090,9 +2165,9 @@ | ||
| 2090 | 2165 | |
| 2091 | 2166 | if(!$gateway_checkout_id || ($completion_error = self::paypal_checkout_order_completion_error($order, $order_id, $token))) |
| 2092 | 2167 | return array('ok' => FALSE, 'error' => $completion_error ? $completion_error : 'gateway_checkout_invalid'); |
| 2093 | 2168 | |
| 2094 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 2169 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 2095 | 2170 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment') |
| 2096 | 2171 | return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid'); |
| 2097 | 2172 | |
| 2098 | 2173 | $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id); |
| @@ -2148,12 +2223,10 @@ | ||
| 2148 | 2223 | $private_context['paypal_checkout']['fulfillment_result'] = $result; |
| 2149 | 2224 | if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context)) |
| 2150 | 2225 | return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed'); |
| 2151 | 2226 | |
| 2152 | - $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array(); | |
| 2153 | - $gateway_ids['order_id'] = $order_id; | |
| 2154 | - $gateway_ids['capture_id'] = $pu_cap_id; | |
| 2155 | - if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => 'CAPTURE_COMPLETED', 'fulfillment_status' => 'fulfilled'))) | |
| 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'))) | |
| 2156 | 2229 | return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed'); |
| 2157 | 2230 | |
| 2158 | 2231 | return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result); |
| 2159 | 2232 | } |
| @@ -2277,14 +2350,14 @@ | ||
| 2277 | 2350 | { |
| 2278 | 2351 | $invoice = trim((string)$invoice); |
| 2279 | 2352 | $subscription_id = trim((string)$subscription_id); |
| 2280 | 2353 | $status = strtoupper(trim((string)$status)); |
| 2281 | - $gateway_checkout_id = (strpos($invoice, 's2mpf-') === 0) ? substr($invoice, strlen('s2mpf-')) : ''; | |
| 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. | |
| 2282 | 2355 | |
| 2283 | 2356 | if(!$subscription_id || !c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id)) |
| 2284 | 2357 | return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout'); |
| 2285 | 2358 | |
| 2286 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 2359 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 2287 | 2360 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription') |
| 2288 | 2361 | return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout'); |
| 2289 | 2362 | |
| 2290 | 2363 | $lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60); |
| @@ -2292,9 +2365,9 @@ | ||
| 2292 | 2365 | return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id); |
| 2293 | 2366 | |
| 2294 | 2367 | try |
| 2295 | 2368 | { |
| 2296 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 2369 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 2297 | 2370 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription') |
| 2298 | 2371 | return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_invalid', 'gateway_checkout_id' => $gateway_checkout_id); |
| 2299 | 2372 | |
| 2300 | 2373 | $existing_subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : ''; |
| @@ -2328,8 +2401,115 @@ | ||
| 2328 | 2401 | } |
| 2329 | 2402 | } |
| 2330 | 2403 | |
| 2331 | 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 | + /** | |
| 2332 | 2512 | * Creates a PayPal Checkout subscription server-side. |
| 2333 | 2513 | * |
| 2334 | 2514 | * Redirect-mode and coordinator-backed JS flows create here; legacy JS buttons may |
| 2335 | 2515 | * still create client-side using plan_id and then confirm server-side. |
| @@ -2350,9 +2530,9 @@ | ||
| 2350 | 2530 | $gateway_checkout_lock = ''; |
| 2351 | 2531 | |
| 2352 | 2532 | if($gateway_checkout_id) |
| 2353 | 2533 | { |
| 2354 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 2534 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 2355 | 2535 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription') |
| 2356 | 2536 | return array('__error' => 'gateway_checkout_invalid'); |
| 2357 | 2537 | |
| 2358 | 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. |
| @@ -2362,9 +2542,9 @@ | ||
| 2362 | 2542 | $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id); |
| 2363 | 2543 | if(!$gateway_checkout_lock) |
| 2364 | 2544 | return array('__error' => 'gateway_checkout_busy'); |
| 2365 | 2545 | |
| 2366 | - $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::get($gateway_checkout_id); | |
| 2546 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 2367 | 2547 | if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription') |
| 2368 | 2548 | { |
| 2369 | 2549 | c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock); |
| 2370 | 2550 | return array('__error' => 'gateway_checkout_invalid'); |