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 +199 -19 260909 → 261001 View file →
@@ -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');