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 +181 -7 260927 → 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
@@ -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();
@@ -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,9 +2350,9 @@
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
@@ -2332,8 +2399,115 @@
2332 2399 {
2333 2400 c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $lock);
2334 2401 }
2335 2402 }
2403 +
2404 + /**
2405 + * Completes an approved standalone Framework button subscription from the same
2406 + * authoritative PayPal resource whether invoked by browser or verified webhook.
2407 + *
2408 + * @since 260928.1530
2409 + */
2410 + public static function paypal_checkout_button_subscription_fulfill($subscription = array(), $token = array(), $via = 'webhook')
2411 + {
2412 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
2413 + $id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2414 + if(!$id || strpos($invoice, 's2mb-') !== 0 || empty($token['gateway_checkout_id']) || !hash_equals($id, (string)$token['gateway_checkout_id']))
2415 + return array('ok' => FALSE, 'error' => 'gateway_checkout_identity_invalid');
2416 +
2417 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state($id);
2418 + if(!$state || (string)$state['gateway'] !== 'paypal_checkout' || (string)$state['operation'] !== 'subscription')
2419 + return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid');
2420 +
2421 + $subscription_id = !empty($subscription['id']) ? (string)$subscription['id'] : '';
2422 + $status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
2423 + $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
2424 + if(!$subscription_id || !$custom_id || !hash_equals($invoice, $custom_id))
2425 + return array('ok' => FALSE, 'error' => 'subscription_purchase_identity_mismatch');
2426 +
2427 + $expected_plan = self::paypal_checkout_plan_get_id($token);
2428 + if(!$expected_plan || empty($subscription['plan_id']) || !hash_equals((string)$expected_plan, (string)$subscription['plan_id']))
2429 + return array('ok' => FALSE, 'error' => 'subscription_plan_mismatch');
2430 +
2431 + $is_single_cycle = isset($token['rr']) && (string)$token['rr'] === '0';
2432 + $last_payment_amount = isset($subscription['billing_info']['last_payment']['amount']['value']) ? (string)$subscription['billing_info']['last_payment']['amount']['value'] : '';
2433 + $last_payment_currency = !empty($subscription['billing_info']['last_payment']['amount']['currency_code']) ? strtoupper((string)$subscription['billing_info']['last_payment']['amount']['currency_code']) : '';
2434 + //260928.1703 An immediately EXPIRED single-cycle subscription is paid only when PayPal's reported last payment matches the signed price and currency, not merely when a payment field exists.
2435 + $last_paid = ($last_payment_amount !== '' && is_numeric($last_payment_amount) && isset($token['amount'])
2436 + && number_format((float)$last_payment_amount, 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
2437 + && !empty($token['cc']) && $last_payment_currency === strtoupper((string)$token['cc']));
2438 + if($status !== 'ACTIVE' && !($is_single_cycle && $status === 'EXPIRED' && $last_paid))
2439 + return in_array($status, array('APPROVAL_PENDING', 'APPROVED'), TRUE)
2440 + ? array('ok' => FALSE, 'pending_activation' => TRUE, 'error' => 'pending_activation', 'status' => $status)
2441 + : array('ok' => FALSE, 'error' => 'subscription_status_invalid', 'status' => $status);
2442 +
2443 + //260928.1530 Bind the real subscription ID before fulfillment so a second event/browser request cannot attach a different provider subscription to this purchase.
2444 + $recovery = self::paypal_checkout_subscription_gateway_checkout_recover($invoice, $subscription_id, $status);
2445 + if(empty($recovery['handled']) || empty($recovery['ok']))
2446 + {
2447 + //260928.1608 An independent CREATED/ACTIVATED webhook can own the coordinator lock briefly; the browser should poll rather than report a permanent checkout failure.
2448 + //260928.1703 A redirect return also competes with CREATED/ACTIVATED webhook recovery; let it retry the signed return instead of displaying a spurious failure.
2449 + if(in_array($via, array('browser', 'return'), TRUE) && !empty($recovery['error']) && $recovery['error'] === 'gateway_checkout_busy')
2450 + return array('ok' => FALSE, 'pending_activation' => TRUE, 'status' => $status, 'error' => 'gateway_checkout_busy');
2451 + return array('ok' => FALSE, 'error' => !empty($recovery['error']) ? $recovery['error'] : 'subscription_recovery_failed');
2452 + }
2453 +
2454 + $private = c_ws_plugin__s2member_gateway_checkouts::private_context_get($id);
2455 + if(!is_array($private) || empty($private['paypal_checkout']['token']) || !is_array($private['paypal_checkout']['token']))
2456 + return array('ok' => FALSE, 'error' => 'gateway_checkout_purchase_context_missing');
2457 + $stored_token = $private['paypal_checkout']['token'];
2458 + if(empty($stored_token['invoice']) || !hash_equals($invoice, (string)$stored_token['invoice']) || empty($stored_token['item_number']))
2459 + return array('ok' => FALSE, 'error' => 'gateway_checkout_purchase_context_mismatch');
2460 +
2461 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state($id);
2462 + if($state && (string)$state['fulfillment_status'] === 'fulfilled' && !empty($private['paypal_checkout']['fulfillment_result']))
2463 + return array_merge(array('ok' => TRUE, 'processed' => FALSE, 'duplicate' => TRUE), $private['paypal_checkout']['fulfillment_result']);
2464 +
2465 + $paypal = array(
2466 + 'txn_type' => 'subscr_signup', 'payment_status' => 'Completed', 'subscr_gateway' => 'paypal',
2467 + 'txn_id' => $subscription_id, 'subscr_id' => $subscription_id, 'subscr_baid' => $subscription_id, 'subscr_cid' => $subscription_id,
2468 + 'mc_gross' => (string)$stored_token['amount'], 'mc_currency' => strtoupper((string)$stored_token['cc']),
2469 + 'period1' => (!empty($stored_token['tp']) && !empty($stored_token['tt'])) ? ((string)$stored_token['tp'].' '.strtoupper((string)$stored_token['tt'])) : '0 D',
2470 + 'mc_amount1' => (!empty($stored_token['tp']) && !empty($stored_token['tt'])) ? (string)$stored_token['ta'] : '0.00',
2471 + 'period3' => ((string)$stored_token['rp'].' '.strtoupper((string)$stored_token['rt'])),
2472 + 'mc_amount3' => (string)$stored_token['amount'],
2473 + 'recurring' => ((isset($stored_token['rr']) && (string)$stored_token['rr'] === '1') ? '1' : '0'),
2474 + 'invoice' => $invoice, 'custom' => (string)$stored_token['custom'],
2475 + 'item_name' => (string)$stored_token['item_name'], 'item_number' => (string)$stored_token['item_number'],
2476 + 'payer_email' => !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '',
2477 + 'first_name' => !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '',
2478 + 'last_name' => !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '',
2479 + 'option_name1' => (string)$stored_token['on0'], 'option_selection1' => (string)$stored_token['os0'],
2480 + 'option_name2' => (string)$stored_token['on1'], 'option_selection2' => (string)$stored_token['os1'],
2481 + );
2482 +
2483 + $notify_result = self::paypal_checkout_notify_once($paypal, 's2m_ppco_subscr_done_'.md5($subscription_id));
2484 + if(empty($notify_result['ok']))
2485 + return array('ok' => FALSE, 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed');
2486 +
2487 + $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', (string)$stored_token['return']);
2488 + $return_post = array_merge($paypal, array('s2member_paypal_proxy' => 'paypal', 's2member_paypal_proxy_use' => 'paypal_checkout'));
2489 + $handoff = self::paypal_checkout_return_handoff_create($return_post);
2490 + if(!$handoff)
2491 + return array('ok' => FALSE, 'error' => 'return_handoff_failed');
2492 + $return_post['s2member_paypal_checkout_handoff'] = $handoff;
2493 + $result = array('rtn_url' => $return_url, 'rtn_post' => $return_post, 'subscription_id' => $subscription_id);
2494 +
2495 + $private['paypal_checkout']['fulfillment_result'] = $result;
2496 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($id, $private))
2497 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_save_failed');
2498 + if(!c_ws_plugin__s2member_gateway_checkouts::patch($id, array('gateway_ids' => array('subscription_id' => $subscription_id), 'gateway_status' => $status, 'fulfillment_status' => 'fulfilled')))
2499 + return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed');
2500 +
2501 + //260928.1530 Preserve button upgrade semantics: only the request that processed Notify may cancel the old subscription, never a duplicate callback.
2502 + $old_id = !empty($stored_token['old__subscr_id']) ? (string)$stored_token['old__subscr_id'] : '';
2503 + if(!empty($notify_result['processed']) && $old_id && $old_id !== $subscription_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', TRUE, array('old__subscr_id' => $old_id, 'subscr_id' => $subscription_id)))
2504 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription(!empty($stored_token['old__subscr_gateway']) ? (string)$stored_token['old__subscr_gateway'] : '', $old_id,
2505 + !empty($stored_token['old__subscr_baid']) ? (string)$stored_token['old__subscr_baid'] : '', !empty($stored_token['old__subscr_cid']) ? (string)$stored_token['old__subscr_cid'] : '',
2506 + !empty($stored_token['old__ipn_signup_vars']) && is_array($stored_token['old__ipn_signup_vars']) ? $stored_token['old__ipn_signup_vars'] : array());
2507 +
2508 + return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result);
2509 + }
2336 2510
2337 2511 /**
2338 2512 * Creates a PayPal Checkout subscription server-side.
2339 2513 *