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 +191 -17 260913 → 261001 View file →
@@ -120,10 +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 + //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 +
124 128 //260909.0411 Normalize proxy verification input types and use the standard constant-time comparison helper.
125 - 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"]))
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"]))
126 130 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_REQUEST["s2member_paypal_proxy"])), get_defined_vars());
127 131
128 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"))
129 133 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_GET["s2member_paypal_proxy"])), get_defined_vars());
@@ -1528,13 +1532,76 @@
1528 1532 if(strpos($invoice, 's2mpf-') === 0)
1529 1533 $gateway_checkout_id = substr($invoice, strlen('s2mpf-'));
1530 1534 else if(strpos($invoice, 's2msp-') === 0)
1531 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-'));
1532 1538
1533 1539 return c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id) ? $gateway_checkout_id : '';
1534 1540 }
1535 1541
1536 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 + /**
1537 1604 * Creates a PayPal Checkout order for one-time (Buy Now) purchases.
1538 1605 *
1539 1606 * This must be server-side to prevent client-side manipulation of amount, item_number,
1540 1607 * custom fields, etc. The resulting order id is returned to the JS SDK or used for
@@ -1560,9 +1627,9 @@
1560 1627 $gateway_checkout_lock = '';
1561 1628
1562 1629 if($gateway_checkout_id)
1563 1630 {
1564 - $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);
1565 1632 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1566 1633 return array('__error' => 'gateway_checkout_invalid');
1567 1634
1568 1635 //260902.0635 Return an already-persisted PayPal order before another provider create; a lost browser response can therefore resume the same logical purchase.
@@ -1573,9 +1640,9 @@
1573 1640 $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
1574 1641 if(!$gateway_checkout_lock)
1575 1642 return array('__error' => 'gateway_checkout_busy');
1576 1643
1577 - $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);
1578 1645 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1579 1646 {
1580 1647 c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1581 1648 return array('__error' => 'gateway_checkout_invalid');
@@ -1653,9 +1720,11 @@
1653 1720 return array('__error' => 'gateway_checkout_private_context_failed');
1654 1721 $private_context = (array)$private_context;
1655 1722 $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array();
1656 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.
1657 - $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;
1658 1727 if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
1659 1728 return array('__error' => 'gateway_checkout_private_context_failed');
1660 1729
1661 1730 $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
@@ -1856,9 +1925,9 @@
1856 1925
1857 1926 if($gateway_checkout_id)
1858 1927 {
1859 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.
1860 - $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);
1861 1930 $expected_order_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '';
1862 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))
1863 1932 return array('__error' => 'gateway_checkout_order_mismatch');
1864 1933
@@ -1884,9 +1953,9 @@
1884 1953 try
1885 1954 {
1886 1955 if($gateway_checkout_id)
1887 1956 {
1888 - $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);
1889 1958 if(!$gateway_checkout || empty($gateway_checkout['gateway_ids']['order_id']) || !hash_equals((string)$gateway_checkout['gateway_ids']['order_id'], $order_id))
1890 1959 return array('__error' => 'gateway_checkout_order_mismatch');
1891 1960
1892 1961 $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : '';
@@ -2036,9 +2105,9 @@
2036 2105 }
2037 2106
2038 2107 try
2039 2108 {
2040 - $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);
2041 2110 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2042 2111 return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout');
2043 2112
2044 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.
@@ -2096,9 +2165,9 @@
2096 2165
2097 2166 if(!$gateway_checkout_id || ($completion_error = self::paypal_checkout_order_completion_error($order, $order_id, $token)))
2098 2167 return array('ok' => FALSE, 'error' => $completion_error ? $completion_error : 'gateway_checkout_invalid');
2099 2168
2100 - $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);
2101 2170 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2102 2171 return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid');
2103 2172
2104 2173 $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
@@ -2154,12 +2223,10 @@
2154 2223 $private_context['paypal_checkout']['fulfillment_result'] = $result;
2155 2224 if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
2156 2225 return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed');
2157 2226
2158 - $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2159 - $gateway_ids['order_id'] = $order_id;
2160 - $gateway_ids['capture_id'] = $pu_cap_id;
2161 - 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')))
2162 2229 return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed');
2163 2230
2164 2231 return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result);
2165 2232 }
@@ -2283,14 +2350,14 @@
2283 2350 {
2284 2351 $invoice = trim((string)$invoice);
2285 2352 $subscription_id = trim((string)$subscription_id);
2286 2353 $status = strtoupper(trim((string)$status));
2287 - $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.
2288 2355
2289 2356 if(!$subscription_id || !c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id))
2290 2357 return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2291 2358
2292 - $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);
2293 2360 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2294 2361 return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2295 2362
2296 2363 $lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60);
@@ -2298,9 +2365,9 @@
2298 2365 return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id);
2299 2366
2300 2367 try
2301 2368 {
2302 - $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);
2303 2370 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2304 2371 return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_invalid', 'gateway_checkout_id' => $gateway_checkout_id);
2305 2372
2306 2373 $existing_subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
@@ -2334,8 +2401,115 @@
2334 2401 }
2335 2402 }
2336 2403
2337 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 + /**
2338 2512 * Creates a PayPal Checkout subscription server-side.
2339 2513 *
2340 2514 * Redirect-mode and coordinator-backed JS flows create here; legacy JS buttons may
2341 2515 * still create client-side using plan_id and then confirm server-side.
@@ -2356,9 +2530,9 @@
2356 2530 $gateway_checkout_lock = '';
2357 2531
2358 2532 if($gateway_checkout_id)
2359 2533 {
2360 - $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);
2361 2535 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2362 2536 return array('__error' => 'gateway_checkout_invalid');
2363 2537
2364 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.
@@ -2368,9 +2542,9 @@
2368 2542 $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
2369 2543 if(!$gateway_checkout_lock)
2370 2544 return array('__error' => 'gateway_checkout_busy');
2371 2545
2372 - $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);
2373 2547 if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2374 2548 {
2375 2549 c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2376 2550 return array('__error' => 'gateway_checkout_invalid');