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 +1179 -117 260805 → 260927 View file →
@@ -73,8 +73,40 @@
73 73 return apply_filters("ws_plugin__s2member_paypal_postvars", $postvars, get_defined_vars());
74 74 }
75 75 else return false;
76 76 }
77 + //260817 Allow signed Checkout data through Return or custom handlers, but never use a browser handoff to authenticate the PayPal Notify endpoint.
78 + else if(empty($_GET["s2member_paypal_notify"]) && !empty($_GET["s2member_paypal_proxy"]) && $_GET["s2member_paypal_proxy"] === "paypal"
79 + && array_key_exists("s2member_paypal_checkout_handoff", $_POST) && is_array($postvars = stripslashes_deep($_POST)))
80 + {
81 + if(!is_string($postvars["s2member_paypal_checkout_handoff"]) || $postvars["s2member_paypal_checkout_handoff"] === '')
82 + return false;
83 +
84 + $handoff = $postvars["s2member_paypal_checkout_handoff"];
85 + unset($postvars["s2member_paypal_checkout_handoff"]);
86 +
87 + //260817 Verify the complete PayPal Checkout browser-return payload before trusting any transaction or proxy metadata.
88 + if(!self::paypal_checkout_return_handoff_verify($handoff, $postvars))
89 + return false;
90 +
91 + if(empty($postvars["s2member_paypal_proxy"]) || $postvars["s2member_paypal_proxy"] !== "paypal"
92 + || (string)$_GET["s2member_paypal_proxy"] !== (string)$postvars["s2member_paypal_proxy"])
93 + return false;
94 +
95 + //260817 If proxy-use routing is supplied in the URL, it must be scalar and match the signed browser-return metadata.
96 + if(!empty($_GET["s2member_paypal_proxy_use"]) && (!is_string($_GET["s2member_paypal_proxy_use"]) || empty($postvars["s2member_paypal_proxy_use"]) || $_GET["s2member_paypal_proxy_use"] !== (string)$postvars["s2member_paypal_proxy_use"]))
97 + return false;
98 +
99 + foreach($postvars as $key => $value)
100 + if(preg_match("/^s2member_/", $key))
101 + unset($postvars[$key]);
102 +
103 + $postvars = self::paypal_postvars_back_compat($postvars);
104 + $postvars = c_ws_plugin__s2member_utils_strings::trim_deep($postvars);
105 + $postvars = self::paypal_postvars_utf8($postvars);
106 +
107 + return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => "paypal")), get_defined_vars());
108 + }
77 109 else if(!empty($_REQUEST) && is_array($postvars = stripslashes_deep($_REQUEST)))
78 110 {
79 111 foreach($postvars as $key => $value)
80 112 if(preg_match("/^s2member_/", $key))
@@ -88,9 +120,10 @@
88 120
89 121 $postvars = self::paypal_postvars_utf8($postvars);
90 122 $endpoint = ($GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_sandbox"]) ? "www.sandbox.paypal.com" : "www.paypal.com";
91 123
92 - 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"]))
93 126 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_REQUEST["s2member_paypal_proxy"])), get_defined_vars());
94 127
95 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"))
96 129 return apply_filters("ws_plugin__s2member_paypal_postvars", array_merge($postvars, array("proxy_verified" => $_GET["s2member_paypal_proxy"])), get_defined_vars());
@@ -174,8 +207,106 @@
174 207
175 208 return $postvars; // w/ back. compat keys.
176 209 }
177 210 /**
211 + * Normalizes PayPal Checkout browser-return variables for handoff signing.
212 + *
213 + * @package s2Member\PayPal
214 + * @since 260817
215 + *
216 + * @param array $postvars Browser-return variables.
217 + *
218 + * @return string|bool Canonical payload string, else false.
219 + */
220 + public static function paypal_checkout_return_handoff_payload($postvars)
221 + {
222 + if(!is_array($postvars) || !$postvars)
223 + return false;
224 +
225 + $normalized = array();
226 + foreach($postvars as $key => $value)
227 + {
228 + $key = (string)$key;
229 +
230 + if($key === 's2member_paypal_checkout_handoff')
231 + continue;
232 + if(!is_scalar($value) && $value !== null)
233 + return false;
234 +
235 + $key = preg_replace('/\r\n|\r|\n/', "\r\n", $key);
236 + $value = preg_replace('/\r\n|\r|\n/', "\r\n", (string)$value);
237 + $normalized[$key] = $value;
238 + }
239 + if(!$normalized)
240 + return false;
241 +
242 + ksort($normalized, SORT_STRING);
243 + return http_build_query($normalized, '', '&', PHP_QUERY_RFC3986);
244 + }
245 + /**
246 + * Generates the private signing key for PayPal Checkout browser-return handoffs.
247 + *
248 + * @package s2Member\PayPal
249 + * @since 260817
250 + *
251 + * @return string Private signing key.
252 + */
253 + public static function paypal_checkout_return_handoff_key()
254 + {
255 + return hash_hmac('sha256', 's2member_paypal_checkout_return_handoff|'.self::paypal_proxy_key_gen(), c_ws_plugin__s2member_utils_encryption::key());
256 + }
257 + /**
258 + * Creates a short-lived PayPal Checkout browser-return handoff.
259 + *
260 + * @package s2Member\PayPal
261 + * @since 260817
262 + *
263 + * @param array $postvars Verified browser-return variables.
264 + *
265 + * @return string Signed handoff token, else an empty string on failure.
266 + */
267 + public static function paypal_checkout_return_handoff_create($postvars)
268 + {
269 + $payload = self::paypal_checkout_return_handoff_payload($postvars);
270 +
271 + if($payload === false)
272 + return '';
273 +
274 + $expires = time() + HOUR_IN_SECONDS;
275 + $signature = hash_hmac('sha256', $expires.'|'.$payload, self::paypal_checkout_return_handoff_key());
276 +
277 + // The browser gets only a transaction-scoped signature; reusable server-side secrets remain private.
278 + return $expires.'.'.$signature;
279 + }
280 + /**
281 + * Verifies a PayPal Checkout browser-return handoff.
282 + *
283 + * @package s2Member\PayPal
284 + * @since 260817
285 + *
286 + * @param string $handoff Signed handoff token.
287 + * @param array $postvars Browser-return variables received by POST.
288 + *
289 + * @return bool TRUE if valid; else FALSE.
290 + */
291 + public static function paypal_checkout_return_handoff_verify($handoff, $postvars)
292 + {
293 + $handoff = trim((string)$handoff);
294 +
295 + if(!preg_match('/^([0-9]{10,12})\.([a-f0-9]{64})$/D', $handoff, $matches))
296 + return false;
297 +
298 + $expires = (int)$matches[1];
299 + $signature = (string)$matches[2];
300 + $payload = self::paypal_checkout_return_handoff_payload($postvars);
301 +
302 + if($payload === false || time() > $expires)
303 + return false;
304 +
305 + $expected = hash_hmac('sha256', $expires.'|'.$payload, self::paypal_checkout_return_handoff_key());
306 + return hash_equals($expected, $signature);
307 + }
308 + /**
178 309 * Generates a PayPal Proxy Key, for simulated IPN responses.
179 310 *
180 311 * @package s2Member\PayPal
181 312 * @since 3.5
@@ -193,10 +324,15 @@
193 324 if(is_multisite() && !is_main_site())
194 325 $key = md5(c_ws_plugin__s2member_utils_encryption::xencrypt(strtolower($current_blog->domain.$current_blog->path), false, false));
195 326
196 327 else {
197 - $host = ($GLOBALS['WS_PLUGIN__']['s2member']['o']['skip_ipn_domain_validation']) ? parse_url(home_url('/'), PHP_URL_HOST) : $_SERVER["HTTP_HOST"]; //250917
198 - $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));
199 335 }
200 336
201 337 return apply_filters("ws_plugin__s2member_paypal_proxy_key_gen", $key, get_defined_vars());
202 338 }
@@ -1250,8 +1386,155 @@
1250 1386 return $r;
1251 1387 }
1252 1388
1253 1389 /**
1390 + * Retrieves a PayPal Checkout order for validation or capture recovery.
1391 + *
1392 + * @since 260817
1393 + *
1394 + * @param string $order_id PayPal Checkout order id.
1395 + *
1396 + * @return array Decoded order response, with __code/__body added; __error on failure.
1397 + */
1398 + public static function paypal_checkout_order_details($order_id = '')
1399 + {
1400 + $order_id = trim((string)$order_id);
1401 +
1402 + if(!$order_id)
1403 + return array('__error' => 'missing_order_id', '__code' => 0, '__body' => '');
1404 +
1405 + $r = self::paypal_checkout_api_request('GET', '/v2/checkout/orders/'.rawurlencode($order_id));
1406 +
1407 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
1408 + $body = !empty($r['body']) ? (string)$r['body'] : '';
1409 + $data = ($body) ? json_decode($body, true) : array();
1410 + $data = is_array($data) ? $data : array();
1411 +
1412 + $data['__code'] = $code;
1413 + $data['__body'] = $body;
1414 +
1415 + if(!($code >= 200 && $code <= 299) || empty($data['id']))
1416 + $data['__error'] = 'order_details_failed';
1417 +
1418 + return $data;
1419 + }
1420 + /**
1421 + * Validates a PayPal Checkout order against the server-side purchase token.
1422 + *
1423 + * @since 260817
1424 + *
1425 + * @param array $order PayPal order representation.
1426 + * @param string $order_id Expected PayPal order id.
1427 + * @param array $token Signed/validated purchase token.
1428 + *
1429 + * @return string Empty string if valid; otherwise a stable error code.
1430 + */
1431 + public static function paypal_checkout_order_validation_error($order = array(), $order_id = '', $token = array())
1432 + {
1433 + if(!is_array($order) || empty($order['id']))
1434 + return 'order_missing';
1435 + if($order_id && (string)$order['id'] !== (string)$order_id)
1436 + return 'order_id_mismatch';
1437 + if(empty($order['intent']) || strtoupper((string)$order['intent']) !== 'CAPTURE')
1438 + return 'order_intent_mismatch';
1439 + if(empty($order['purchase_units'][0]) || !is_array($order['purchase_units'][0]))
1440 + return 'order_purchase_unit_missing';
1441 +
1442 + $pu = $order['purchase_units'][0];
1443 + $invoice = isset($pu['invoice_id']) ? (string)$pu['invoice_id'] : '';
1444 + $amount = isset($pu['amount']['value']) ? (string)$pu['amount']['value'] : '';
1445 + $cc = isset($pu['amount']['currency_code']) ? strtoupper((string)$pu['amount']['currency_code']) : '';
1446 +
1447 + if(!empty($token['invoice']) && $invoice !== (string)$token['invoice'])
1448 + return 'order_invoice_mismatch';
1449 + if(!empty($token['amount']) && (!$amount || number_format((float)$amount, 2, '.', '') !== number_format((float)$token['amount'], 2, '.', '')))
1450 + return 'order_amount_mismatch';
1451 + if(!empty($token['cc']) && $cc !== strtoupper((string)$token['cc']))
1452 + return 'order_currency_mismatch';
1453 +
1454 + $custom = !empty($token['custom']) ? (string)$token['custom'] : '';
1455 + if($custom && strlen($custom) <= 127 && (!isset($pu['custom_id']) || (string)$pu['custom_id'] !== $custom))
1456 + return 'order_custom_mismatch';
1457 +
1458 + return '';
1459 + }
1460 + /**
1461 + * Validates that a PayPal Checkout order contains a completed capture for the purchase token.
1462 + *
1463 + * @since 260817
1464 + *
1465 + * @param array $order PayPal order representation.
1466 + * @param string $order_id Expected PayPal order id.
1467 + * @param array $token Signed/validated purchase token.
1468 + *
1469 + * @return string Empty string if complete and valid; otherwise a stable error code.
1470 + */
1471 + public static function paypal_checkout_order_completion_error($order = array(), $order_id = '', $token = array())
1472 + {
1473 + if(($error = self::paypal_checkout_order_validation_error($order, $order_id, $token)))
1474 + return $error;
1475 + if(empty($order['status']) || strtoupper((string)$order['status']) !== 'COMPLETED')
1476 + return 'order_not_completed';
1477 +
1478 + $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();
1479 + if(empty($capture['id']) || empty($capture['status']) || strtoupper((string)$capture['status']) !== 'COMPLETED')
1480 + return 'capture_missing_fields';
1481 +
1482 + $amount = !empty($capture['amount']['value']) ? (string)$capture['amount']['value'] : '';
1483 + $cc = !empty($capture['amount']['currency_code']) ? strtoupper((string)$capture['amount']['currency_code']) : '';
1484 +
1485 + if(!empty($token['amount']) && (!$amount || number_format((float)$amount, 2, '.', '') !== number_format((float)$token['amount'], 2, '.', '')))
1486 + return 'capture_amount_mismatch';
1487 + if(!empty($token['cc']) && $cc !== strtoupper((string)$token['cc']))
1488 + return 'capture_currency_mismatch';
1489 + if(empty($order['payer']['email_address']))
1490 + return 'capture_missing_fields';
1491 +
1492 + return '';
1493 + }
1494 +
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 + /**
1254 1537 * Creates a PayPal Checkout order for one-time (Buy Now) purchases.
1255 1538 *
1256 1539 * This must be server-side to prevent client-side manipulation of amount, item_number,
1257 1540 * custom fields, etc. The resulting order id is returned to the JS SDK or used for
@@ -1264,81 +1547,192 @@
1264 1547 * @return array API request result array from paypal_checkout_api_request().
1265 1548 */
1266 1549 public static function paypal_checkout_order_create($token = array())
1267 1550 {
1551 + if(!is_array($token))
1552 + return array('__error' => 'invalid_token');
1553 +
1268 1554 // token: invoice, custom, item_name, item_number, amount, cc, ns, return, cancel.
1269 - $invoice = (string)$token['invoice'];
1270 - $custom = (string)$token['custom'];
1271 - $amount = (string)$token['amount'];
1272 - $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 = '';
1273 1561
1274 - $item_name = trim((string)$token['item_name']);
1275 - if(!$item_name)
1276 - $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');
1277 1567
1278 - // PayPal limits various fields; keep item name within common limits.
1279 - if(strlen($item_name) > 127)
1280 - $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'] : '');
1281 1571
1282 - $item_sku = trim((string)$token['item_number']);
1283 - if(strlen($item_sku) > 127)
1284 - $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');
1285 1576
1286 - $purchase_unit = array(
1287 - 'invoice_id' => $invoice,
1288 - 'amount' => array(
1289 - 'currency_code' => $cc,
1290 - 'value' => $amount,
1291 - 'breakdown' => array(
1292 - 'item_total' => array(
1293 - 'currency_code' => $cc,
1294 - 'value' => $amount,
1295 - ),
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
1593 + {
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);
1599 +
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)),
1296 1620 ),
1297 - ),
1298 - 'description' => $item_name,
1299 - 'items' => array(
1300 - array(
1301 - 'name' => $item_name,
1302 - 'quantity' => '1',
1303 - 'unit_amount' => array(
1304 - 'currency_code' => $cc,
1305 - 'value' => $amount,
1306 - ),
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'] : '',
1307 1642 ),
1308 - ),
1309 - );
1643 + );
1310 1644
1311 - if($item_sku)
1312 - $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);
1313 1648
1314 - // PayPal limits custom_id length; keep it short/consistent.
1315 - if($custom && strlen($custom) <= 127)
1316 - $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');
1317 1660
1318 - $body = array(
1319 - 'intent' => 'CAPTURE',
1320 - 'purchase_units' => array($purchase_unit),
1321 - 'application_context' => array(
1322 - 'user_action' => 'PAY_NOW',
1323 - 'shipping_preference' => (!empty($token['ns']) && (string)$token['ns'] === '1') ? 'NO_SHIPPING' : 'GET_FROM_FILE',
1324 - 'return_url' => (string)$token['return'],
1325 - 'cancel_url' => (string)$token['cancel'],
1326 - ),
1327 - );
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 + }
1328 1681
1329 - // Idempotency: stable per invoice for create-order retries.
1330 - $headers = array(
1331 - 'PayPal-Request-Id' => 's2m-ppco-order-'.md5($invoice),
1332 - );
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++)
1687 + {
1688 + $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1689 + $code = !empty($r['code']) ? (int)$r['code'] : 0;
1690 + $response_body = !empty($r['body']) ? (string)$r['body'] : '';
1691 + $data = $response_body ? json_decode($response_body, true) : array();
1692 + $data = is_array($data) ? $data : array();
1693 + $ambiguous = ($code === 0 || $code === 408 || $code >= 500 || ($code >= 200 && $code <= 299));
1333 1694
1334 - $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders', $body, $headers);
1695 + if($code >= 200 && $code <= 299 && !empty($data['id']))
1696 + break;
1697 + if(!$ambiguous)
1698 + break;
1699 + }
1335 1700
1336 - $data = array();
1337 - if(!empty($r['body']) && is_string($r['body']))
1338 - $data = json_decode($r['body'], true);
1701 + if($code >= 200 && $code <= 299 && !empty($data['id']))
1702 + {
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);
1339 1704
1340 - return is_array($data) ? $data : array();
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 + }
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 + }
1724 +
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 + }
1341 1735 }
1342 1736
1343 1737 /**
1344 1738 * Retrieves PayPal Checkout subscription details via the Subscriptions REST API.
@@ -1451,30 +1845,502 @@
1451 1845 public static function paypal_checkout_order_capture($order_id = '', $token = array())
1452 1846 {
1453 1847 $order_id = trim((string)$order_id);
1454 1848 if(!$order_id)
1455 - return array();
1849 + return array('__error' => 'missing_order_id');
1456 1850
1457 - // Idempotency: stable per order capture retries.
1458 - $headers = array(
1459 - 'PayPal-Request-Id' => 's2m-ppco-cap-'.md5($order_id),
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'] : '';
1853 + $binding_name = $invoice ? 's2m_ppco_order_bind_'.md5($invoice) : '';
1854 + $binding = $binding_name ? get_transient($binding_name) : false;
1855 + $gateway_checkout_lock = '';
1856 +
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');
1864 +
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 + }
1879 +
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))
1882 + return array('__error' => 'capture_in_progress');
1883 +
1884 + try
1885 + {
1886 + if($gateway_checkout_id)
1887 + {
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');
1891 +
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 + }
1897 +
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);
1907 +
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 + }
1920 +
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 + }
1928 +
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 + }
1939 +
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 + }
1958 +
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))
1967 + {
1968 + if($binding_name) delete_transient($binding_name);
1969 + return $data;
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 + }
1977 +
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))
1988 + {
1989 + if($binding_name) delete_transient($binding_name);
1990 + return $details;
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 + }
1998 +
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 + }
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')
2068 + {
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';
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 + }
2084 + }
2085 +
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'] : '',
1460 2124 );
2125 + if(isset($token['tax']))
2126 + $paypal['tax'] = (string)$token['tax'];
1461 2127
1462 - $r = self::paypal_checkout_api_request('POST', '/v2/checkout/orders/'.$order_id.'/capture', (object)array(), $headers);
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'];
1463 2134
1464 - $data = array();
1465 - if(!empty($r['body']) && is_string($r['body']))
1466 - $data = json_decode($r['body'], true);
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');
1467 2139
1468 - return is_array($data) ? $data : array();
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);
1469 2165 }
1470 2166
1471 2167 /**
1472 - * Creates a PayPal Checkout subscription (server-side) when using redirect-mode approval.
2168 + * Sends PayPal Checkout fulfillment through s2Member's existing PayPal Notify handler once.
1473 2169 *
1474 - * In JS SDK button mode, subscriptions are created client-side using plan_id and
1475 - * then confirmed server-side. Redirect-mode requires server-side creation.
2170 + * @since 260817
1476 2171 *
2172 + * @param array $paypal PayPal-style transaction variables.
2173 + * @param string $done_option Local fulfillment done-marker option name.
2174 + * @param string $proxy_use Optional proxy-use routing value.
2175 + * @param array $extra Optional additional server-side Notify variables.
2176 + *
2177 + * @return array Result with ok/processed/duplicate/error and response details.
2178 + */
2179 + public static function paypal_checkout_notify_once($paypal = array(), $done_option = '', $proxy_use = 'paypal_checkout', $extra = array())
2180 + {
2181 + if(!is_array($paypal) || !$paypal || !$done_option || !is_string($done_option))
2182 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_invalid_args');
2183 +
2184 + //260818.0603 This helper now coordinates one-time and subscription fulfillment markers.
2185 + self::dedupe_markers_cleanup('s2m_ppco_notify_cleanup_throttle', array(
2186 + array('prefix' => 's2m_ppco_capture_done_', 'ttl' => DAY_IN_SECONDS),
2187 + array('prefix' => 's2m_ppco_subscr_done_', 'ttl' => DAY_IN_SECONDS),
2188 + array('prefix' => 's2m_ppco_notify_lock_', 'ttl' => HOUR_IN_SECONDS),
2189 + array('prefix' => 's2m_ppco_capture_lock_', 'ttl' => HOUR_IN_SECONDS),
2190 + ));
2191 +
2192 + $result_transient = 's2m_ppco_notify_result_'.md5($done_option);
2193 + if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
2194 + {
2195 + $cached_result = get_transient($result_transient);
2196 + return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
2197 + }
2198 +
2199 + $lock_option = 's2m_ppco_notify_lock_'.md5($done_option);
2200 + if(!self::dedupe_lock_acquire($lock_option, 900))
2201 + {
2202 + if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
2203 + {
2204 + $cached_result = get_transient($result_transient);
2205 + return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
2206 + }
2207 +
2208 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_in_progress');
2209 + }
2210 +
2211 + try
2212 + {
2213 + if(self::dedupe_done_time_get($done_option, DAY_IN_SECONDS))
2214 + {
2215 + $cached_result = get_transient($result_transient);
2216 + return array_merge(array('ok' => true, 'processed' => false, 'duplicate' => true, 'error' => ''), is_array($cached_result) ? $cached_result : array());
2217 + }
2218 +
2219 + //260818.0617 Allow Pro to prepare account-specific fulfillment inside the shared Notify lock and enrich fallback context.
2220 + $notify_context = apply_filters('ws_plugin__s2member_paypal_checkout_notify_context', array(
2221 + 'paypal' => $paypal,
2222 + 'proxy_use' => (string)$proxy_use,
2223 + 'extra' => is_array($extra) ? $extra : array(),
2224 + ), $done_option);
2225 +
2226 + if(is_wp_error($notify_context))
2227 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_context_failed', 'context_error' => (string)$notify_context->get_error_code());
2228 +
2229 + if(!is_array($notify_context) || empty($notify_context['paypal']) || !is_array($notify_context['paypal']))
2230 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_context_invalid');
2231 +
2232 + $paypal = $notify_context['paypal'];
2233 + $proxy_use = isset($notify_context['proxy_use']) ? (string)$notify_context['proxy_use'] : (string)$proxy_use;
2234 + $extra = !empty($notify_context['extra']) && is_array($notify_context['extra']) ? $notify_context['extra'] : array();
2235 +
2236 + $notify_url = home_url('/?s2member_paypal_notify=1');
2237 + $notify_post = array_merge($paypal, $extra, array(
2238 + 's2member_paypal_proxy' => 'paypal',
2239 + 's2member_paypal_proxy_use' => $proxy_use,
2240 + 's2member_paypal_proxy_verification' => self::paypal_proxy_key_gen(),
2241 + ));
2242 + $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
2243 +
2244 + if(!is_array($notify_r))
2245 + $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
2246 +
2247 + $code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
2248 + $message = !empty($notify_r['message']) ? (string)$notify_r['message'] : '';
2249 + $body = !empty($notify_r['body']) ? (string)$notify_r['body'] : '';
2250 +
2251 + if($code >= 200 && $code <= 299)
2252 + {
2253 + $result = array('code' => $code, 'message' => $message, 'body' => $body);
2254 + set_transient($result_transient, $result, DAY_IN_SECONDS); // Preserve the Notify result for safe duplicate/retry returns, including future Pro success URLs.
2255 + self::dedupe_done_mark($done_option);
2256 +
2257 + //260818.1752 Run account-specific post-Notify work only after fulfillment is durably marked complete.
2258 + do_action('ws_plugin__s2member_paypal_checkout_notify_processed', $notify_context, $done_option, $result);
2259 +
2260 + return array_merge(array('ok' => true, 'processed' => true, 'duplicate' => false, 'error' => ''), $result);
2261 + }
2262 +
2263 + return array('ok' => false, 'processed' => false, 'duplicate' => false, 'error' => 'notify_proxy_failed', 'code' => $code, 'message' => $message, 'body' => $body);
2264 + }
2265 + finally
2266 + {
2267 + self::dedupe_lock_release($lock_option);
2268 + }
2269 + }
2270 +
2271 + /**
2272 + * Recovers a coordinator-backed PayPal subscription ID/status from a verified webhook resource.
2273 + *
2274 + * @since 260902.0200
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 + *
1477 2343 * @since 260114
1478 2344 *
1479 2345 * @param array $token Signed/validated purchase token.
1480 2346 *
@@ -1485,40 +2351,140 @@
1485 2351 if(!is_array($token))
1486 2352 return array();
1487 2353
1488 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 = '';
1489 2357
1490 - $plan_id = self::paypal_checkout_plan_get_id($token);
1491 - if(!$plan_id)
1492 - 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');
1493 2363
1494 - $brand_name = get_bloginfo('name');
1495 - $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'] : '');
1496 2367
1497 - $body = array(
1498 - 'plan_id' => $plan_id,
1499 - 'custom_id' => $invoice,
1500 - 'application_context' => array(
1501 - 'brand_name' => $brand_name,
1502 - 'return_url' => (string)$token['return'],
1503 - 'cancel_url' => (string)$token['cancel'],
1504 - 'user_action' => 'SUBSCRIBE_NOW',
1505 - 'shipping_preference' => 'NO_SHIPPING',
1506 - ),
1507 - );
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');
1508 2371
1509 - // Idempotency: stable per invoice for create-subscription retries.
1510 - $headers = array(
1511 - 'PayPal-Request-Id' => 's2m-ppco-sub-'.md5($invoice),
1512 - );
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 + }
1513 2386
1514 - $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');
1515 2392
1516 - $data = array();
1517 - if(!empty($r['body']) && is_string($r['body']))
1518 - $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);
1519 2395
1520 - 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 + }
1521 2487 }
1522 2488
1523 2489 /**
1524 2490 * Returns a PayPal Checkout Plan ID for a subscription token (creates product/plan if needed).
@@ -1542,17 +2508,16 @@
1542 2508 $ra = isset($token['amount']) ? (string)$token['amount'] : '';
1543 2509 $rp = !empty($token['rp']) ? (int)$token['rp'] : 0;
1544 2510 $rt = !empty($token['rt']) ? strtoupper(trim((string)$token['rt'])) : '';
1545 2511
2512 + $is_pro_form = !empty($token['s2member_paypal_proxy_use']) && strpos((string)$token['s2member_paypal_proxy_use'], 'pro-emails') !== false;
1546 2513 $rrt = !empty($token['rrt']) ? (int)$token['rrt'] : 0;
1547 - $rra = isset($token['rra']) ? (int)$token['rra'] : 1;
2514 + $rra = isset($token['rra']) ? (int)$token['rra'] : ($is_pro_form ? 2 : 1);
1548 2515
1549 - // rrt/rra are only meaningful when rr="1" (recurring).
2516 + //260827.1950 Pro-Forms define rra as the exact Max Failed Payments value for any recurring profile;
2517 + // Framework buttons retain their legacy PayPal Standard retry semantics. rrt remains rr="1" only.
1550 2518 if($rr !== '1')
1551 - {
1552 2519 $rrt = 0;
1553 - $rra = 0;
1554 - }
1555 2520
1556 2521 $ta = isset($token['ta']) ? (string)$token['ta'] : '';
1557 2522 $tp = !empty($token['tp']) ? (int)$token['tp'] : 0;
1558 2523 $tt = !empty($token['tt']) ? strtoupper(trim((string)$token['tt'])) : '';
@@ -1574,8 +2539,10 @@
1574 2539 'rt' => (string)$rt,
1575 2540
1576 2541 'rrt' => (int)$rrt,
1577 2542 'rra' => (int)$rra,
2543 + //260827.2129 !!! TO-DO: Standardize Pro-Form and Framework rrt/rra semantics in a future gateway abstraction; keep Plan caches separate until both contracts match.
2544 + 'pro_form' => (int)$is_pro_form,
1578 2545
1579 2546 'ta' => (string)$ta,
1580 2547 'tp' => (int)$tp,
1581 2548 'tt' => (string)$tt,
@@ -1607,15 +2574,21 @@
1607 2574 $ta_v = number_format((float)$ta, 2, '.', '');
1608 2575
1609 2576 $regular_total_cycles = 0; // 0 = infinite.
1610 2577
1611 - // rrt = number of payments (limited recurring). Only applies to rr="1".
2578 + //260827.2129 Legacy Pro-Forms without an initial term charge once at checkout and define rrt as additional payments.
2579 + // PPCO regular cycles include the checkout payment, while Framework buttons retain total-installment rrt semantics.
1612 2580 if($rr === '1' && $rrt > 0)
1613 - $regular_total_cycles = min(999, max(1, (int)$rrt));
2581 + {
2582 + $regular_total_cycles = (int)$rrt + (($is_pro_form && $tp === 0) ? 1 : 0);
2583 + if($regular_total_cycles > 999) // PayPal cannot represent the legacy Pro-Form result; fail instead of silently reducing the number of charges.
2584 + return '';
2585 + }
1614 2586 else if($rr === '0')
1615 2587 $regular_total_cycles = 1;
1616 2588
1617 - $payment_failure_threshold = ($rr === '1' && $rra) ? 2 : 1;
2589 + //260827.1950 Preserve the Pro-Form's documented exact rra value; Framework buttons keep legacy Standard boolean retry behavior.
2590 + $payment_failure_threshold = $is_pro_form ? max(0, (int)$rra) : (($rr === '1' && $rra) ? 2 : 1);
1618 2591
1619 2592 $billing_cycles = array();
1620 2593 $seq = 1;
1621 2594
@@ -1934,10 +2907,10 @@
1934 2907 /**
1935 2908 * Returns the PayPal Checkout webhook event names processed by s2Member.
1936 2909 *
1937 2910 * These events are used for:
1938 - * - Recurring payment bookkeeping (completed payments).
1939 - * - Subscription lifecycle changes (cancel/suspend/expire/payment failed).
2911 + * - Subscription activation fallback and lifecycle changes.
2912 + * - Recurring payment bookkeeping, refunds, and reversals.
1940 2913 *
1941 2914 * @since 260115
1942 2915 *
1943 2916 * @return array<string> Event type names.
@@ -1943,16 +2916,30 @@
1943 2916 * @return array<string> Event type names.
1944 2917 */
1945 2918 public static function paypal_checkout_webhook_event_names()
1946 2919 {
2920 + //260820.0218 Keep automatic webhook registration aligned with the events handled by s2Member and listed in PayPal Checkout setup help.
1947 2921 return array(
2922 + 'PAYMENT.SALE.COMPLETED',
2923 + 'PAYMENT.CAPTURE.PENDING',
2924 + 'PAYMENT.CAPTURE.COMPLETED',
2925 + 'PAYMENT.CAPTURE.DENIED',
2926 + 'PAYMENT.SALE.REFUNDED',
2927 + 'PAYMENT.CAPTURE.REFUNDED',
2928 + 'PAYMENT.SALE.REVERSED',
2929 + 'PAYMENT.CAPTURE.REVERSED',
2930 +
2931 + //260824.1727 Treat a newly opened PayPal dispute as a chargeback/reversal through s2Member's existing EOT policy.
2932 + 'CUSTOMER.DISPUTE.CREATED',
2933 +
2934 + 'BILLING.SUBSCRIPTION.CREATED',
2935 + 'BILLING.SUBSCRIPTION.ACTIVATED',
2936 + 'BILLING.SUBSCRIPTION.RE-ACTIVATED',
2937 + 'BILLING.SUBSCRIPTION.UPDATED',
1948 2938 'BILLING.SUBSCRIPTION.CANCELLED',
1949 2939 'BILLING.SUBSCRIPTION.SUSPENDED',
1950 2940 'BILLING.SUBSCRIPTION.EXPIRED',
1951 2941 'BILLING.SUBSCRIPTION.PAYMENT.FAILED',
1952 -
1953 - 'PAYMENT.SALE.COMPLETED',
1954 - 'PAYMENT.CAPTURE.COMPLETED',
1955 2942 );
1956 2943 }
1957 2944
1958 2945 /**
@@ -1962,17 +2949,18 @@
1962 2949 * Persists the webhook id into ws_plugin__s2member_options for the selected environment.
1963 2950 *
1964 2951 * @since 260115
1965 2952 *
1966 - * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
2953 + * @param string $env 'live' or 'sandbox'. Defaults to 'live'.
2954 + * @param bool $existing_only If true, update only a webhook whose ID is already stored; never create/adopt one.
1967 2955 *
1968 2956 * @return array Result array on success with keys:
1969 2957 * - id (string) webhook id
1970 - * - op (string) 'created'|'updated'
2958 + * - op (string) 'created'|'updated'|'adopted'
1971 2959 * - env (string) 'live'|'sandbox'
1972 2960 * Empty array on failure.
1973 2961 */
1974 - public static function paypal_checkout_webhook_upsert($env = '')
2962 + public static function paypal_checkout_webhook_upsert($env = '', $existing_only = false)
1975 2963 {
1976 2964 $env = ($env === 'sandbox') ? 'sandbox' : 'live';
1977 2965
1978 2966 $orig_sandbox = self::paypal_checkout_is_sandbox();
@@ -2046,8 +3034,15 @@
2046 3034 'body' => !empty($r['body']) ? (string)$r['body'] : '',
2047 3035 ));
2048 3036 }
2049 3037
3038 + //260820.0313 Upgrade reconciliation must never create or adopt a webhook the site owner did not already store.
3039 + if($existing_only)
3040 + {
3041 + $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_checkout_sandbox'] = $orig_sandbox ? '1' : '0';
3042 + return array();
3043 + }
3044 +
2050 3045 $body = array(
2051 3046 'url' => $url,
2052 3047 'event_types' => $event_types,
2053 3048 );
@@ -2086,8 +3081,39 @@
2086 3081 }
2087 3082 }
2088 3083 }
2089 3084
3085 + //260820.0313 A same-app webhook found by this exact s2Member URL is safe to adopt, but first reconcile its required events.
3086 + if($id && $adopted_existing)
3087 + {
3088 + $patch = array(
3089 + array('op' => 'replace', 'path' => '/url', 'value' => $url),
3090 + array('op' => 'replace', 'path' => '/event_types', 'value' => $event_types),
3091 + );
3092 + $ur = self::paypal_checkout_api_request('PATCH', '/v1/notifications/webhooks/'.rawurlencode($id), $patch);
3093 + $adopt_update_ok = (!empty($ur['code']) && (int)$ur['code'] === 200);
3094 +
3095 + if(!$adopt_update_ok && !empty($ur['body']) && is_string($ur['body']))
3096 + {
3097 + $ud = json_decode($ur['body'], true);
3098 + $adopt_update_ok = !empty($ud['name']) && $ud['name'] === 'WEBHOOK_PATCH_REQUEST_NO_CHANGE';
3099 + }
3100 + if(!$adopt_update_ok)
3101 + {
3102 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
3103 + 'ppco' => 'webhook',
3104 + 'event' => 'update_adopted_webhook_failed',
3105 + 'env_setting' => $env,
3106 + 'id' => $id,
3107 + 'url' => $url,
3108 + 'code' => !empty($ur['code']) ? (int)$ur['code'] : 0,
3109 + 'message' => !empty($ur['message']) ? (string)$ur['message'] : '',
3110 + 'body' => !empty($ur['body']) ? (string)$ur['body'] : '',
3111 + ));
3112 + $id = '';
3113 + }
3114 + }
3115 +
2090 3116 if($id)
2091 3117 {
2092 3118 self::paypal_checkout_webhook_store_id($id);
2093 3119
@@ -2118,8 +3144,38 @@
2118 3144 return array();
2119 3145 }
2120 3146
2121 3147 /**
3148 + * Clears a resolved PayPal Checkout webhook upgrade notice.
3149 + *
3150 + * @since 260824.0507
3151 + *
3152 + * @param string $env 'live' or 'sandbox'.
3153 + *
3154 + * @return void
3155 + */
3156 + protected static function paypal_checkout_webhook_upgrade_notice_clear($env = '')
3157 + {
3158 + $env = ($env === 'sandbox') ? 'sandbox' : 'live';
3159 + $env_label = ($env === 'sandbox') ? 'Sandbox' : 'Live';
3160 + $marker = 's2member-ppco-webhook-upgrade-notice-'.$env;
3161 + $legacy_message = 'Your '.$env_label.' webhook could not be updated automatically with the latest required events.';
3162 +
3163 + $notices = (array)get_option('ws_plugin__s2member_notices');
3164 + $changed = FALSE;
3165 +
3166 + foreach($notices as $notice_key => $notice)
3167 + if(is_array($notice) && !empty($notice['notice']) && (strpos((string)$notice['notice'], $marker) !== FALSE || strpos((string)$notice['notice'], $legacy_message) !== FALSE))
3168 + {
3169 + unset($notices[$notice_key]);
3170 + $changed = TRUE;
3171 + }
3172 +
3173 + if($changed)
3174 + update_option('ws_plugin__s2member_notices', array_values($notices));
3175 + }
3176 +
3177 + /**
2122 3178 * Stores a PayPal Checkout webhook id into ws_plugin__s2member_options for the current env.
2123 3179 *
2124 3180 * @since 260115
2125 3181 *
@@ -2128,13 +3184,16 @@
2128 3184 * @return void
2129 3185 */
2130 3186 protected static function paypal_checkout_webhook_store_id($webhook_id)
2131 3187 {
3188 + //260820.0427 Preserve the selected environment before option normalization resets the global Checkout environment.
3189 + $is_sandbox = self::paypal_checkout_is_sandbox();
3190 +
2132 3191 $options = get_option('ws_plugin__s2member_options');
2133 3192 if(!is_array($options))
2134 3193 $options = array();
2135 3194
2136 - if(self::paypal_checkout_is_sandbox())
3195 + if($is_sandbox)
2137 3196 $options['paypal_checkout_sandbox_webhook_id'] = (string)$webhook_id;
2138 3197 else
2139 3198 $options['paypal_checkout_webhook_id'] = (string)$webhook_id;
2140 3199
@@ -2141,11 +3200,14 @@
2141 3200 $options = ws_plugin__s2member_configure_options_and_their_defaults($options);
2142 3201
2143 3202 update_option('ws_plugin__s2member_options', $options).((is_multisite() && is_main_site()) ? update_site_option('ws_plugin__s2member_options', $options) : NULL);
2144 3203
2145 - if(self::paypal_checkout_is_sandbox())
3204 + if($is_sandbox)
2146 3205 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_sandbox_webhook_id"] = (string)$webhook_id;
2147 3206 else
2148 3207 $GLOBALS["WS_PLUGIN__"]["s2member"]["o"]["paypal_checkout_webhook_id"] = (string)$webhook_id;
3208 +
3209 + //260824.0507 A successful create/update or no-change verification resolves any queued upgrade warning for this environment.
3210 + self::paypal_checkout_webhook_upgrade_notice_clear($is_sandbox ? 'sandbox' : 'live');
2149 3211 }
2150 3212 }
2151 3213 }