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 +861 -186 260829 → 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 }
@@ -1486,8 +1496,112 @@
1486 1496 return '';
1487 1497 }
1488 1498
1489 1499 /**
1500 + * Returns the first PayPal capture ID/status from an order representation.
1501 + *
1502 + * @since 260902.0635
1503 + *
1504 + * @param array $order PayPal order representation.
1505 + *
1506 + * @return array Capture snapshot with id/status.
1507 + */
1508 + public static function paypal_checkout_order_capture_snapshot($order = array())
1509 + {
1510 + $capture = (!empty($order['purchase_units'][0]['payments']['captures'][0]) && is_array($order['purchase_units'][0]['payments']['captures'][0])) ? $order['purchase_units'][0]['payments']['captures'][0] : array();
1511 +
1512 + return array(
1513 + 'id' => !empty($capture['id']) ? (string)$capture['id'] : '',
1514 + 'status' => !empty($capture['status']) ? strtoupper((string)$capture['status']) : '',
1515 + );
1516 + }
1517 +
1518 + /**
1519 + * Extracts a Gateway Checkout ID from a modern PayPal Checkout Pro-Form invoice.
1520 + *
1521 + * @since 260902.0635
1522 + *
1523 + * @param string $invoice Membership (`s2mpf-`) or Specific Post/Page (`s2msp-`) invoice.
1524 + *
1525 + * @return string Gateway Checkout ID, else an empty string.
1526 + */
1527 + public static function paypal_checkout_gateway_checkout_id_from_invoice($invoice = '')
1528 + {
1529 + $invoice = (string)$invoice;
1530 + $gateway_checkout_id = '';
1531 +
1532 + if(strpos($invoice, 's2mpf-') === 0)
1533 + $gateway_checkout_id = substr($invoice, strlen('s2mpf-'));
1534 + else if(strpos($invoice, 's2msp-') === 0)
1535 + $gateway_checkout_id = substr($invoice, strlen('s2msp-'));
1536 + else if(strpos($invoice, 's2mb-') === 0) //260928.1515 Standalone Framework buttons use their own invoice namespace, separate from Pro-Form account preparation.
1537 + $gateway_checkout_id = substr($invoice, strlen('s2mb-'));
1538 +
1539 + return c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id) ? $gateway_checkout_id : '';
1540 + }
1541 +
1542 + /**
1543 + * Starts or resumes a standalone Framework PayPal Checkout button using shared durable state.
1544 + *
1545 + * @since 260928.1520
1546 + *
1547 + * @param array $token Verified, signed standalone button purchase token.
1548 + * @param bool $create_allowed True only before starting provider work.
1549 + * @return array Operation result containing ok and error.
1550 + */
1551 + public static function paypal_checkout_button_gateway_checkout_prepare($token = array(), $create_allowed = FALSE)
1552 + {
1553 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1554 + if(strpos($invoice, 's2mb-') !== 0)
1555 + return array('ok' => TRUE, 'coordinator' => FALSE, 'error' => ''); // Existing in-flight button tokens and Pro-Forms use their established paths.
1556 +
1557 + $id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
1558 + $browser_token = !empty($token['gateway_checkout_token']) ? (string)$token['gateway_checkout_token'] : '';
1559 + if(!$id || empty($token['gateway_checkout_id']) || !hash_equals($id, (string)$token['gateway_checkout_id'])
1560 + || !c_ws_plugin__s2member_gateway_checkouts::browser_token_verify($id, $browser_token))
1561 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_identity_invalid');
1562 +
1563 + $operation = (!empty($token['rr']) && strtoupper((string)$token['rr']) !== 'BN') ? 'subscription' : 'payment';
1564 + $purchase_terms = (array)$token;
1565 + unset($purchase_terms['exp'], $purchase_terms['gateway_checkout_token']); //260928.1520 Token renewal does not alter the underlying purchase contract.
1566 + $fingerprint = c_ws_plugin__s2member_gateway_checkouts::purchase_fingerprint($purchase_terms);
1567 +
1568 + if($create_allowed)
1569 + {
1570 + //260928.1705 Do not rewrite a bound option on every retry: create_or_resume() may otherwise overwrite provider/fulfillment updates committed concurrently by a webhook.
1571 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state_uncached($id);
1572 + if(!$state)
1573 + $state = c_ws_plugin__s2member_gateway_checkouts::create_or_resume('paypal_checkout', $operation, $id, $browser_token, $fingerprint, get_current_user_id());
1574 + else if(!empty($state['user_id']) && (int)$state['user_id'] !== (int)get_current_user_id())
1575 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_user_mismatch');
1576 + }
1577 + else
1578 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state_uncached($id);
1579 +
1580 + //260928.1520 Reject a checkout returned under a replacement identity: the verified button token and PayPal invoice must keep pointing to the same durable record.
1581 + if(!$state || !hash_equals($id, (string)$state['id']) || (string)$state['gateway'] !== 'paypal_checkout'
1582 + || (string)$state['operation'] !== $operation || !hash_equals($fingerprint, (string)$state['purchase_fingerprint']))
1583 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_mismatch');
1584 +
1585 + $private = c_ws_plugin__s2member_gateway_checkouts::private_context_get($id);
1586 + if($private === FALSE)
1587 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_private_context_invalid');
1588 +
1589 + if(empty($private['paypal_checkout']['token']))
1590 + {
1591 + if(!$create_allowed)
1592 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_purchase_context_missing');
1593 + $private = (array)$private;
1594 + $private['paypal_checkout'] = !empty($private['paypal_checkout']) && is_array($private['paypal_checkout']) ? $private['paypal_checkout'] : array();
1595 + //260928.1520 The first provider operation durably stores the authenticated purchase token for webhook-only fulfillment. No password/card data is stored.
1596 + $private['paypal_checkout']['token'] = $token;
1597 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($id, $private))
1598 + return array('ok' => FALSE, 'coordinator' => TRUE, 'error' => 'gateway_checkout_purchase_context_save_failed');
1599 + }
1600 + return array('ok' => TRUE, 'coordinator' => TRUE, 'error' => '', 'gateway_checkout_id' => $id);
1601 + }
1602 +
1603 + /**
1490 1604 * Creates a PayPal Checkout order for one-time (Buy Now) purchases.
1491 1605 *
1492 1606 * This must be server-side to prevent client-side manipulation of amount, item_number,
1493 1607 * custom fields, etc. The resulting order id is returned to the JS SDK or used for
@@ -1500,126 +1614,194 @@
1500 1614 * @return array API request result array from paypal_checkout_api_request().
1501 1615 */
1502 1616 public static function paypal_checkout_order_create($token = array())
1503 1617 {
1618 + if(!is_array($token))
1619 + return array('__error' => 'invalid_token');
1620 +
1504 1621 // token: invoice, custom, item_name, item_number, amount, cc, ns, return, cancel.
1505 - $invoice = (string)$token['invoice'];
1506 - $custom = (string)$token['custom'];
1507 - $amount = (string)$token['amount'];
1508 - $cc = strtoupper((string)$token['cc']);
1622 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1623 + $custom = isset($token['custom']) ? (string)$token['custom'] : '';
1624 + $amount = isset($token['amount']) ? (string)$token['amount'] : '';
1625 + $cc = !empty($token['cc']) ? strtoupper((string)$token['cc']) : '';
1626 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
1627 + $gateway_checkout_lock = '';
1509 1628
1510 - $item_name = trim((string)$token['item_name']);
1511 - if(!$item_name)
1512 - $item_name = 's2Member Purchase';
1629 + if($gateway_checkout_id)
1630 + {
1631 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1632 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1633 + return array('__error' => 'gateway_checkout_invalid');
1513 1634
1514 - // PayPal limits various fields; keep item name within common limits.
1515 - if(strlen($item_name) > 127)
1516 - $item_name = substr($item_name, 0, 127);
1635 + //260902.0635 Return an already-persisted PayPal order before another provider create; a lost browser response can therefore resume the same logical purchase.
1636 + if(!empty($gateway_checkout['gateway_ids']['order_id']))
1637 + return array('id' => (string)$gateway_checkout['gateway_ids']['order_id'], 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '');
1517 1638
1518 - $item_sku = trim((string)$token['item_number']);
1519 - if(strlen($item_sku) > 127)
1520 - $item_sku = substr($item_sku, 0, 127);
1639 + //260907.1820 Lock the logical checkout and then re-read it; concurrent browser requests can both arrive before either has observed the PayPal order ID persisted by the other.
1640 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
1641 + if(!$gateway_checkout_lock)
1642 + return array('__error' => 'gateway_checkout_busy');
1521 1643
1522 - //260817.2119 Keep normal Checkout pricing unchanged; only split subtotal/tax when a Pro-Form token supplies a breakdown that reconciles exactly to the charged total.
1523 - $item_amount = $amount;
1524 - $tax_amount = '';
1525 - if(isset($token['sub_total'], $token['tax']) && is_numeric($token['sub_total']) && is_numeric($token['tax'])
1526 - && number_format((float)$token['sub_total'] + (float)$token['tax'], 2, '.', '') === number_format((float)$amount, 2, '.', ''))
1644 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1645 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1646 + {
1647 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1648 + return array('__error' => 'gateway_checkout_invalid');
1649 + }
1650 + if(!empty($gateway_checkout['gateway_ids']['order_id']))
1651 + {
1652 + $order_id = (string)$gateway_checkout['gateway_ids']['order_id'];
1653 + $status = !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '';
1654 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1655 + return array('id' => $order_id, 'status' => $status);
1656 + }
1657 + }
1658 +
1659 + try
1527 1660 {
1528 - $item_amount = (string)$token['sub_total'];
1529 - $tax_amount = (string)$token['tax'];
1530 - }
1661 + $item_name = !empty($token['item_name']) ? trim((string)$token['item_name']) : '';
1662 + if(!$item_name)
1663 + $item_name = 's2Member Purchase';
1664 + if(strlen($item_name) > 127)
1665 + $item_name = substr($item_name, 0, 127);
1531 1666
1532 - $purchase_unit = array(
1533 - 'invoice_id' => $invoice,
1534 - 'amount' => array(
1535 - 'currency_code' => $cc,
1536 - 'value' => $amount,
1537 - 'breakdown' => array(
1538 - 'item_total' => array(
1539 - 'currency_code' => $cc,
1540 - 'value' => $item_amount,
1541 - ),
1667 + $item_sku = !empty($token['item_number']) ? trim((string)$token['item_number']) : '';
1668 + if(strlen($item_sku) > 127)
1669 + $item_sku = substr($item_sku, 0, 127);
1670 +
1671 + //260817.2119 Keep normal Checkout pricing unchanged; only split subtotal/tax when a Pro-Form token supplies a breakdown that reconciles exactly to the charged total.
1672 + $item_amount = $amount;
1673 + $tax_amount = '';
1674 + if(isset($token['sub_total'], $token['tax']) && is_numeric($token['sub_total']) && is_numeric($token['tax'])
1675 + && number_format((float)$token['sub_total'] + (float)$token['tax'], 2, '.', '') === number_format((float)$amount, 2, '.', ''))
1676 + {
1677 + $item_amount = (string)$token['sub_total'];
1678 + $tax_amount = (string)$token['tax'];
1679 + }
1680 +
1681 + $purchase_unit = array(
1682 + 'invoice_id' => $invoice,
1683 + 'amount' => array(
1684 + 'currency_code' => $cc,
1685 + 'value' => $amount,
1686 + 'breakdown' => array('item_total' => array('currency_code' => $cc, 'value' => $item_amount)),
1542 1687 ),
1543 - ),
1544 - 'description' => $item_name,
1545 - 'items' => array(
1546 - array(
1547 - 'name' => $item_name,
1548 - 'quantity' => '1',
1549 - 'unit_amount' => array(
1550 - 'currency_code' => $cc,
1551 - 'value' => $item_amount,
1552 - ),
1688 + 'description' => $item_name,
1689 + 'items' => array(array('name' => $item_name, 'quantity' => '1', 'unit_amount' => array('currency_code' => $cc, 'value' => $item_amount))),
1690 + );
1691 + if($tax_amount !== '' && (float)$tax_amount > 0)
1692 + {
1693 + $purchase_unit['amount']['breakdown']['tax_total'] = array('currency_code' => $cc, 'value' => $tax_amount);
1694 + $purchase_unit['items'][0]['tax'] = array('currency_code' => $cc, 'value' => $tax_amount);
1695 + }
1696 + if($item_sku)
1697 + $purchase_unit['items'][0]['sku'] = $item_sku;
1698 + if($custom && strlen($custom) <= 127)
1699 + $purchase_unit['custom_id'] = $custom;
1700 +
1701 + $body = array(
1702 + 'intent' => 'CAPTURE',
1703 + 'purchase_units' => array($purchase_unit),
1704 + 'application_context' => array(
1705 + 'user_action' => 'PAY_NOW',
1706 + 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1707 + 'return_url' => !empty($token['return']) ? (string)$token['return'] : '',
1708 + 'cancel_url' => !empty($token['cancel']) ? (string)$token['cancel'] : '',
1553 1709 ),
1554 - ),
1555 - );
1556 -
1557 - if($tax_amount !== '' && (float)$tax_amount > 0)
1558 - {
1559 - $purchase_unit['amount']['breakdown']['tax_total'] = array(
1560 - 'currency_code' => $cc,
1561 - 'value' => $tax_amount,
1562 1710 );
1563 - $purchase_unit['items'][0]['tax'] = array(
1564 - 'currency_code' => $cc,
1565 - 'value' => $tax_amount,
1566 - );
1567 - }
1568 1711
1569 - if($item_sku)
1570 - $purchase_unit['items'][0]['sku'] = $item_sku;
1712 + //260907.1820 Derive PayPal-Request-Id from durable logical-checkout identity, not a browser request, so reloads and immediate ambiguous retries address the same provider create operation.
1713 + $request_id = $gateway_checkout_id ? 's2m-ppco-order-'.str_replace('-', '', $gateway_checkout_id) : 's2m-ppco-order-'.md5($invoice);
1714 + $headers = array('PayPal-Request-Id' => $request_id);
1571 1715
1572 - // PayPal limits custom_id length; keep it short/consistent.
1573 - if($custom && strlen($custom) <= 127)
1574 - $purchase_unit['custom_id'] = $custom;
1716 + if($gateway_checkout_id)
1717 + {
1718 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
1719 + if($private_context === FALSE)
1720 + return array('__error' => 'gateway_checkout_private_context_failed');
1721 + $private_context = (array)$private_context;
1722 + $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array();
1723 + //260902.0635 Save the validated token before contacting PayPal so a later capture webhook has enough trusted server-side context to finish an interrupted browser checkout.
1724 + //260928.1615 An anchor/url checkout temporarily substitutes PayPal's internal approval-return URL for provider creation; keep the canonical, previously validated button token so a capture webhook returns the buyer to the original success page.
1725 + if(strpos($invoice, 's2mb-') !== 0 || empty($private_context['paypal_checkout']['token']))
1726 + $private_context['paypal_checkout']['token'] = $token;
1727 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
1728 + return array('__error' => 'gateway_checkout_private_context_failed');
1575 1729
1576 - $body = array(
1577 - 'intent' => 'CAPTURE',
1578 - 'purchase_units' => array($purchase_unit),
1579 - 'application_context' => array(
1580 - 'user_action' => 'PAY_NOW',
1581 - 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1582 - 'return_url' => (string)$token['return'],
1583 - 'cancel_url' => (string)$token['cancel'],
1584 - ),
1585 - );
1730 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1731 + $create_started_at = !empty($context['paypal_order_create_started_at']) ? (int)$context['paypal_order_create_started_at'] : 0;
1732 + //260902.0635 PayPal normally retains Orders request IDs for six hours; if no order ID ever came back, the unknown order never reached browser approval and a fresh create is safe after that window.
1733 + if($create_started_at && $create_started_at <= time() - (6 * HOUR_IN_SECONDS))
1734 + {
1735 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1736 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
1737 + if(!$gateway_checkout)
1738 + return array('__error' => 'gateway_checkout_save_failed');
1739 + $create_started_at = 0;
1740 + }
1741 + if(!$create_started_at)
1742 + {
1743 + $context['paypal_order_create_started_at'] = time();
1744 + $context['paypal_order_request_id'] = $request_id;
1745 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CREATE_PENDING', 'context' => $context));
1746 + if(!$gateway_checkout)
1747 + return array('__error' => 'gateway_checkout_save_failed');
1748 + }
1749 + }
1586 1750
1587 - // Idempotency: stable per invoice for create-order retries.
1588 - $headers = array(
1589 - 'PayPal-Request-Id' => 's2m-ppco-order-'.md5($invoice),
1590 - );
1591 -
1592 - $data = array();
1593 - for($attempt = 0; $attempt < 2; $attempt++)
1751 + $data = array();
1752 + $code = 0;
1753 + $ambiguous = FALSE;
1754 + //260907.1820 Retry only an ambiguous transport/provider result, always with the same PayPal-Request-Id; deterministic rejection must not be treated as a possibly-created order.
1755 + for($attempt = 0; $attempt < 2; $attempt++)
1594 1756 {
1595 1757 $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1596 1758 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1597 1759 $response_body = !empty($r['body']) ? (string)$r['body'] : '';
1598 - $data = ($response_body) ? json_decode($response_body, true) : array();
1760 + $data = $response_body ? json_decode($response_body, true) : array();
1599 1761 $data = is_array($data) ? $data : array();
1762 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
1600 1763
1601 1764 if($code >= 200 && $code <= 299 && !empty($data['id']))
1602 1765 break;
1603 -
1604 - $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
1605 1766 if(!$ambiguous)
1606 1767 break;
1607 1768 }
1608 1769
1609 - if($code >= 200 && $code <= 299 && !empty($data['id']))
1770 + if($code >= 200 && $code <= 299 && !empty($data['id']))
1610 1771 {
1611 - //260817 Bind the invoice and expected payment data to the PayPal order before the browser can request capture.
1612 - set_transient('s2m_ppco_order_bind_'.md5($invoice), array(
1613 - 'order_id' => (string)$data['id'],
1614 - 'invoice' => $invoice,
1615 - 'amount' => $amount,
1616 - 'cc' => $cc,
1617 - 'custom' => $custom,
1618 - ), 3 * HOUR_IN_SECONDS);
1772 + set_transient('s2m_ppco_order_bind_'.md5($invoice), array('order_id' => (string)$data['id'], 'invoice' => $invoice, 'amount' => $amount, 'cc' => $cc, 'custom' => $custom), 3 * HOUR_IN_SECONDS);
1773 +
1774 + if($gateway_checkout_id)
1775 + {
1776 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
1777 + $gateway_ids['order_id'] = (string)$data['id'];
1778 + $status = !empty($data['status']) ? 'ORDER_'.strtoupper((string)$data['status']) : 'ORDER_CREATED';
1779 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1780 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1781 + //260902.0635 Persist the PayPal order ID before returning it to the browser; a reload can then reuse it without a second provider create.
1782 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
1783 + return array('__error' => 'gateway_checkout_save_failed');
1784 + }
1619 1785 }
1786 + else if($gateway_checkout_id && !$ambiguous)
1787 + {
1788 + //260907.1820 A deterministic create failure proves no unknown-success recovery is needed; clear CREATE_PENDING breadcrumbs so a later validated attempt is not stranded behind stale ambiguity state.
1789 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1790 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1791 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
1792 + }
1620 1793
1621 - return $data;
1794 + if($gateway_checkout_id && $ambiguous && !($code >= 200 && $code <= 299 && !empty($data['id'])))
1795 + return array('__error' => 'order_create_unresolved');
1796 +
1797 + return $data;
1798 + }
1799 + finally
1800 + {
1801 + if($gateway_checkout_id && $gateway_checkout_lock)
1802 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1803 + }
1622 1804 }
1623 1805
1624 1806 /**
1625 1807 * Retrieves PayPal Checkout subscription details via the Subscriptions REST API.
@@ -1735,104 +1917,322 @@
1735 1917 if(!$order_id)
1736 1918 return array('__error' => 'missing_order_id');
1737 1919
1738 1920 $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1921 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
1739 1922 $binding_name = $invoice ? 's2m_ppco_order_bind_'.md5($invoice) : '';
1740 1923 $binding = $binding_name ? get_transient($binding_name) : false;
1924 + $gateway_checkout_lock = '';
1741 1925
1742 - if(is_array($binding))
1743 - {
1744 - $binding_matches = (!empty($binding['order_id']) && (string)$binding['order_id'] === $order_id
1745 - && isset($binding['invoice']) && (string)$binding['invoice'] === $invoice
1746 - && isset($binding['amount']) && number_format((float)$binding['amount'], 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
1747 - && isset($binding['cc']) && strtoupper((string)$binding['cc']) === strtoupper((string)$token['cc'])
1748 - && isset($binding['custom']) && (string)$binding['custom'] === (string)$token['custom']);
1926 + if($gateway_checkout_id)
1927 + {
1928 + //260907.1820 For coordinator-backed captures, the order ID already persisted server-side is authoritative; never let a browser-supplied order ID rebind this logical checkout to another PayPal resource.
1929 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1930 + $expected_order_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '';
1931 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment' || !$expected_order_id || !hash_equals($expected_order_id, $order_id))
1932 + return array('__error' => 'gateway_checkout_order_mismatch');
1749 1933
1750 - if(!$binding_matches)
1751 - return array('__error' => 'order_binding_mismatch');
1752 - }
1934 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
1935 + if(!$gateway_checkout_lock)
1936 + return array('__error' => 'gateway_checkout_busy');
1937 + }
1938 + else if(is_array($binding))
1939 + {
1940 + $binding_matches = (!empty($binding['order_id']) && (string)$binding['order_id'] === $order_id
1941 + && isset($binding['invoice']) && (string)$binding['invoice'] === $invoice
1942 + && isset($binding['amount']) && number_format((float)$binding['amount'], 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
1943 + && isset($binding['cc']) && strtoupper((string)$binding['cc']) === strtoupper((string)$token['cc'])
1944 + && isset($binding['custom']) && (string)$binding['custom'] === (string)$token['custom']);
1945 + if(!$binding_matches)
1946 + return array('__error' => 'order_binding_mismatch');
1947 + }
1753 1948
1754 - $capture_lock = 's2m_ppco_capture_lock_'.md5($order_id);
1755 - if(!self::dedupe_lock_acquire($capture_lock, 300))
1949 + $capture_lock = $gateway_checkout_id ? '' : 's2m_ppco_capture_lock_'.md5($order_id);
1950 + if(!$gateway_checkout_id && !self::dedupe_lock_acquire($capture_lock, 300))
1756 1951 return array('__error' => 'capture_in_progress');
1757 1952
1758 1953 try
1954 + {
1955 + if($gateway_checkout_id)
1759 1956 {
1760 - //260817 If the short-lived local binding is gone, verify PayPal's order before attempting capture.
1761 - if(!is_array($binding))
1762 - {
1763 - $details = self::paypal_checkout_order_details($order_id);
1764 - if(!empty($details['__error']))
1765 - return $details;
1957 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1958 + if(!$gateway_checkout || empty($gateway_checkout['gateway_ids']['order_id']) || !hash_equals((string)$gateway_checkout['gateway_ids']['order_id'], $order_id))
1959 + return array('__error' => 'gateway_checkout_order_mismatch');
1766 1960
1767 - if(($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
1768 - return array('__error' => $validation_error);
1961 + $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : '';
1962 + //260907.1820 Terminal capture failure is sticky for this logical checkout; recovery must start a fresh validated checkout instead of attempting another capture against the failed order.
1963 + if(in_array($gateway_status, array('CAPTURE_DENIED', 'CAPTURE_FAILED', 'CAPTURE_DECLINED'), TRUE))
1964 + return array('__error' => strtolower($gateway_status));
1965 + }
1769 1966
1770 - if(!empty($details['status']) && strtoupper((string)$details['status']) === 'COMPLETED')
1771 - {
1772 - if(($completion_error = self::paypal_checkout_order_completion_error($details, $order_id, $token)))
1773 - return array('__error' => $completion_error);
1967 + //260902.0635 Once a capture is pending, do not POST another capture; read PayPal's current order state and let webhooks/browser recovery converge on the same capture.
1968 + $read_only = ($gateway_checkout_id && !empty($gateway_checkout['gateway_status']) && strtoupper((string)$gateway_checkout['gateway_status']) === 'CAPTURE_PENDING');
1969 + if(!is_array($binding) || $gateway_checkout_id || $read_only)
1970 + {
1971 + $details = self::paypal_checkout_order_details($order_id);
1972 + if(!empty($details['__error']))
1973 + return $details;
1974 + if(($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
1975 + return array('__error' => $validation_error);
1774 1976
1775 - return $details;
1776 - }
1777 - if(empty($details['status']) || strtoupper((string)$details['status']) !== 'APPROVED')
1778 - return array('__error' => 'order_not_approved');
1779 - }
1977 + $snapshot = self::paypal_checkout_order_capture_snapshot($details);
1978 + if($snapshot['id'] && $snapshot['status'])
1979 + {
1980 + if($gateway_checkout_id)
1981 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
1982 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($details, $order_id, $token))
1983 + return $details;
1984 + if($snapshot['status'] === 'PENDING')
1985 + return array_merge($details, array('__error' => 'capture_pending'));
1986 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
1987 + return array_merge($details, array('__error' => 'capture_'.strtolower($snapshot['status'])));
1988 + }
1780 1989
1781 - // Idempotency: stable per order capture retries.
1782 - $headers = array(
1783 - 'PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id),
1784 - 'Prefer' => 'return=representation',
1785 - );
1990 + if($read_only)
1991 + return array_merge($details, array('__error' => 'capture_pending'));
1992 + if(!empty($details['status']) && strtoupper((string)$details['status']) === 'COMPLETED')
1993 + return array('__error' => self::paypal_checkout_order_completion_error($details, $order_id, $token));
1994 + if(empty($details['status']) || strtoupper((string)$details['status']) !== 'APPROVED')
1995 + return array('__error' => 'order_not_approved');
1996 + }
1786 1997
1787 - $r = array();
1788 - $data = array();
1789 - for($attempt = 0; $attempt < 2; $attempt++)
1790 - {
1791 - $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
1792 - $code = !empty($r['code']) ? (int)$r['code'] : 0;
1793 - $body = !empty($r['body']) ? (string)$r['body'] : '';
1794 - $data = ($body) ? json_decode($body, true) : array();
1795 - $data = is_array($data) ? $data : array();
1998 + if($gateway_checkout_id)
1999 + {
2000 + //260907.1820 Persist CAPTURE_PENDING before the provider POST; if PHP dies after PayPal receives the capture, the next request will recover/read the existing attempt instead of issuing a second capture.
2001 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2002 + $context['paypal_capture_started_at'] = !empty($context['paypal_capture_started_at']) ? (int)$context['paypal_capture_started_at'] : time();
2003 + $context['paypal_capture_request_id'] = 's2m-ppco-cap-'.md5($order_id);
2004 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CAPTURE_PENDING', 'context' => $context));
2005 + if(!$gateway_checkout)
2006 + return array('__error' => 'gateway_checkout_save_failed');
2007 + }
1796 2008
1797 - if($code >= 200 && $code <= 299)
1798 - break;
2009 + //260907.1820 Immediate ambiguous capture retries reuse this same request ID; once a real PENDING capture is observed, later browser requests are read-only and do not POST capture again.
2010 + $headers = array('PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id), 'Prefer' => 'return=representation');
2011 + $r = array();
2012 + $data = array();
2013 + $ambiguous = FALSE;
2014 + for($attempt = 0; $attempt < 2; $attempt++)
2015 + {
2016 + $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
2017 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
2018 + $body = !empty($r['body']) ? (string)$r['body'] : '';
2019 + $data = $body ? json_decode($body, true) : array();
2020 + $data = is_array($data) ? $data : array();
2021 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500);
2022 + if($code >= 200 && $code <= 299)
2023 + break;
2024 + if(!$ambiguous)
2025 + break;
2026 + }
1799 2027
1800 - $ambiguous = ($code === 0 || $code === 408 || $code >= 500);
1801 - if(!$ambiguous)
1802 - break;
1803 - }
1804 -
1805 - $code = !empty($r['code']) ? (int)$r['code'] : 0;
1806 - if($code >= 200 && $code <= 299 && !($completion_error = self::paypal_checkout_order_completion_error($data, $order_id, $token)))
2028 + if($code >= 200 && $code <= 299)
2029 + {
2030 + $snapshot = self::paypal_checkout_order_capture_snapshot($data);
2031 + if($snapshot['id'] && $snapshot['status'])
2032 + {
2033 + if($gateway_checkout_id)
2034 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
2035 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($data, $order_id, $token))
1807 2036 {
1808 - if($binding_name)
1809 - delete_transient($binding_name);
2037 + if($binding_name) delete_transient($binding_name);
1810 2038 return $data;
1811 2039 }
2040 + if($snapshot['status'] === 'PENDING')
2041 + return array_merge($data, array('__error' => 'capture_pending'));
2042 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
2043 + return array_merge($data, array('__error' => 'capture_'.strtolower($snapshot['status'])));
2044 + }
2045 + }
1812 2046
1813 - //260817 Recover from an ambiguous or incomplete capture response by reading PayPal's final order state.
1814 - $details = self::paypal_checkout_order_details($order_id);
1815 - if(empty($details['__error']) && !($completion_error = self::paypal_checkout_order_completion_error($details, $order_id, $token)))
2047 + //260902.0635 Resolve ambiguous/incomplete capture responses by reading PayPal's current order state; never issue a second capture after a known PENDING capture exists.
2048 + $details = self::paypal_checkout_order_details($order_id);
2049 + if(empty($details['__error']) && !($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
2050 + {
2051 + $snapshot = self::paypal_checkout_order_capture_snapshot($details);
2052 + if($snapshot['id'] && $snapshot['status'])
2053 + {
2054 + if($gateway_checkout_id)
2055 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
2056 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($details, $order_id, $token))
1816 2057 {
1817 - if($binding_name)
1818 - delete_transient($binding_name);
2058 + if($binding_name) delete_transient($binding_name);
1819 2059 return $details;
1820 2060 }
2061 + if($snapshot['status'] === 'PENDING')
2062 + return array_merge($details, array('__error' => 'capture_pending'));
2063 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
2064 + return array_merge($details, array('__error' => 'capture_'.strtolower($snapshot['status'])));
2065 + }
2066 + }
1821 2067
1822 - if($code >= 200 && $code <= 299 && !empty($completion_error))
1823 - return array('__error' => $completion_error);
1824 - if(!empty($details['__error']))
1825 - return $details;
1826 - return array('__error' => 'order_capture_failed', '__code' => $code, '__body' => !empty($r['body']) ? (string)$r['body'] : '');
1827 - }
2068 + if($gateway_checkout_id && $ambiguous)
2069 + return array('__error' => 'order_capture_unresolved');
2070 + if(!empty($details['__error']))
2071 + return $details;
2072 + return array('__error' => 'order_capture_failed', '__code' => !empty($r['code']) ? (int)$r['code'] : 0, '__body' => !empty($r['body']) ? (string)$r['body'] : '');
2073 + }
1828 2074 finally
2075 + {
2076 + if($gateway_checkout_id && $gateway_checkout_lock)
2077 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2078 + else if(!$gateway_checkout_id && $capture_lock)
2079 + self::dedupe_lock_release($capture_lock);
2080 + }
2081 + }
2082 +
2083 + /**
2084 + * Reconciles a one-time PayPal order/capture into Gateway Checkout state.
2085 + *
2086 + * @since 260902.0635
2087 + */
2088 + public static function paypal_checkout_order_gateway_checkout_recover($invoice = '', $order_id = '', $capture_id = '', $capture_status = '', $via = 'webhook', $gateway_checkout_lock = '')
2089 + {
2090 + $gateway_checkout_id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2091 + $order_id = trim((string)$order_id);
2092 + $capture_id = trim((string)$capture_id);
2093 + $capture_status = strtoupper(trim((string)$capture_status));
2094 + $owns_lock = FALSE;
2095 +
2096 + if(!$gateway_checkout_id || !$order_id)
2097 + return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout');
2098 +
2099 + if(!$gateway_checkout_lock)
2100 + {
2101 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60);
2102 + if(!$gateway_checkout_lock)
2103 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id);
2104 + $owns_lock = TRUE;
2105 + }
2106 +
2107 + try
2108 + {
2109 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2110 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2111 + return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout');
2112 +
2113 + //260907.1820 Provider identities are immutable once learned: browser/webhook reconciliation may advance status only for the same PayPal order/capture and must never rebind a checkout to conflicting IDs.
2114 + $existing_order_id = !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '';
2115 + $existing_capture_id = !empty($gateway_checkout['gateway_ids']['capture_id']) ? (string)$gateway_checkout['gateway_ids']['capture_id'] : '';
2116 + if($existing_order_id && !hash_equals($existing_order_id, $order_id))
2117 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_order_conflict', 'gateway_checkout_id' => $gateway_checkout_id);
2118 + if($existing_capture_id && $capture_id && !hash_equals($existing_capture_id, $capture_id))
2119 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_capture_conflict', 'gateway_checkout_id' => $gateway_checkout_id);
2120 +
2121 + $existing_gateway_status = strtoupper((string)$gateway_checkout['gateway_status']);
2122 + //260902.0646 Provider finality is monotonic; stale browser/webhook observations must never downgrade a capture that already completed or reached a terminal failure.
2123 + if(in_array($existing_gateway_status, array('CAPTURE_COMPLETED', 'CAPTURE_DENIED', 'CAPTURE_FAILED', 'CAPTURE_DECLINED'), TRUE))
2124 + return array('handled' => TRUE, 'ok' => TRUE, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'order_id' => $existing_order_id ? $existing_order_id : $order_id, 'capture_id' => $existing_capture_id ? $existing_capture_id : $capture_id, 'status' => $existing_gateway_status);
2125 +
2126 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2127 + $gateway_ids['order_id'] = $order_id;
2128 + if($capture_id)
2129 + $gateway_ids['capture_id'] = $capture_id;
2130 +
2131 + $status = $capture_status ? 'CAPTURE_'.$capture_status : (!empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : 'ORDER_CREATED');
2132 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2133 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
2134 + if($capture_status && $capture_status !== 'PENDING')
2135 + unset($context['paypal_capture_started_at'], $context['paypal_capture_request_id']);
2136 + if($via === 'webhook')
1829 2137 {
1830 - self::dedupe_lock_release($capture_lock);
2138 + //260902.0635 Preserve a compact breadcrumb for the future admin diagnostics screen without retaining raw gateway payloads.
2139 + $context['paypal_capture_recovered_at'] = time();
2140 + $context['paypal_capture_recovered_via'] = 'webhook';
1831 2141 }
2142 +
2143 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
2144 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_save_failed', 'gateway_checkout_id' => $gateway_checkout_id);
2145 +
2146 + return array('handled' => TRUE, 'ok' => TRUE, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'order_id' => $order_id, 'capture_id' => $capture_id, 'status' => $status);
2147 + }
2148 + finally
2149 + {
2150 + if($owns_lock && $gateway_checkout_lock)
2151 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2152 + }
1832 2153 }
1833 2154
1834 2155 /**
2156 + * Fulfills one completed coordinator-backed PayPal order and saves its browser result.
2157 + *
2158 + * @since 260902.0635
2159 + */
2160 + public static function paypal_checkout_order_fulfill($order = array(), $token = array())
2161 + {
2162 + $order_id = !empty($order['id']) ? (string)$order['id'] : '';
2163 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
2164 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2165 +
2166 + if(!$gateway_checkout_id || ($completion_error = self::paypal_checkout_order_completion_error($order, $order_id, $token)))
2167 + return array('ok' => FALSE, 'error' => $completion_error ? $completion_error : 'gateway_checkout_invalid');
2168 +
2169 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2170 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2171 + return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid');
2172 +
2173 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
2174 + if($private_context === FALSE)
2175 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed');
2176 + //260907.1820 Gateway Checkout's fulfilled result is the outer browser/webhook convergence checkpoint; paypal_checkout_notify_once() remains the inner transaction-level entitlement dedupe.
2177 + if((string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']))
2178 + return array_merge(array('ok' => TRUE, 'processed' => FALSE, 'duplicate' => TRUE), $private_context['paypal_checkout']['fulfillment_result']);
2179 +
2180 + $capture = $order['purchase_units'][0]['payments']['captures'][0];
2181 + $pu_cap_id = (string)$capture['id'];
2182 + $paypal = array(
2183 + 'txn_type' => 'web_accept', 'payment_status' => 'Completed', 'subscr_gateway' => 'paypal',
2184 + 'txn_id' => $pu_cap_id, 'subscr_id' => $pu_cap_id, 'subscr_baid' => $pu_cap_id, 'subscr_cid' => $pu_cap_id,
2185 + 'mc_gross' => (string)$capture['amount']['value'], 'mc_currency' => strtoupper((string)$capture['amount']['currency_code']),
2186 + 'invoice' => $invoice, 'custom' => isset($token['custom']) ? (string)$token['custom'] : '',
2187 + 'item_name' => isset($token['item_name']) ? (string)$token['item_name'] : '', 'item_number' => isset($token['item_number']) ? (string)$token['item_number'] : '',
2188 + 'payer_email' => !empty($order['payer']['email_address']) ? (string)$order['payer']['email_address'] : (!empty($token['payer_email']) ? (string)$token['payer_email'] : ''),
2189 + 'first_name' => !empty($order['payer']['name']['given_name']) ? (string)$order['payer']['name']['given_name'] : (!empty($token['first_name']) ? (string)$token['first_name'] : ''),
2190 + 'last_name' => !empty($order['payer']['name']['surname']) ? (string)$order['payer']['name']['surname'] : (!empty($token['last_name']) ? (string)$token['last_name'] : ''),
2191 + 'option_name1' => isset($token['on0']) ? (string)$token['on0'] : '', 'option_selection1' => isset($token['os0']) ? (string)$token['os0'] : '',
2192 + 'option_name2' => isset($token['on1']) ? (string)$token['on1'] : '', 'option_selection2' => isset($token['os1']) ? (string)$token['os1'] : '',
2193 + );
2194 + if(isset($token['tax']))
2195 + $paypal['tax'] = (string)$token['tax'];
2196 +
2197 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
2198 + $notify_extra = array();
2199 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
2200 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
2201 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
2202 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
2203 +
2204 + //260907.1820 Keep the established PayPal Notify path authoritative for entitlement side effects, keyed by capture ID so simultaneous browser/webhook completion cannot process the same transaction twice.
2205 + $notify_result = self::paypal_checkout_notify_once($paypal, 's2m_ppco_capture_done_'.md5($pu_cap_id), $proxy_use, $notify_extra);
2206 + if(empty($notify_result['ok']))
2207 + return array('ok' => FALSE, 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed');
2208 +
2209 + $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', !empty($token['return']) ? (string)$token['return'] : home_url('/'));
2210 + $return_post = array_merge($paypal, array('s2member_paypal_proxy' => 'paypal', 's2member_paypal_proxy_use' => $proxy_use));
2211 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
2212 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
2213 +
2214 + $return_handoff = self::paypal_checkout_return_handoff_create($return_post);
2215 + if(!$return_handoff)
2216 + return array('ok' => FALSE, 'error' => 'return_handoff_failed');
2217 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
2218 +
2219 + $result = array('rtn_url' => $return_url, 'rtn_post' => $return_post, 'txn_id' => $pu_cap_id);
2220 + $private_context = (array)$private_context;
2221 + $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array();
2222 + //260907.1820 Persist the minimal browser handoff before marking fulfillment complete; if the final state write fails after Notify, notify_once still blocks duplicate entitlement work and this result remains recoverable. Passwords/card credentials never belong here.
2223 + $private_context['paypal_checkout']['fulfillment_result'] = $result;
2224 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
2225 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed');
2226 +
2227 + //260928.1705 Final fulfillment must patch the latest checkout version: a concurrent webhook/browser context write must not be lost or downgrade the terminal fulfilled state.
2228 + if(!c_ws_plugin__s2member_gateway_checkouts::patch($gateway_checkout_id, array('gateway_ids' => array('order_id' => $order_id, 'capture_id' => $pu_cap_id), 'gateway_status' => 'CAPTURE_COMPLETED', 'fulfillment_status' => 'fulfilled')))
2229 + return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed');
2230 +
2231 + return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result);
2232 + }
2233 +
2234 + /**
1835 2235 * Sends PayPal Checkout fulfillment through s2Member's existing PayPal Notify handler once.
1836 2236 *
1837 2237 * @since 260817
1838 2238 *
@@ -1935,13 +2335,186 @@
1935 2335 }
1936 2336 }
1937 2337
1938 2338 /**
1939 - * Creates a PayPal Checkout subscription (server-side) when using redirect-mode approval.
2339 + * Recovers a coordinator-backed PayPal subscription ID/status from a verified webhook resource.
1940 2340 *
1941 - * In JS SDK button mode, subscriptions are created client-side using plan_id and
1942 - * then confirmed server-side. Redirect-mode requires server-side creation.
2341 + * @since 260902.0200
1943 2342 *
2343 + * @param string $invoice PayPal custom_id/invoice carrying the Gateway Checkout ID.
2344 + * @param string $subscription_id PayPal subscription ID.
2345 + * @param string $status PayPal subscription status, if known.
2346 + *
2347 + * @return array Recovery result with handled/ok/recovered/error details.
2348 + */
2349 + public static function paypal_checkout_subscription_gateway_checkout_recover($invoice = '', $subscription_id = '', $status = '')
2350 + {
2351 + $invoice = trim((string)$invoice);
2352 + $subscription_id = trim((string)$subscription_id);
2353 + $status = strtoupper(trim((string)$status));
2354 + $gateway_checkout_id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice); //260928.1515 Recover both Pro-Forms and standalone Framework button subscriptions by their signed invoice identity.
2355 +
2356 + if(!$subscription_id || !c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id))
2357 + return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2358 +
2359 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2360 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2361 + return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2362 +
2363 + $lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60);
2364 + if(!$lock)
2365 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id);
2366 +
2367 + try
2368 + {
2369 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2370 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2371 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_invalid', 'gateway_checkout_id' => $gateway_checkout_id);
2372 +
2373 + $existing_subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
2374 + if($existing_subscription_id && !hash_equals($existing_subscription_id, $subscription_id))
2375 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_subscription_conflict', 'gateway_checkout_id' => $gateway_checkout_id, 'subscription_id' => $existing_subscription_id);
2376 +
2377 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2378 + $gateway_ids['subscription_id'] = $subscription_id;
2379 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2380 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2381 +
2382 + if(!$existing_subscription_id)
2383 + {
2384 + //260902.0200 Record webhook repair for future diagnostics without treating CREATED as payment/fulfillment.
2385 + $context['paypal_subscription_recovered_at'] = time();
2386 + $context['paypal_subscription_recovered_via'] = 'webhook';
2387 + }
2388 +
2389 + $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : '';
2390 + if($status === 'ACTIVE' || ($status === 'APPROVED' && $gateway_status === 'APPROVAL_PENDING') || !$gateway_status || $gateway_status === 'CREATE_PENDING')
2391 + $gateway_status = $status ? $status : 'APPROVAL_PENDING';
2392 +
2393 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $gateway_status, 'context' => $context)))
2394 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_save_failed', 'gateway_checkout_id' => $gateway_checkout_id);
2395 +
2396 + return array('handled' => true, 'ok' => true, 'recovered' => !$existing_subscription_id, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'subscription_id' => $subscription_id, 'status' => $gateway_status);
2397 + }
2398 + finally
2399 + {
2400 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $lock);
2401 + }
2402 + }
2403 +
2404 + /**
2405 + * Completes an approved standalone Framework button subscription from the same
2406 + * authoritative PayPal resource whether invoked by browser or verified webhook.
2407 + *
2408 + * @since 260928.1530
2409 + */
2410 + public static function paypal_checkout_button_subscription_fulfill($subscription = array(), $token = array(), $via = 'webhook')
2411 + {
2412 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
2413 + $id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2414 + if(!$id || strpos($invoice, 's2mb-') !== 0 || empty($token['gateway_checkout_id']) || !hash_equals($id, (string)$token['gateway_checkout_id']))
2415 + return array('ok' => FALSE, 'error' => 'gateway_checkout_identity_invalid');
2416 +
2417 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state($id);
2418 + if(!$state || (string)$state['gateway'] !== 'paypal_checkout' || (string)$state['operation'] !== 'subscription')
2419 + return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid');
2420 +
2421 + $subscription_id = !empty($subscription['id']) ? (string)$subscription['id'] : '';
2422 + $status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
2423 + $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
2424 + if(!$subscription_id || !$custom_id || !hash_equals($invoice, $custom_id))
2425 + return array('ok' => FALSE, 'error' => 'subscription_purchase_identity_mismatch');
2426 +
2427 + $expected_plan = self::paypal_checkout_plan_get_id($token);
2428 + if(!$expected_plan || empty($subscription['plan_id']) || !hash_equals((string)$expected_plan, (string)$subscription['plan_id']))
2429 + return array('ok' => FALSE, 'error' => 'subscription_plan_mismatch');
2430 +
2431 + $is_single_cycle = isset($token['rr']) && (string)$token['rr'] === '0';
2432 + $last_payment_amount = isset($subscription['billing_info']['last_payment']['amount']['value']) ? (string)$subscription['billing_info']['last_payment']['amount']['value'] : '';
2433 + $last_payment_currency = !empty($subscription['billing_info']['last_payment']['amount']['currency_code']) ? strtoupper((string)$subscription['billing_info']['last_payment']['amount']['currency_code']) : '';
2434 + //260928.1703 An immediately EXPIRED single-cycle subscription is paid only when PayPal's reported last payment matches the signed price and currency, not merely when a payment field exists.
2435 + $last_paid = ($last_payment_amount !== '' && is_numeric($last_payment_amount) && isset($token['amount'])
2436 + && number_format((float)$last_payment_amount, 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
2437 + && !empty($token['cc']) && $last_payment_currency === strtoupper((string)$token['cc']));
2438 + if($status !== 'ACTIVE' && !($is_single_cycle && $status === 'EXPIRED' && $last_paid))
2439 + return in_array($status, array('APPROVAL_PENDING', 'APPROVED'), TRUE)
2440 + ? array('ok' => FALSE, 'pending_activation' => TRUE, 'error' => 'pending_activation', 'status' => $status)
2441 + : array('ok' => FALSE, 'error' => 'subscription_status_invalid', 'status' => $status);
2442 +
2443 + //260928.1530 Bind the real subscription ID before fulfillment so a second event/browser request cannot attach a different provider subscription to this purchase.
2444 + $recovery = self::paypal_checkout_subscription_gateway_checkout_recover($invoice, $subscription_id, $status);
2445 + if(empty($recovery['handled']) || empty($recovery['ok']))
2446 + {
2447 + //260928.1608 An independent CREATED/ACTIVATED webhook can own the coordinator lock briefly; the browser should poll rather than report a permanent checkout failure.
2448 + //260928.1703 A redirect return also competes with CREATED/ACTIVATED webhook recovery; let it retry the signed return instead of displaying a spurious failure.
2449 + if(in_array($via, array('browser', 'return'), TRUE) && !empty($recovery['error']) && $recovery['error'] === 'gateway_checkout_busy')
2450 + return array('ok' => FALSE, 'pending_activation' => TRUE, 'status' => $status, 'error' => 'gateway_checkout_busy');
2451 + return array('ok' => FALSE, 'error' => !empty($recovery['error']) ? $recovery['error'] : 'subscription_recovery_failed');
2452 + }
2453 +
2454 + $private = c_ws_plugin__s2member_gateway_checkouts::private_context_get($id);
2455 + if(!is_array($private) || empty($private['paypal_checkout']['token']) || !is_array($private['paypal_checkout']['token']))
2456 + return array('ok' => FALSE, 'error' => 'gateway_checkout_purchase_context_missing');
2457 + $stored_token = $private['paypal_checkout']['token'];
2458 + if(empty($stored_token['invoice']) || !hash_equals($invoice, (string)$stored_token['invoice']) || empty($stored_token['item_number']))
2459 + return array('ok' => FALSE, 'error' => 'gateway_checkout_purchase_context_mismatch');
2460 +
2461 + $state = c_ws_plugin__s2member_gateway_checkouts::load_state($id);
2462 + if($state && (string)$state['fulfillment_status'] === 'fulfilled' && !empty($private['paypal_checkout']['fulfillment_result']))
2463 + return array_merge(array('ok' => TRUE, 'processed' => FALSE, 'duplicate' => TRUE), $private['paypal_checkout']['fulfillment_result']);
2464 +
2465 + $paypal = array(
2466 + 'txn_type' => 'subscr_signup', 'payment_status' => 'Completed', 'subscr_gateway' => 'paypal',
2467 + 'txn_id' => $subscription_id, 'subscr_id' => $subscription_id, 'subscr_baid' => $subscription_id, 'subscr_cid' => $subscription_id,
2468 + 'mc_gross' => (string)$stored_token['amount'], 'mc_currency' => strtoupper((string)$stored_token['cc']),
2469 + 'period1' => (!empty($stored_token['tp']) && !empty($stored_token['tt'])) ? ((string)$stored_token['tp'].' '.strtoupper((string)$stored_token['tt'])) : '0 D',
2470 + 'mc_amount1' => (!empty($stored_token['tp']) && !empty($stored_token['tt'])) ? (string)$stored_token['ta'] : '0.00',
2471 + 'period3' => ((string)$stored_token['rp'].' '.strtoupper((string)$stored_token['rt'])),
2472 + 'mc_amount3' => (string)$stored_token['amount'],
2473 + 'recurring' => ((isset($stored_token['rr']) && (string)$stored_token['rr'] === '1') ? '1' : '0'),
2474 + 'invoice' => $invoice, 'custom' => (string)$stored_token['custom'],
2475 + 'item_name' => (string)$stored_token['item_name'], 'item_number' => (string)$stored_token['item_number'],
2476 + 'payer_email' => !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '',
2477 + 'first_name' => !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '',
2478 + 'last_name' => !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '',
2479 + 'option_name1' => (string)$stored_token['on0'], 'option_selection1' => (string)$stored_token['os0'],
2480 + 'option_name2' => (string)$stored_token['on1'], 'option_selection2' => (string)$stored_token['os1'],
2481 + );
2482 +
2483 + $notify_result = self::paypal_checkout_notify_once($paypal, 's2m_ppco_subscr_done_'.md5($subscription_id));
2484 + if(empty($notify_result['ok']))
2485 + return array('ok' => FALSE, 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed');
2486 +
2487 + $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', (string)$stored_token['return']);
2488 + $return_post = array_merge($paypal, array('s2member_paypal_proxy' => 'paypal', 's2member_paypal_proxy_use' => 'paypal_checkout'));
2489 + $handoff = self::paypal_checkout_return_handoff_create($return_post);
2490 + if(!$handoff)
2491 + return array('ok' => FALSE, 'error' => 'return_handoff_failed');
2492 + $return_post['s2member_paypal_checkout_handoff'] = $handoff;
2493 + $result = array('rtn_url' => $return_url, 'rtn_post' => $return_post, 'subscription_id' => $subscription_id);
2494 +
2495 + $private['paypal_checkout']['fulfillment_result'] = $result;
2496 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($id, $private))
2497 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_save_failed');
2498 + if(!c_ws_plugin__s2member_gateway_checkouts::patch($id, array('gateway_ids' => array('subscription_id' => $subscription_id), 'gateway_status' => $status, 'fulfillment_status' => 'fulfilled')))
2499 + return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed');
2500 +
2501 + //260928.1530 Preserve button upgrade semantics: only the request that processed Notify may cancel the old subscription, never a duplicate callback.
2502 + $old_id = !empty($stored_token['old__subscr_id']) ? (string)$stored_token['old__subscr_id'] : '';
2503 + if(!empty($notify_result['processed']) && $old_id && $old_id !== $subscription_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', TRUE, array('old__subscr_id' => $old_id, 'subscr_id' => $subscription_id)))
2504 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription(!empty($stored_token['old__subscr_gateway']) ? (string)$stored_token['old__subscr_gateway'] : '', $old_id,
2505 + !empty($stored_token['old__subscr_baid']) ? (string)$stored_token['old__subscr_baid'] : '', !empty($stored_token['old__subscr_cid']) ? (string)$stored_token['old__subscr_cid'] : '',
2506 + !empty($stored_token['old__ipn_signup_vars']) && is_array($stored_token['old__ipn_signup_vars']) ? $stored_token['old__ipn_signup_vars'] : array());
2507 +
2508 + return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result);
2509 + }
2510 +
2511 + /**
2512 + * Creates a PayPal Checkout subscription server-side.
2513 + *
2514 + * Redirect-mode and coordinator-backed JS flows create here; legacy JS buttons may
2515 + * still create client-side using plan_id and then confirm server-side.
2516 + *
1944 2517 * @since 260114
1945 2518 *
1946 2519 * @param array $token Signed/validated purchase token.
1947 2520 *
@@ -1952,40 +2525,140 @@
1952 2525 if(!is_array($token))
1953 2526 return array();
1954 2527
1955 2528 $invoice = (string)$token['invoice'];
2529 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
2530 + $gateway_checkout_lock = '';
1956 2531
1957 - $plan_id = self::paypal_checkout_plan_get_id($token);
1958 - if(!$plan_id)
1959 - return array();
2532 + if($gateway_checkout_id)
2533 + {
2534 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2535 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2536 + return array('__error' => 'gateway_checkout_invalid');
1960 2537
1961 - $brand_name = get_bloginfo('name');
1962 - $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
2538 + //260901.2145 Return a previously persisted PayPal subscription before making another create request; this also recovers a browser reload after server-side creation succeeded.
2539 + if(!empty($gateway_checkout['gateway_ids']['subscription_id']))
2540 + return array('id' => (string)$gateway_checkout['gateway_ids']['subscription_id'], 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '');
1963 2541
1964 - $body = array(
1965 - 'plan_id' => $plan_id,
1966 - 'custom_id' => $invoice,
1967 - 'application_context' => array(
1968 - 'brand_name' => $brand_name,
1969 - 'return_url' => (string)$token['return'],
1970 - 'cancel_url' => (string)$token['cancel'],
1971 - 'user_action' => 'SUBSCRIBE_NOW',
1972 - 'shipping_preference' => 'NO_SHIPPING',
1973 - ),
1974 - );
2542 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
2543 + if(!$gateway_checkout_lock)
2544 + return array('__error' => 'gateway_checkout_busy');
1975 2545
1976 - // Idempotency: stable per invoice for create-subscription retries.
1977 - $headers = array(
1978 - 'PayPal-Request-Id' => 's2m-ppco-sub-'.md5($invoice),
1979 - );
2546 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2547 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2548 + {
2549 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2550 + return array('__error' => 'gateway_checkout_invalid');
2551 + }
2552 + if(!empty($gateway_checkout['gateway_ids']['subscription_id']))
2553 + {
2554 + $subscription_id = (string)$gateway_checkout['gateway_ids']['subscription_id'];
2555 + $status = !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '';
2556 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2557 + return array('id' => $subscription_id, 'status' => $status);
2558 + }
2559 + }
1980 2560
1981 - $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
2561 + try
2562 + {
2563 + $plan_id = self::paypal_checkout_plan_get_id($token);
2564 + if(!$plan_id)
2565 + return array('__error' => 'plan_create_failed');
1982 2566
1983 - $data = array();
1984 - if(!empty($r['body']) && is_string($r['body']))
1985 - $data = json_decode($r['body'], true);
2567 + $brand_name = get_bloginfo('name');
2568 + $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
1986 2569
1987 - return is_array($data) ? $data : array();
2570 + $body = array(
2571 + 'plan_id' => $plan_id,
2572 + 'custom_id' => $invoice,
2573 + 'application_context' => array(
2574 + 'brand_name' => $brand_name,
2575 + 'return_url' => (string)$token['return'],
2576 + 'cancel_url' => (string)$token['cancel'],
2577 + 'user_action' => 'SUBSCRIBE_NOW',
2578 + 'shipping_preference' => 'NO_SHIPPING',
2579 + ),
2580 + );
2581 +
2582 + //260901.2145 Coordinator-backed Pro-Forms use the logical checkout ID as PayPal's stable idempotency anchor; legacy callers retain the established invoice-derived key.
2583 + $request_id = $gateway_checkout_id ? 's2m-ppco-sub-'.str_replace('-', '', $gateway_checkout_id) : 's2m-ppco-sub-'.md5($invoice);
2584 + $headers = array('PayPal-Request-Id' => $request_id);
2585 +
2586 + if($gateway_checkout_id)
2587 + {
2588 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2589 + $create_started_at = !empty($context['paypal_subscription_create_started_at']) ? (int)$context['paypal_subscription_create_started_at'] : 0;
2590 +
2591 + if($create_started_at && $create_started_at <= time() - (3 * DAY_IN_SECONDS))
2592 + {
2593 + //260902.0200 An unresolved server-created subscription could never reach buyer approval without its ID reaching the browser; after PayPal's 72-hour idempotency window, start a fresh approval-pending create instead of permanently blocking the checkout.
2594 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2595 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
2596 + if(!$gateway_checkout)
2597 + return array('__error' => 'gateway_checkout_save_failed');
2598 + $create_started_at = 0;
2599 + }
2600 +
2601 + if(!$create_started_at)
2602 + {
2603 + $context['paypal_subscription_create_started_at'] = time();
2604 + $context['paypal_subscription_request_id'] = $request_id;
2605 + //260901.2145 Record an in-flight create before contacting PayPal so changed purchase terms cannot silently abandon an ambiguous subscription attempt.
2606 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CREATE_PENDING', 'context' => $context));
2607 + if(!$gateway_checkout)
2608 + return array('__error' => 'gateway_checkout_save_failed');
2609 + }
2610 + }
2611 +
2612 + $data = array();
2613 + $code = 0;
2614 + $ambiguous = FALSE;
2615 + for($attempt = 0; $attempt < 2; $attempt++)
2616 + {
2617 + $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
2618 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
2619 + $response_body = !empty($r['body']) ? (string)$r['body'] : '';
2620 + $data = $response_body ? json_decode($response_body, true) : array();
2621 + $data = is_array($data) ? $data : array();
2622 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
2623 +
2624 + if($code >= 200 && $code <= 299 && !empty($data['id']))
2625 + break;
2626 + if(!$ambiguous)
2627 + break;
2628 + }
2629 +
2630 + if($gateway_checkout_id && $code >= 200 && $code <= 299 && !empty($data['id']))
2631 + {
2632 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2633 + $gateway_ids['subscription_id'] = (string)$data['id'];
2634 + $status = !empty($data['status']) ? strtoupper((string)$data['status']) : 'APPROVAL_PENDING';
2635 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2636 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2637 +
2638 + //260901.2145 Persist the PayPal subscription ID before returning it to the browser; if persistence fails, retrying within PayPal's idempotency window recovers the same resource.
2639 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
2640 + return array('__error' => 'gateway_checkout_save_failed');
2641 + }
2642 + else if($gateway_checkout_id && !$ambiguous)
2643 + {
2644 + //260901.2145 A deterministic rejection did not create a subscription; clear the in-flight marker so a corrected attempt is not treated as an unresolved provider result.
2645 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2646 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2647 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
2648 + }
2649 +
2650 + //260902.0200 Preserve an ambiguous create as recoverable state so the browser can briefly wait for the independent CREATED webhook instead of repeatedly calling PayPal.
2651 + if($gateway_checkout_id && $ambiguous && !($code >= 200 && $code <= 299 && !empty($data['id'])))
2652 + return array('__error' => 'subscription_create_unresolved');
2653 +
2654 + return $data;
2655 + }
2656 + finally
2657 + {
2658 + if($gateway_checkout_id && $gateway_checkout_lock)
2659 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2660 + }
1988 2661 }
1989 2662
1990 2663 /**
1991 2664 * Returns a PayPal Checkout Plan ID for a subscription token (creates product/plan if needed).
@@ -2420,9 +3093,11 @@
2420 3093 {
2421 3094 //260820.0218 Keep automatic webhook registration aligned with the events handled by s2Member and listed in PayPal Checkout setup help.
2422 3095 return array(
2423 3096 'PAYMENT.SALE.COMPLETED',
3097 + 'PAYMENT.CAPTURE.PENDING',
2424 3098 'PAYMENT.CAPTURE.COMPLETED',
3099 + 'PAYMENT.CAPTURE.DENIED',
2425 3100 'PAYMENT.SALE.REFUNDED',
2426 3101 'PAYMENT.CAPTURE.REFUNDED',
2427 3102 'PAYMENT.SALE.REVERSED',
2428 3103 'PAYMENT.CAPTURE.REVERSED',