PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260927
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260927
260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 All 190 releases
← All changes | src/includes/classes/paypal-utilities.inc.php +687 -186 260829 → 260927 View file →
@@ -120,9 +120,10 @@
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 + //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"]))
125 126 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_REQUEST["s2member_paypal_proxy"])), get_defined_vars());
126 127
127 128 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 129 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_GET["s2member_paypal_proxy"])), get_defined_vars());
@@ -323,10 +324,15 @@
323 324 if(is_multisite() && !is_main_site())
324 325 $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(strtolower($current_blog->domain.$current_blog->path), false, false));
325 326
326 327 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));
328 + //260909.0217 Normalize host selection so proxy verification behaves consistently across different server configurations.
329 + $site_host = preg_replace("/\:[0-9]+$/", "", strtolower((string)parse_url(home_url('/'), PHP_URL_HOST)));
330 + $request_host = (!empty($_SERVER["HTTP_HOST"]) && is_string($_SERVER["HTTP_HOST"])) ? preg_replace("/\:[0-9]+$/", "", strtolower($_SERVER["HTTP_HOST"])) : '';
331 + $host = ($GLOBALS['WS_PLUGIN__']['s2member']['o']['skip_ipn_domain_validation']) ? $site_host : $request_host;
332 + $host = strlen($host) ? $host : $site_host;
333 + $host = strlen($host) ? $host : 's2member-paypal-proxy'; //260909.0338 Provide a stable final fallback when no usable site host is available.
334 + $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt($host, false, false));
329 335 }
330 336
331 337 return apply_filters("ws_plugin__s2member_paypal_proxy_key_gen", $key, get_defined_vars());
332 338 }
@@ -1486,8 +1492,49 @@
1486 1492 return '';
1487 1493 }
1488 1494
1489 1495 /**
1496 + * Returns the first PayPal capture ID/status from an order representation.
1497 + *
1498 + * @since 260902.0635
1499 + *
1500 + * @param array $order PayPal order representation.
1501 + *
1502 + * @return array Capture snapshot with id/status.
1503 + */
1504 + public static function paypal_checkout_order_capture_snapshot($order = array())
1505 + {
1506 + $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();
1507 +
1508 + return array(
1509 + 'id' => !empty($capture['id']) ? (string)$capture['id'] : '',
1510 + 'status' => !empty($capture['status']) ? strtoupper((string)$capture['status']) : '',
1511 + );
1512 + }
1513 +
1514 + /**
1515 + * Extracts a Gateway Checkout ID from a modern PayPal Checkout Pro-Form invoice.
1516 + *
1517 + * @since 260902.0635
1518 + *
1519 + * @param string $invoice Membership (`s2mpf-`) or Specific Post/Page (`s2msp-`) invoice.
1520 + *
1521 + * @return string Gateway Checkout ID, else an empty string.
1522 + */
1523 + public static function paypal_checkout_gateway_checkout_id_from_invoice($invoice = '')
1524 + {
1525 + $invoice = (string)$invoice;
1526 + $gateway_checkout_id = '';
1527 +
1528 + if(strpos($invoice, 's2mpf-') === 0)
1529 + $gateway_checkout_id = substr($invoice, strlen('s2mpf-'));
1530 + else if(strpos($invoice, 's2msp-') === 0)
1531 + $gateway_checkout_id = substr($invoice, strlen('s2msp-'));
1532 +
1533 + return c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id) ? $gateway_checkout_id : '';
1534 + }
1535 +
1536 + /**
1490 1537 * Creates a PayPal Checkout order for one-time (Buy Now) purchases.
1491 1538 *
1492 1539 * This must be server-side to prevent client-side manipulation of amount, item_number,
1493 1540 * custom fields, etc. The resulting order id is returned to the JS SDK or used for
@@ -1500,126 +1547,192 @@
1500 1547 * @return array API request result array from paypal_checkout_api_request().
1501 1548 */
1502 1549 public static function paypal_checkout_order_create($token = array())
1503 1550 {
1551 + if(!is_array($token))
1552 + return array('__error' => 'invalid_token');
1553 +
1504 1554 // 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']);
1555 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1556 + $custom = isset($token['custom']) ? (string)$token['custom'] : '';
1557 + $amount = isset($token['amount']) ? (string)$token['amount'] : '';
1558 + $cc = !empty($token['cc']) ? strtoupper((string)$token['cc']) : '';
1559 + $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'] : '';
1560 + $gateway_checkout_lock = '';
1509 1561
1510 - $item_name = trim((string)$token['item_name']);
1511 - if(!$item_name)
1512 - $item_name = 's2Member Purchase';
1562 + if($gateway_checkout_id)
1563 + {
1564 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1565 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1566 + return array('__error' => 'gateway_checkout_invalid');
1513 1567
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);
1568 + //260902.0635 Return an already-persisted PayPal order before another provider create; a lost browser response can therefore resume the same logical purchase.
1569 + if(!empty($gateway_checkout['gateway_ids']['order_id']))
1570 + return array('id' => (string)$gateway_checkout['gateway_ids']['order_id'], 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '');
1517 1571
1518 - $item_sku = trim((string)$token['item_number']);
1519 - if(strlen($item_sku) > 127)
1520 - $item_sku = substr($item_sku, 0, 127);
1572 + //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.
1573 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
1574 + if(!$gateway_checkout_lock)
1575 + return array('__error' => 'gateway_checkout_busy');
1521 1576
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, '.', ''))
1577 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1578 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1579 + {
1580 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1581 + return array('__error' => 'gateway_checkout_invalid');
1582 + }
1583 + if(!empty($gateway_checkout['gateway_ids']['order_id']))
1584 + {
1585 + $order_id = (string)$gateway_checkout['gateway_ids']['order_id'];
1586 + $status = !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '';
1587 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1588 + return array('id' => $order_id, 'status' => $status);
1589 + }
1590 + }
1591 +
1592 + try
1527 1593 {
1528 - $item_amount = (string)$token['sub_total'];
1529 - $tax_amount = (string)$token['tax'];
1530 - }
1594 + $item_name = !empty($token['item_name']) ? trim((string)$token['item_name']) : '';
1595 + if(!$item_name)
1596 + $item_name = 's2Member Purchase';
1597 + if(strlen($item_name) > 127)
1598 + $item_name = substr($item_name, 0, 127);
1531 1599
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 - ),
1600 + $item_sku = !empty($token['item_number']) ? trim((string)$token['item_number']) : '';
1601 + if(strlen($item_sku) > 127)
1602 + $item_sku = substr($item_sku, 0, 127);
1603 +
1604 + //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.
1605 + $item_amount = $amount;
1606 + $tax_amount = '';
1607 + if(isset($token['sub_total'], $token['tax']) && is_numeric($token['sub_total']) && is_numeric($token['tax'])
1608 + && number_format((float)$token['sub_total'] + (float)$token['tax'], 2, '.', '') === number_format((float)$amount, 2, '.', ''))
1609 + {
1610 + $item_amount = (string)$token['sub_total'];
1611 + $tax_amount = (string)$token['tax'];
1612 + }
1613 +
1614 + $purchase_unit = array(
1615 + 'invoice_id' => $invoice,
1616 + 'amount' => array(
1617 + 'currency_code' => $cc,
1618 + 'value' => $amount,
1619 + 'breakdown' => array('item_total' => array('currency_code' => $cc, 'value' => $item_amount)),
1542 1620 ),
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 - ),
1621 + 'description' => $item_name,
1622 + 'items' => array(array('name' => $item_name, 'quantity' => '1', 'unit_amount' => array('currency_code' => $cc, 'value' => $item_amount))),
1623 + );
1624 + if($tax_amount !== '' && (float)$tax_amount > 0)
1625 + {
1626 + $purchase_unit['amount']['breakdown']['tax_total'] = array('currency_code' => $cc, 'value' => $tax_amount);
1627 + $purchase_unit['items'][0]['tax'] = array('currency_code' => $cc, 'value' => $tax_amount);
1628 + }
1629 + if($item_sku)
1630 + $purchase_unit['items'][0]['sku'] = $item_sku;
1631 + if($custom && strlen($custom) <= 127)
1632 + $purchase_unit['custom_id'] = $custom;
1633 +
1634 + $body = array(
1635 + 'intent' => 'CAPTURE',
1636 + 'purchase_units' => array($purchase_unit),
1637 + 'application_context' => array(
1638 + 'user_action' => 'PAY_NOW',
1639 + 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1640 + 'return_url' => !empty($token['return']) ? (string)$token['return'] : '',
1641 + 'cancel_url' => !empty($token['cancel']) ? (string)$token['cancel'] : '',
1553 1642 ),
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 1643 );
1563 - $purchase_unit['items'][0]['tax'] = array(
1564 - 'currency_code' => $cc,
1565 - 'value' => $tax_amount,
1566 - );
1567 - }
1568 1644
1569 - if($item_sku)
1570 - $purchase_unit['items'][0]['sku'] = $item_sku;
1645 + //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.
1646 + $request_id = $gateway_checkout_id ? 's2m-ppco-order-'.str_replace('-', '', $gateway_checkout_id) : 's2m-ppco-order-'.md5($invoice);
1647 + $headers = array('PayPal-Request-Id' => $request_id);
1571 1648
1572 - // PayPal limits custom_id length; keep it short/consistent.
1573 - if($custom && strlen($custom) <= 127)
1574 - $purchase_unit['custom_id'] = $custom;
1649 + if($gateway_checkout_id)
1650 + {
1651 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
1652 + if($private_context === FALSE)
1653 + return array('__error' => 'gateway_checkout_private_context_failed');
1654 + $private_context = (array)$private_context;
1655 + $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array();
1656 + //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;
1658 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
1659 + return array('__error' => 'gateway_checkout_private_context_failed');
1575 1660
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 - );
1661 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1662 + $create_started_at = !empty($context['paypal_order_create_started_at']) ? (int)$context['paypal_order_create_started_at'] : 0;
1663 + //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.
1664 + if($create_started_at && $create_started_at <= time() - (6 * HOUR_IN_SECONDS))
1665 + {
1666 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1667 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
1668 + if(!$gateway_checkout)
1669 + return array('__error' => 'gateway_checkout_save_failed');
1670 + $create_started_at = 0;
1671 + }
1672 + if(!$create_started_at)
1673 + {
1674 + $context['paypal_order_create_started_at'] = time();
1675 + $context['paypal_order_request_id'] = $request_id;
1676 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CREATE_PENDING', 'context' => $context));
1677 + if(!$gateway_checkout)
1678 + return array('__error' => 'gateway_checkout_save_failed');
1679 + }
1680 + }
1586 1681
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++)
1682 + $data = array();
1683 + $code = 0;
1684 + $ambiguous = FALSE;
1685 + //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.
1686 + for($attempt = 0; $attempt < 2; $attempt++)
1594 1687 {
1595 1688 $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1596 1689 $code = !empty($r['code']) ? (int)$r['code'] : 0;
1597 1690 $response_body = !empty($r['body']) ? (string)$r['body'] : '';
1598 - $data = ($response_body) ? json_decode($response_body, true) : array();
1691 + $data = $response_body ? json_decode($response_body, true) : array();
1599 1692 $data = is_array($data) ? $data : array();
1693 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
1600 1694
1601 1695 if($code >= 200 && $code <= 299 && !empty($data['id']))
1602 1696 break;
1603 -
1604 - $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
1605 1697 if(!$ambiguous)
1606 1698 break;
1607 1699 }
1608 1700
1609 - if($code >= 200 && $code <= 299 && !empty($data['id']))
1701 + if($code >= 200 && $code <= 299 && !empty($data['id']))
1610 1702 {
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);
1703 + 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);
1704 +
1705 + if($gateway_checkout_id)
1706 + {
1707 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
1708 + $gateway_ids['order_id'] = (string)$data['id'];
1709 + $status = !empty($data['status']) ? 'ORDER_'.strtoupper((string)$data['status']) : 'ORDER_CREATED';
1710 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1711 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1712 + //260902.0635 Persist the PayPal order ID before returning it to the browser; a reload can then reuse it without a second provider create.
1713 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
1714 + return array('__error' => 'gateway_checkout_save_failed');
1715 + }
1619 1716 }
1717 + else if($gateway_checkout_id && !$ambiguous)
1718 + {
1719 + //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.
1720 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1721 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
1722 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
1723 + }
1620 1724
1621 - return $data;
1725 + if($gateway_checkout_id && $ambiguous && !($code >= 200 && $code <= 299 && !empty($data['id'])))
1726 + return array('__error' => 'order_create_unresolved');
1727 +
1728 + return $data;
1729 + }
1730 + finally
1731 + {
1732 + if($gateway_checkout_id && $gateway_checkout_lock)
1733 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
1734 + }
1622 1735 }
1623 1736
1624 1737 /**
1625 1738 * Retrieves PayPal Checkout subscription details via the Subscriptions REST API.
@@ -1735,104 +1848,324 @@
1735 1848 if(!$order_id)
1736 1849 return array('__error' => 'missing_order_id');
1737 1850
1738 1851 $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
1852 + $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 1853 $binding_name = $invoice ? 's2m_ppco_order_bind_'.md5($invoice) : '';
1740 1854 $binding = $binding_name ? get_transient($binding_name) : false;
1855 + $gateway_checkout_lock = '';
1741 1856
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']);
1857 + if($gateway_checkout_id)
1858 + {
1859 + //260907.1820 For coordinator-backed captures, the order ID already persisted server-side is authoritative; never let a browser-supplied order ID rebind this logical checkout to another PayPal resource.
1860 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1861 + $expected_order_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '';
1862 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment' || !$expected_order_id || !hash_equals($expected_order_id, $order_id))
1863 + return array('__error' => 'gateway_checkout_order_mismatch');
1749 1864
1750 - if(!$binding_matches)
1751 - return array('__error' => 'order_binding_mismatch');
1752 - }
1865 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
1866 + if(!$gateway_checkout_lock)
1867 + return array('__error' => 'gateway_checkout_busy');
1868 + }
1869 + else if(is_array($binding))
1870 + {
1871 + $binding_matches = (!empty($binding['order_id']) && (string)$binding['order_id'] === $order_id
1872 + && isset($binding['invoice']) && (string)$binding['invoice'] === $invoice
1873 + && isset($binding['amount']) && number_format((float)$binding['amount'], 2, '.', '') === number_format((float)$token['amount'], 2, '.', '')
1874 + && isset($binding['cc']) && strtoupper((string)$binding['cc']) === strtoupper((string)$token['cc'])
1875 + && isset($binding['custom']) && (string)$binding['custom'] === (string)$token['custom']);
1876 + if(!$binding_matches)
1877 + return array('__error' => 'order_binding_mismatch');
1878 + }
1753 1879
1754 - $capture_lock = 's2m_ppco_capture_lock_'.md5($order_id);
1755 - if(!self::dedupe_lock_acquire($capture_lock, 300))
1880 + $capture_lock = $gateway_checkout_id ? '' : 's2m_ppco_capture_lock_'.md5($order_id);
1881 + if(!$gateway_checkout_id && !self::dedupe_lock_acquire($capture_lock, 300))
1756 1882 return array('__error' => 'capture_in_progress');
1757 1883
1758 1884 try
1885 + {
1886 + if($gateway_checkout_id)
1759 1887 {
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;
1888 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1889 + if(!$gateway_checkout || empty($gateway_checkout['gateway_ids']['order_id']) || !hash_equals((string)$gateway_checkout['gateway_ids']['order_id'], $order_id))
1890 + return array('__error' => 'gateway_checkout_order_mismatch');
1766 1891
1767 - if(($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
1768 - return array('__error' => $validation_error);
1892 + $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : '';
1893 + //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.
1894 + if(in_array($gateway_status, array('CAPTURE_DENIED', 'CAPTURE_FAILED', 'CAPTURE_DECLINED'), TRUE))
1895 + return array('__error' => strtolower($gateway_status));
1896 + }
1769 1897
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);
1898 + //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.
1899 + $read_only = ($gateway_checkout_id && !empty($gateway_checkout['gateway_status']) && strtoupper((string)$gateway_checkout['gateway_status']) === 'CAPTURE_PENDING');
1900 + if(!is_array($binding) || $gateway_checkout_id || $read_only)
1901 + {
1902 + $details = self::paypal_checkout_order_details($order_id);
1903 + if(!empty($details['__error']))
1904 + return $details;
1905 + if(($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
1906 + return array('__error' => $validation_error);
1774 1907
1775 - return $details;
1776 - }
1777 - if(empty($details['status']) || strtoupper((string)$details['status']) !== 'APPROVED')
1778 - return array('__error' => 'order_not_approved');
1779 - }
1908 + $snapshot = self::paypal_checkout_order_capture_snapshot($details);
1909 + if($snapshot['id'] && $snapshot['status'])
1910 + {
1911 + if($gateway_checkout_id)
1912 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
1913 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($details, $order_id, $token))
1914 + return $details;
1915 + if($snapshot['status'] === 'PENDING')
1916 + return array_merge($details, array('__error' => 'capture_pending'));
1917 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
1918 + return array_merge($details, array('__error' => 'capture_'.strtolower($snapshot['status'])));
1919 + }
1780 1920
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 - );
1921 + if($read_only)
1922 + return array_merge($details, array('__error' => 'capture_pending'));
1923 + if(!empty($details['status']) && strtoupper((string)$details['status']) === 'COMPLETED')
1924 + return array('__error' => self::paypal_checkout_order_completion_error($details, $order_id, $token));
1925 + if(empty($details['status']) || strtoupper((string)$details['status']) !== 'APPROVED')
1926 + return array('__error' => 'order_not_approved');
1927 + }
1786 1928
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();
1929 + if($gateway_checkout_id)
1930 + {
1931 + //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.
1932 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
1933 + $context['paypal_capture_started_at'] = !empty($context['paypal_capture_started_at']) ? (int)$context['paypal_capture_started_at'] : time();
1934 + $context['paypal_capture_request_id'] = 's2m-ppco-cap-'.md5($order_id);
1935 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CAPTURE_PENDING', 'context' => $context));
1936 + if(!$gateway_checkout)
1937 + return array('__error' => 'gateway_checkout_save_failed');
1938 + }
1796 1939
1797 - if($code >= 200 && $code <= 299)
1798 - break;
1940 + //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.
1941 + $headers = array('PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id), 'Prefer' => 'return=representation');
1942 + $r = array();
1943 + $data = array();
1944 + $ambiguous = FALSE;
1945 + for($attempt = 0; $attempt < 2; $attempt++)
1946 + {
1947 + $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
1948 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
1949 + $body = !empty($r['body']) ? (string)$r['body'] : '';
1950 + $data = $body ? json_decode($body, true) : array();
1951 + $data = is_array($data) ? $data : array();
1952 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500);
1953 + if($code >= 200 && $code <= 299)
1954 + break;
1955 + if(!$ambiguous)
1956 + break;
1957 + }
1799 1958
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)))
1959 + if($code >= 200 && $code <= 299)
1960 + {
1961 + $snapshot = self::paypal_checkout_order_capture_snapshot($data);
1962 + if($snapshot['id'] && $snapshot['status'])
1963 + {
1964 + if($gateway_checkout_id)
1965 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
1966 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($data, $order_id, $token))
1807 1967 {
1808 - if($binding_name)
1809 - delete_transient($binding_name);
1968 + if($binding_name) delete_transient($binding_name);
1810 1969 return $data;
1811 1970 }
1971 + if($snapshot['status'] === 'PENDING')
1972 + return array_merge($data, array('__error' => 'capture_pending'));
1973 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
1974 + return array_merge($data, array('__error' => 'capture_'.strtolower($snapshot['status'])));
1975 + }
1976 + }
1812 1977
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)))
1978 + //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.
1979 + $details = self::paypal_checkout_order_details($order_id);
1980 + if(empty($details['__error']) && !($validation_error = self::paypal_checkout_order_validation_error($details, $order_id, $token)))
1981 + {
1982 + $snapshot = self::paypal_checkout_order_capture_snapshot($details);
1983 + if($snapshot['id'] && $snapshot['status'])
1984 + {
1985 + if($gateway_checkout_id)
1986 + self::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $snapshot['id'], $snapshot['status'], 'browser', $gateway_checkout_lock);
1987 + if($snapshot['status'] === 'COMPLETED' && !self::paypal_checkout_order_completion_error($details, $order_id, $token))
1816 1988 {
1817 - if($binding_name)
1818 - delete_transient($binding_name);
1989 + if($binding_name) delete_transient($binding_name);
1819 1990 return $details;
1820 1991 }
1992 + if($snapshot['status'] === 'PENDING')
1993 + return array_merge($details, array('__error' => 'capture_pending'));
1994 + if(in_array($snapshot['status'], array('DENIED', 'FAILED', 'DECLINED'), TRUE))
1995 + return array_merge($details, array('__error' => 'capture_'.strtolower($snapshot['status'])));
1996 + }
1997 + }
1821 1998
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 - }
1999 + if($gateway_checkout_id && $ambiguous)
2000 + return array('__error' => 'order_capture_unresolved');
2001 + if(!empty($details['__error']))
2002 + return $details;
2003 + return array('__error' => 'order_capture_failed', '__code' => !empty($r['code']) ? (int)$r['code'] : 0, '__body' => !empty($r['body']) ? (string)$r['body'] : '');
2004 + }
1828 2005 finally
2006 + {
2007 + if($gateway_checkout_id && $gateway_checkout_lock)
2008 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2009 + else if(!$gateway_checkout_id && $capture_lock)
2010 + self::dedupe_lock_release($capture_lock);
2011 + }
2012 + }
2013 +
2014 + /**
2015 + * Reconciles a one-time PayPal order/capture into Gateway Checkout state.
2016 + *
2017 + * @since 260902.0635
2018 + */
2019 + public static function paypal_checkout_order_gateway_checkout_recover($invoice = '', $order_id = '', $capture_id = '', $capture_status = '', $via = 'webhook', $gateway_checkout_lock = '')
2020 + {
2021 + $gateway_checkout_id = self::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
2022 + $order_id = trim((string)$order_id);
2023 + $capture_id = trim((string)$capture_id);
2024 + $capture_status = strtoupper(trim((string)$capture_status));
2025 + $owns_lock = FALSE;
2026 +
2027 + if(!$gateway_checkout_id || !$order_id)
2028 + return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout');
2029 +
2030 + if(!$gateway_checkout_lock)
2031 + {
2032 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60);
2033 + if(!$gateway_checkout_lock)
2034 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id);
2035 + $owns_lock = TRUE;
2036 + }
2037 +
2038 + try
2039 + {
2040 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2041 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2042 + return array('handled' => FALSE, 'ok' => FALSE, 'error' => 'not_coordinator_checkout');
2043 +
2044 + //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.
2045 + $existing_order_id = !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '';
2046 + $existing_capture_id = !empty($gateway_checkout['gateway_ids']['capture_id']) ? (string)$gateway_checkout['gateway_ids']['capture_id'] : '';
2047 + if($existing_order_id && !hash_equals($existing_order_id, $order_id))
2048 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_order_conflict', 'gateway_checkout_id' => $gateway_checkout_id);
2049 + if($existing_capture_id && $capture_id && !hash_equals($existing_capture_id, $capture_id))
2050 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_capture_conflict', 'gateway_checkout_id' => $gateway_checkout_id);
2051 +
2052 + $existing_gateway_status = strtoupper((string)$gateway_checkout['gateway_status']);
2053 + //260902.0646 Provider finality is monotonic; stale browser/webhook observations must never downgrade a capture that already completed or reached a terminal failure.
2054 + if(in_array($existing_gateway_status, array('CAPTURE_COMPLETED', 'CAPTURE_DENIED', 'CAPTURE_FAILED', 'CAPTURE_DECLINED'), TRUE))
2055 + 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);
2056 +
2057 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2058 + $gateway_ids['order_id'] = $order_id;
2059 + if($capture_id)
2060 + $gateway_ids['capture_id'] = $capture_id;
2061 +
2062 + $status = $capture_status ? 'CAPTURE_'.$capture_status : (!empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : 'ORDER_CREATED');
2063 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2064 + unset($context['paypal_order_create_started_at'], $context['paypal_order_request_id']);
2065 + if($capture_status && $capture_status !== 'PENDING')
2066 + unset($context['paypal_capture_started_at'], $context['paypal_capture_request_id']);
2067 + if($via === 'webhook')
1829 2068 {
1830 - self::dedupe_lock_release($capture_lock);
2069 + //260902.0635 Preserve a compact breadcrumb for the future admin diagnostics screen without retaining raw gateway payloads.
2070 + $context['paypal_capture_recovered_at'] = time();
2071 + $context['paypal_capture_recovered_via'] = 'webhook';
1831 2072 }
2073 +
2074 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
2075 + return array('handled' => TRUE, 'ok' => FALSE, 'error' => 'gateway_checkout_save_failed', 'gateway_checkout_id' => $gateway_checkout_id);
2076 +
2077 + return array('handled' => TRUE, 'ok' => TRUE, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'order_id' => $order_id, 'capture_id' => $capture_id, 'status' => $status);
2078 + }
2079 + finally
2080 + {
2081 + if($owns_lock && $gateway_checkout_lock)
2082 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2083 + }
1832 2084 }
1833 2085
1834 2086 /**
2087 + * Fulfills one completed coordinator-backed PayPal order and saves its browser result.
2088 + *
2089 + * @since 260902.0635
2090 + */
2091 + public static function paypal_checkout_order_fulfill($order = array(), $token = array())
2092 + {
2093 + $order_id = !empty($order['id']) ? (string)$order['id'] : '';
2094 + $invoice = !empty($token['invoice']) ? (string)$token['invoice'] : '';
2095 + $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);
2096 +
2097 + if(!$gateway_checkout_id || ($completion_error = self::paypal_checkout_order_completion_error($order, $order_id, $token)))
2098 + return array('ok' => FALSE, 'error' => $completion_error ? $completion_error : 'gateway_checkout_invalid');
2099 +
2100 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2101 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
2102 + return array('ok' => FALSE, 'error' => 'gateway_checkout_invalid');
2103 +
2104 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
2105 + if($private_context === FALSE)
2106 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed');
2107 + //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.
2108 + if((string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']))
2109 + return array_merge(array('ok' => TRUE, 'processed' => FALSE, 'duplicate' => TRUE), $private_context['paypal_checkout']['fulfillment_result']);
2110 +
2111 + $capture = $order['purchase_units'][0]['payments']['captures'][0];
2112 + $pu_cap_id = (string)$capture['id'];
2113 + $paypal = array(
2114 + 'txn_type' => 'web_accept', 'payment_status' => 'Completed', 'subscr_gateway' => 'paypal',
2115 + 'txn_id' => $pu_cap_id, 'subscr_id' => $pu_cap_id, 'subscr_baid' => $pu_cap_id, 'subscr_cid' => $pu_cap_id,
2116 + 'mc_gross' => (string)$capture['amount']['value'], 'mc_currency' => strtoupper((string)$capture['amount']['currency_code']),
2117 + 'invoice' => $invoice, 'custom' => isset($token['custom']) ? (string)$token['custom'] : '',
2118 + 'item_name' => isset($token['item_name']) ? (string)$token['item_name'] : '', 'item_number' => isset($token['item_number']) ? (string)$token['item_number'] : '',
2119 + 'payer_email' => !empty($order['payer']['email_address']) ? (string)$order['payer']['email_address'] : (!empty($token['payer_email']) ? (string)$token['payer_email'] : ''),
2120 + 'first_name' => !empty($order['payer']['name']['given_name']) ? (string)$order['payer']['name']['given_name'] : (!empty($token['first_name']) ? (string)$token['first_name'] : ''),
2121 + 'last_name' => !empty($order['payer']['name']['surname']) ? (string)$order['payer']['name']['surname'] : (!empty($token['last_name']) ? (string)$token['last_name'] : ''),
2122 + 'option_name1' => isset($token['on0']) ? (string)$token['on0'] : '', 'option_selection1' => isset($token['os0']) ? (string)$token['os0'] : '',
2123 + 'option_name2' => isset($token['on1']) ? (string)$token['on1'] : '', 'option_selection2' => isset($token['os1']) ? (string)$token['os1'] : '',
2124 + );
2125 + if(isset($token['tax']))
2126 + $paypal['tax'] = (string)$token['tax'];
2127 +
2128 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
2129 + $notify_extra = array();
2130 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
2131 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
2132 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
2133 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
2134 +
2135 + //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.
2136 + $notify_result = self::paypal_checkout_notify_once($paypal, 's2m_ppco_capture_done_'.md5($pu_cap_id), $proxy_use, $notify_extra);
2137 + if(empty($notify_result['ok']))
2138 + return array('ok' => FALSE, 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed');
2139 +
2140 + $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', !empty($token['return']) ? (string)$token['return'] : home_url('/'));
2141 + $return_post = array_merge($paypal, array('s2member_paypal_proxy' => 'paypal', 's2member_paypal_proxy_use' => $proxy_use));
2142 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
2143 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
2144 +
2145 + $return_handoff = self::paypal_checkout_return_handoff_create($return_post);
2146 + if(!$return_handoff)
2147 + return array('ok' => FALSE, 'error' => 'return_handoff_failed');
2148 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
2149 +
2150 + $result = array('rtn_url' => $return_url, 'rtn_post' => $return_post, 'txn_id' => $pu_cap_id);
2151 + $private_context = (array)$private_context;
2152 + $private_context['paypal_checkout'] = !empty($private_context['paypal_checkout']) && is_array($private_context['paypal_checkout']) ? $private_context['paypal_checkout'] : array();
2153 + //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.
2154 + $private_context['paypal_checkout']['fulfillment_result'] = $result;
2155 + if(!c_ws_plugin__s2member_gateway_checkouts::private_context_set($gateway_checkout_id, $private_context))
2156 + return array('ok' => FALSE, 'error' => 'gateway_checkout_private_context_failed');
2157 +
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')))
2162 + return array('ok' => FALSE, 'error' => 'gateway_checkout_save_failed');
2163 +
2164 + return array_merge(array('ok' => TRUE, 'processed' => !empty($notify_result['processed']), 'duplicate' => !empty($notify_result['duplicate'])), $result);
2165 + }
2166 +
2167 + /**
1835 2168 * Sends PayPal Checkout fulfillment through s2Member's existing PayPal Notify handler once.
1836 2169 *
1837 2170 * @since 260817
1838 2171 *
@@ -1935,13 +2268,79 @@
1935 2268 }
1936 2269 }
1937 2270
1938 2271 /**
1939 - * Creates a PayPal Checkout subscription (server-side) when using redirect-mode approval.
2272 + * Recovers a coordinator-backed PayPal subscription ID/status from a verified webhook resource.
1940 2273 *
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.
2274 + * @since 260902.0200
1943 2275 *
2276 + * @param string $invoice PayPal custom_id/invoice carrying the Gateway Checkout ID.
2277 + * @param string $subscription_id PayPal subscription ID.
2278 + * @param string $status PayPal subscription status, if known.
2279 + *
2280 + * @return array Recovery result with handled/ok/recovered/error details.
2281 + */
2282 + public static function paypal_checkout_subscription_gateway_checkout_recover($invoice = '', $subscription_id = '', $status = '')
2283 + {
2284 + $invoice = trim((string)$invoice);
2285 + $subscription_id = trim((string)$subscription_id);
2286 + $status = strtoupper(trim((string)$status));
2287 + $gateway_checkout_id = (strpos($invoice, 's2mpf-') === 0) ? substr($invoice, strlen('s2mpf-')) : '';
2288 +
2289 + if(!$subscription_id || !c_ws_plugin__s2member_gateway_checkouts::valid_id($gateway_checkout_id))
2290 + return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2291 +
2292 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2293 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2294 + return array('handled' => false, 'ok' => false, 'recovered' => false, 'error' => 'not_coordinator_checkout');
2295 +
2296 + $lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id, 60);
2297 + if(!$lock)
2298 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_busy', 'gateway_checkout_id' => $gateway_checkout_id);
2299 +
2300 + try
2301 + {
2302 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2303 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2304 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_invalid', 'gateway_checkout_id' => $gateway_checkout_id);
2305 +
2306 + $existing_subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
2307 + if($existing_subscription_id && !hash_equals($existing_subscription_id, $subscription_id))
2308 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_subscription_conflict', 'gateway_checkout_id' => $gateway_checkout_id, 'subscription_id' => $existing_subscription_id);
2309 +
2310 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2311 + $gateway_ids['subscription_id'] = $subscription_id;
2312 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2313 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2314 +
2315 + if(!$existing_subscription_id)
2316 + {
2317 + //260902.0200 Record webhook repair for future diagnostics without treating CREATED as payment/fulfillment.
2318 + $context['paypal_subscription_recovered_at'] = time();
2319 + $context['paypal_subscription_recovered_via'] = 'webhook';
2320 + }
2321 +
2322 + $gateway_status = !empty($gateway_checkout['gateway_status']) ? strtoupper((string)$gateway_checkout['gateway_status']) : '';
2323 + if($status === 'ACTIVE' || ($status === 'APPROVED' && $gateway_status === 'APPROVAL_PENDING') || !$gateway_status || $gateway_status === 'CREATE_PENDING')
2324 + $gateway_status = $status ? $status : 'APPROVAL_PENDING';
2325 +
2326 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $gateway_status, 'context' => $context)))
2327 + return array('handled' => true, 'ok' => false, 'recovered' => false, 'error' => 'gateway_checkout_save_failed', 'gateway_checkout_id' => $gateway_checkout_id);
2328 +
2329 + return array('handled' => true, 'ok' => true, 'recovered' => !$existing_subscription_id, 'error' => '', 'gateway_checkout_id' => $gateway_checkout_id, 'subscription_id' => $subscription_id, 'status' => $gateway_status);
2330 + }
2331 + finally
2332 + {
2333 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $lock);
2334 + }
2335 + }
2336 +
2337 + /**
2338 + * Creates a PayPal Checkout subscription server-side.
2339 + *
2340 + * Redirect-mode and coordinator-backed JS flows create here; legacy JS buttons may
2341 + * still create client-side using plan_id and then confirm server-side.
2342 + *
1944 2343 * @since 260114
1945 2344 *
1946 2345 * @param array $token Signed/validated purchase token.
1947 2346 *
@@ -1952,40 +2351,140 @@
1952 2351 if(!is_array($token))
1953 2352 return array();
1954 2353
1955 2354 $invoice = (string)$token['invoice'];
2355 + $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'] : '';
2356 + $gateway_checkout_lock = '';
1956 2357
1957 - $plan_id = self::paypal_checkout_plan_get_id($token);
1958 - if(!$plan_id)
1959 - return array();
2358 + if($gateway_checkout_id)
2359 + {
2360 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2361 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2362 + return array('__error' => 'gateway_checkout_invalid');
1960 2363
1961 - $brand_name = get_bloginfo('name');
1962 - $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
2364 + //260901.2145 Return a previously persisted PayPal subscription before making another create request; this also recovers a browser reload after server-side creation succeeded.
2365 + if(!empty($gateway_checkout['gateway_ids']['subscription_id']))
2366 + return array('id' => (string)$gateway_checkout['gateway_ids']['subscription_id'], 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '');
1963 2367
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 - );
2368 + $gateway_checkout_lock = c_ws_plugin__s2member_gateway_checkouts::processing_lock($gateway_checkout_id);
2369 + if(!$gateway_checkout_lock)
2370 + return array('__error' => 'gateway_checkout_busy');
1975 2371
1976 - // Idempotency: stable per invoice for create-subscription retries.
1977 - $headers = array(
1978 - 'PayPal-Request-Id' => 's2m-ppco-sub-'.md5($invoice),
1979 - );
2372 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
2373 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
2374 + {
2375 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2376 + return array('__error' => 'gateway_checkout_invalid');
2377 + }
2378 + if(!empty($gateway_checkout['gateway_ids']['subscription_id']))
2379 + {
2380 + $subscription_id = (string)$gateway_checkout['gateway_ids']['subscription_id'];
2381 + $status = !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '';
2382 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2383 + return array('id' => $subscription_id, 'status' => $status);
2384 + }
2385 + }
1980 2386
1981 - $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
2387 + try
2388 + {
2389 + $plan_id = self::paypal_checkout_plan_get_id($token);
2390 + if(!$plan_id)
2391 + return array('__error' => 'plan_create_failed');
1982 2392
1983 - $data = array();
1984 - if(!empty($r['body']) && is_string($r['body']))
1985 - $data = json_decode($r['body'], true);
2393 + $brand_name = get_bloginfo('name');
2394 + $brand_name = substr(preg_replace('/\s+/', ' ', trim(strip_tags($brand_name))), 0, 127);
1986 2395
1987 - return is_array($data) ? $data : array();
2396 + $body = array(
2397 + 'plan_id' => $plan_id,
2398 + 'custom_id' => $invoice,
2399 + 'application_context' => array(
2400 + 'brand_name' => $brand_name,
2401 + 'return_url' => (string)$token['return'],
2402 + 'cancel_url' => (string)$token['cancel'],
2403 + 'user_action' => 'SUBSCRIBE_NOW',
2404 + 'shipping_preference' => 'NO_SHIPPING',
2405 + ),
2406 + );
2407 +
2408 + //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.
2409 + $request_id = $gateway_checkout_id ? 's2m-ppco-sub-'.str_replace('-', '', $gateway_checkout_id) : 's2m-ppco-sub-'.md5($invoice);
2410 + $headers = array('PayPal-Request-Id' => $request_id);
2411 +
2412 + if($gateway_checkout_id)
2413 + {
2414 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2415 + $create_started_at = !empty($context['paypal_subscription_create_started_at']) ? (int)$context['paypal_subscription_create_started_at'] : 0;
2416 +
2417 + if($create_started_at && $create_started_at <= time() - (3 * DAY_IN_SECONDS))
2418 + {
2419 + //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.
2420 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2421 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
2422 + if(!$gateway_checkout)
2423 + return array('__error' => 'gateway_checkout_save_failed');
2424 + $create_started_at = 0;
2425 + }
2426 +
2427 + if(!$create_started_at)
2428 + {
2429 + $context['paypal_subscription_create_started_at'] = time();
2430 + $context['paypal_subscription_request_id'] = $request_id;
2431 + //260901.2145 Record an in-flight create before contacting PayPal so changed purchase terms cannot silently abandon an ambiguous subscription attempt.
2432 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => 'CREATE_PENDING', 'context' => $context));
2433 + if(!$gateway_checkout)
2434 + return array('__error' => 'gateway_checkout_save_failed');
2435 + }
2436 + }
2437 +
2438 + $data = array();
2439 + $code = 0;
2440 + $ambiguous = FALSE;
2441 + for($attempt = 0; $attempt < 2; $attempt++)
2442 + {
2443 + $r = self::paypal_checkout_api_request('POST', '/v1/billing/subscriptions', $body, $headers);
2444 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
2445 + $response_body = !empty($r['body']) ? (string)$r['body'] : '';
2446 + $data = $response_body ? json_decode($response_body, true) : array();
2447 + $data = is_array($data) ? $data : array();
2448 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
2449 +
2450 + if($code >= 200 && $code <= 299 && !empty($data['id']))
2451 + break;
2452 + if(!$ambiguous)
2453 + break;
2454 + }
2455 +
2456 + if($gateway_checkout_id && $code >= 200 && $code <= 299 && !empty($data['id']))
2457 + {
2458 + $gateway_ids = !empty($gateway_checkout['gateway_ids']) && is_array($gateway_checkout['gateway_ids']) ? $gateway_checkout['gateway_ids'] : array();
2459 + $gateway_ids['subscription_id'] = (string)$data['id'];
2460 + $status = !empty($data['status']) ? strtoupper((string)$data['status']) : 'APPROVAL_PENDING';
2461 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2462 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2463 +
2464 + //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.
2465 + if(!c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_ids' => $gateway_ids, 'gateway_status' => $status, 'context' => $context)))
2466 + return array('__error' => 'gateway_checkout_save_failed');
2467 + }
2468 + else if($gateway_checkout_id && !$ambiguous)
2469 + {
2470 + //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.
2471 + $context = !empty($gateway_checkout['context']) && is_array($gateway_checkout['context']) ? $gateway_checkout['context'] : array();
2472 + unset($context['paypal_subscription_create_started_at'], $context['paypal_subscription_request_id']);
2473 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => '', 'context' => $context));
2474 + }
2475 +
2476 + //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.
2477 + if($gateway_checkout_id && $ambiguous && !($code >= 200 && $code <= 299 && !empty($data['id'])))
2478 + return array('__error' => 'subscription_create_unresolved');
2479 +
2480 + return $data;
2481 + }
2482 + finally
2483 + {
2484 + if($gateway_checkout_id && $gateway_checkout_lock)
2485 + c_ws_plugin__s2member_gateway_checkouts::processing_unlock($gateway_checkout_id, $gateway_checkout_lock);
2486 + }
1988 2487 }
1989 2488
1990 2489 /**
1991 2490 * Returns a PayPal Checkout Plan ID for a subscription token (creates product/plan if needed).
@@ -2420,9 +2919,11 @@
2420 2919 {
2421 2920 //260820.0218 Keep automatic webhook registration aligned with the events handled by s2Member and listed in PayPal Checkout setup help.
2422 2921 return array(
2423 2922 'PAYMENT.SALE.COMPLETED',
2923 + 'PAYMENT.CAPTURE.PENDING',
2424 2924 'PAYMENT.CAPTURE.COMPLETED',
2925 + 'PAYMENT.CAPTURE.DENIED',
2425 2926 'PAYMENT.SALE.REFUNDED',
2426 2927 'PAYMENT.CAPTURE.REFUNDED',
2427 2928 'PAYMENT.SALE.REVERSED',
2428 2929 'PAYMENT.CAPTURE.REVERSED',