← All changes
|
src/includes/classes/paypal-checkout-in.inc.php
+311
-21
260829
→
261001
View file →
| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | * s2Member's PayPal Checkout (REST) handler. |
| 5 | 5 | * |
| 6 | 6 | * Server-side entrypoint for PayPal Checkout operations used by s2Member shortcodes: |
| 7 | 7 | * - Buy Now: create_order + capture_order (one-time payments). |
| 8 | - * - Subscriptions (membership level): get_plan_id + confirm_subscription. | |
| 8 | + * - Subscriptions (membership level): create_subscription/get_plan_id + confirm_subscription. | |
| 9 | 9 | * - output="url|anchor": redirect/return flow (does not create orders on page load). |
| 10 | 10 | * - Optional: cancel_subscription (on-site cancel for logged-in users). |
| 11 | 11 | * |
| 12 | 12 | * Successful operations are proxied into s2Member's existing PayPal notify/return handlers, |
| @@ -76,8 +76,11 @@ | ||
| 76 | 76 | { |
| 77 | 77 | echo wp_json_encode(array('error' => 'invalid_token')); |
| 78 | 78 | exit(); |
| 79 | 79 | } |
| 80 | + //260928.1645 Never write a reusable signed Gateway Checkout browser token to PayPal debug logs; the encrypted shortcode token remains available to the handler itself. | |
| 81 | + $log_token = $token; | |
| 82 | + unset($log_token['gateway_checkout_token']); | |
| 80 | 83 | if(!empty($token['exp']) && is_numeric($token['exp']) && time() > (int)$token['exp']) |
| 81 | 84 | { |
| 82 | 85 | echo wp_json_encode(array('error' => 'token_expired')); |
| 83 | 86 | exit(); |
| @@ -92,8 +95,24 @@ | ||
| 92 | 95 | echo wp_json_encode(array('error' => 'token_checksum_mismatch')); |
| 93 | 96 | exit(); |
| 94 | 97 | } |
| 95 | 98 | |
| 99 | + //260928.1520 Framework button shortcodes use the same durable coordinator as Pro-Forms, initialized before any provider-side create operation and required for later browser return/confirmation. | |
| 100 | + if(strpos((string)$token['invoice'], 's2mb-') === 0 && $op !== 'cancel') | |
| 101 | + { | |
| 102 | + $create_allowed = in_array($op, array('create_order', 'create_subscription', 'get_plan_id', 'redirect'), TRUE); | |
| 103 | + $prepared = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_button_gateway_checkout_prepare($token, $create_allowed); | |
| 104 | + if(empty($prepared['ok'])) | |
| 105 | + { | |
| 106 | + $error = !empty($prepared['error']) ? (string)$prepared['error'] : 'gateway_checkout_unavailable'; | |
| 107 | + if($is_redirect_mode) | |
| 108 | + echo esc_html($error); | |
| 109 | + else | |
| 110 | + echo wp_json_encode(array('error' => $error)); | |
| 111 | + exit(); | |
| 112 | + } | |
| 113 | + } | |
| 114 | + | |
| 96 | 115 | if($token['ip'] !== c_ws_plugin__s2member_utils_ip::current()) |
| 97 | 116 | { |
| 98 | 117 | //260414 PayPal Checkout browser returns can legitimately arrive with a different client IP; log it, but do not fail the token. |
| 99 | 118 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| @@ -99,9 +118,9 @@ | ||
| 99 | 118 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| 100 | 119 | 'ppco' => 'checkout', |
| 101 | 120 | 'env_setting' => $env_setting, |
| 102 | 121 | 'event' => 'token_ip_mismatch', |
| 103 | - 'token' => $token, | |
| 122 | + 'token' => $log_token, | |
| 104 | 123 | 'ip' => c_ws_plugin__s2member_utils_ip::current(), |
| 105 | 124 | )); |
| 106 | 125 | } |
| 107 | 126 | |
| @@ -143,9 +162,9 @@ | ||
| 143 | 162 | 'ppco' => 'checkout', |
| 144 | 163 | 'env_setting' => $env_setting, |
| 145 | 164 | 'event' => 'redirect_order_create_response', |
| 146 | 165 | 'order' => $order, |
| 147 | - 'token' => $token, | |
| 166 | + 'token' => $log_token, | |
| 148 | 167 | )); |
| 149 | 168 | |
| 150 | 169 | $approve_url = ''; |
| 151 | 170 | if(!empty($order['links']) && is_array($order['links'])) |
| @@ -156,8 +175,18 @@ | ||
| 156 | 175 | if($rel === 'approve' || $rel === 'payer-action' || $rel === 'approval_url') |
| 157 | 176 | $approve_url = (string)$link['href']; |
| 158 | 177 | } |
| 159 | 178 | |
| 179 | + //260928.1605 A resumed Gateway Checkout returns its existing provider ID, not the original create response links; fetch the provider resource rather than create another chargeable order. | |
| 180 | + if(!$approve_url && !empty($order['id']) && strpos((string)$token['invoice'], 's2mb-') === 0) | |
| 181 | + { | |
| 182 | + $order_details = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_details((string)$order['id']); | |
| 183 | + if(empty($order_details['__error']) && !empty($order_details['links']) && is_array($order_details['links'])) | |
| 184 | + foreach($order_details['links'] as $link) | |
| 185 | + if(!empty($link['rel']) && !empty($link['href']) && in_array(strtolower((string)$link['rel']), array('approve', 'payer-action', 'approval_url'), TRUE)) | |
| 186 | + $approve_url = (string)$link['href']; | |
| 187 | + } | |
| 188 | + | |
| 160 | 189 | if(!$approve_url) |
| 161 | 190 | { |
| 162 | 191 | echo 'order_approval_url_missing'; |
| 163 | 192 | exit(); |
| @@ -174,9 +203,9 @@ | ||
| 174 | 203 | 'ppco' => 'checkout', |
| 175 | 204 | 'env_setting' => $env_setting, |
| 176 | 205 | 'event' => 'redirect_subscription_create_response', |
| 177 | 206 | 'subscription' => $subscription, |
| 178 | - 'token' => $token, | |
| 207 | + 'token' => $log_token, | |
| 179 | 208 | )); |
| 180 | 209 | |
| 181 | 210 | $approve_url = ''; |
| 182 | 211 | if(!empty($subscription['links']) && is_array($subscription['links'])) |
| @@ -183,8 +212,18 @@ | ||
| 183 | 212 | foreach($subscription['links'] as $link) |
| 184 | 213 | if(!empty($link['rel']) && !empty($link['href']) && strtolower((string)$link['rel']) === 'approve') |
| 185 | 214 | $approve_url = (string)$link['href']; |
| 186 | 215 | |
| 216 | + //260928.1605 Reuse the same persisted PayPal subscription on repeated redirect clicks. A details GET may supply the approval link without starting a second subscription. | |
| 217 | + if(!$approve_url && !empty($subscription['id']) && strpos((string)$token['invoice'], 's2mb-') === 0) | |
| 218 | + { | |
| 219 | + $subscription_details = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details((string)$subscription['id']); | |
| 220 | + if(empty($subscription_details['__error']) && !empty($subscription_details['links']) && is_array($subscription_details['links'])) | |
| 221 | + foreach($subscription_details['links'] as $link) | |
| 222 | + if(!empty($link['rel']) && !empty($link['href']) && strtolower((string)$link['rel']) === 'approve') | |
| 223 | + $approve_url = (string)$link['href']; | |
| 224 | + } | |
| 225 | + | |
| 187 | 226 | if(!$approve_url) |
| 188 | 227 | { |
| 189 | 228 | echo 'subscription_approval_url_missing'; |
| 190 | 229 | exit(); |
| @@ -218,9 +257,9 @@ | ||
| 218 | 257 | 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '', |
| 219 | 258 | 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '', |
| 220 | 259 | 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '', |
| 221 | 260 | 'capture' => $capture, |
| 222 | - 'token' => $token, | |
| 261 | + 'token' => $log_token, | |
| 223 | 262 | )); |
| 224 | 263 | |
| 225 | 264 | if(!empty($capture['__error'])) |
| 226 | 265 | { |
| @@ -233,8 +272,25 @@ | ||
| 233 | 272 | echo 'order_capture_failed'; |
| 234 | 273 | exit(); |
| 235 | 274 | } |
| 236 | 275 | |
| 276 | + //260928.1538 Framework button redirect purchases use the shared coordinator fulfillment instead of a second hand-written notify/return path. | |
| 277 | + if(strpos((string)$token['invoice'], 's2mb-') === 0) | |
| 278 | + { | |
| 279 | + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($capture, $token); | |
| 280 | + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post'])) | |
| 281 | + { | |
| 282 | + echo esc_html(!empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'order_fulfillment_failed'); | |
| 283 | + exit(); | |
| 284 | + } | |
| 285 | + echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>'; | |
| 286 | + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($fulfillment['rtn_url']).'">'; | |
| 287 | + foreach($fulfillment['rtn_post'] as $k => $v) | |
| 288 | + echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />'; | |
| 289 | + echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>'; | |
| 290 | + exit(); | |
| 291 | + } | |
| 292 | + | |
| 237 | 293 | //260818.0126 Keep submitted Pro-Form contact details for pro-emails; they may differ from the payer's PayPal profile. |
| 238 | 294 | $is_pro_form = (!empty($token['s2member_paypal_proxy_use']) && (string)$token['s2member_paypal_proxy_use'] === 'pro-emails'); |
| 239 | 295 | $payer_email = ($is_pro_form && isset($token['payer_email'])) ? sanitize_email((string)$token['payer_email']) : (!empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : ''); |
| 240 | 296 | $first_name = ($is_pro_form && isset($token['first_name'])) ? (string)$token['first_name'] : (!empty($capture['payer']['name']['given_name']) ? (string)$capture['payer']['name']['given_name'] : ''); |
| @@ -365,9 +421,9 @@ | ||
| 365 | 421 | 'event' => 'subscription_get_response', |
| 366 | 422 | 'subscription_id' => $subscription_id, |
| 367 | 423 | 'code' => !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0, |
| 368 | 424 | 'body' => !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '', |
| 369 | - 'token' => $token, | |
| 425 | + 'token' => $log_token, | |
| 370 | 426 | )); |
| 371 | 427 | |
| 372 | 428 | $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0; |
| 373 | 429 | $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : ''; |
| @@ -391,8 +447,41 @@ | ||
| 391 | 447 | echo 'subscription_custom_id_mismatch'; |
| 392 | 448 | exit(); |
| 393 | 449 | } |
| 394 | 450 | |
| 451 | + //260928.1538 A returned Framework button and an ACTIVATED webhook resolve the same provider subscription against the same saved purchase token. | |
| 452 | + if(strpos((string)$token['invoice'], 's2mb-') === 0) | |
| 453 | + { | |
| 454 | + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_button_subscription_fulfill($subscription, $token, 'return'); | |
| 455 | + if(!empty($fulfillment['pending_activation'])) | |
| 456 | + { | |
| 457 | + //260928.1703 PayPal can redirect before ACTIVE (or while the webhook holds the checkout lock). Recheck the same signed return, without creating another subscription or showing a false payment failure. | |
| 458 | + $wait_attempt = isset($_GET['s2member_paypal_checkout_wait']) ? max(0, (int)$_GET['s2member_paypal_checkout_wait']) : 0; | |
| 459 | + echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /><title>PayPal subscription confirmation</title></head><body>'; | |
| 460 | + echo '<p>PayPal is confirming your subscription. Please do not start a second checkout.</p>'; | |
| 461 | + if($wait_attempt < 12) | |
| 462 | + { | |
| 463 | + $poll_url = add_query_arg(array('subscription_id' => $subscription_id, 's2member_paypal_checkout_wait' => $wait_attempt + 1), $return_url); | |
| 464 | + echo '<script type="text/javascript">setTimeout(function(){window.location.replace('.wp_json_encode($poll_url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT).');},2000);</script>'; | |
| 465 | + } | |
| 466 | + else | |
| 467 | + echo '<p>Confirmation is taking longer than expected. If PayPal activates the subscription, s2Member will complete it through the webhook; check your registration email before trying again.</p>'; | |
| 468 | + echo '</body></html>'; | |
| 469 | + exit(); | |
| 470 | + } | |
| 471 | + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post'])) | |
| 472 | + { | |
| 473 | + echo esc_html(!empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'subscription_fulfillment_failed'); | |
| 474 | + exit(); | |
| 475 | + } | |
| 476 | + echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>'; | |
| 477 | + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($fulfillment['rtn_url']).'">'; | |
| 478 | + foreach($fulfillment['rtn_post'] as $k => $v) | |
| 479 | + echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />'; | |
| 480 | + echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>'; | |
| 481 | + exit(); | |
| 482 | + } | |
| 483 | + | |
| 395 | 484 | $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : ''; |
| 396 | 485 | $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : ''; |
| 397 | 486 | $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : ''; |
| 398 | 487 | |
| @@ -471,8 +560,66 @@ | ||
| 471 | 560 | exit(); |
| 472 | 561 | } |
| 473 | 562 | } |
| 474 | 563 | |
| 564 | + if($op === 'create_subscription') | |
| 565 | + { | |
| 566 | + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN') | |
| 567 | + { | |
| 568 | + echo wp_json_encode(array('error' => 'not_subscription')); | |
| 569 | + exit(); | |
| 570 | + } | |
| 571 | + | |
| 572 | + $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_create($token); | |
| 573 | + | |
| 574 | + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( | |
| 575 | + 'ppco' => 'checkout', | |
| 576 | + 'env_setting' => $env_setting, | |
| 577 | + 'event' => 'create_subscription_response', | |
| 578 | + 'subscription' => $subscription, | |
| 579 | + 'token' => $log_token, | |
| 580 | + )); | |
| 581 | + | |
| 582 | + if(empty($subscription['id'])) | |
| 583 | + { | |
| 584 | + $error = !empty($subscription['__error']) ? (string)$subscription['__error'] : 'subscription_create_failed'; | |
| 585 | + $recoverable = in_array($error, array('subscription_create_unresolved', 'gateway_checkout_busy'), TRUE); | |
| 586 | + //260902.0200 Let coordinator-backed browser flows briefly wait for webhook repair only when creation is genuinely unresolved/in progress; deterministic failures remain immediate errors. | |
| 587 | + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable)); | |
| 588 | + exit(); | |
| 589 | + } | |
| 590 | + | |
| 591 | + //260901.2145 The browser receives only the already-persisted PayPal subscription ID; PayPal's JS SDK handles buyer approval from that server-created resource. | |
| 592 | + echo wp_json_encode(array('subscription_id' => (string)$subscription['id'])); | |
| 593 | + exit(); | |
| 594 | + } | |
| 595 | + | |
| 596 | + if($op === 'get_subscription_id') | |
| 597 | + { | |
| 598 | + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN') | |
| 599 | + { | |
| 600 | + echo wp_json_encode(array('error' => 'not_subscription')); | |
| 601 | + exit(); | |
| 602 | + } | |
| 603 | + | |
| 604 | + $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'] : ''; | |
| 605 | + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id) : FALSE; | |
| 606 | + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription') | |
| 607 | + { | |
| 608 | + echo wp_json_encode(array('error' => 'gateway_checkout_invalid')); | |
| 609 | + exit(); | |
| 610 | + } | |
| 611 | + | |
| 612 | + $subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : ''; | |
| 613 | + //260902.0200 This poll reads only local coordinator state; PayPal is not called repeatedly while a CREATED webhook has a chance to repair an ambiguous create response. | |
| 614 | + echo wp_json_encode(array( | |
| 615 | + 'subscription_id' => $subscription_id, | |
| 616 | + 'pending' => !$subscription_id, | |
| 617 | + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '', | |
| 618 | + )); | |
| 619 | + exit(); | |
| 620 | + } | |
| 621 | + | |
| 475 | 622 | if($op === 'get_plan_id') |
| 476 | 623 | { |
| 477 | 624 | if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN') |
| 478 | 625 | { |
| @@ -486,9 +633,9 @@ | ||
| 486 | 633 | 'ppco' => 'checkout', |
| 487 | 634 | 'env_setting' => $env_setting, |
| 488 | 635 | 'event' => 'get_plan_id_response', |
| 489 | 636 | 'plan_id' => $plan_id, |
| 490 | - 'token' => $token, | |
| 637 | + 'token' => $log_token, | |
| 491 | 638 | )); |
| 492 | 639 | |
| 493 | 640 | if(!$plan_id) |
| 494 | 641 | { |
| @@ -513,8 +660,22 @@ | ||
| 513 | 660 | { |
| 514 | 661 | echo wp_json_encode(array('error' => 'missing_subscription_id')); |
| 515 | 662 | exit(); |
| 516 | 663 | } |
| 664 | + | |
| 665 | + $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'] : ''; | |
| 666 | + if($gateway_checkout_id) | |
| 667 | + { | |
| 668 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 669 | + $expected_subscription_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : ''; | |
| 670 | + //260901.2145 A coordinator-backed browser may confirm only the PayPal subscription that s2Member created and persisted for this logical checkout. | |
| 671 | + if(!$expected_subscription_id || !hash_equals($expected_subscription_id, $subscription_id)) | |
| 672 | + { | |
| 673 | + echo wp_json_encode(array('error' => 'gateway_checkout_subscription_mismatch')); | |
| 674 | + exit(); | |
| 675 | + } | |
| 676 | + } | |
| 677 | + | |
| 517 | 678 | $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id)); |
| 518 | 679 | |
| 519 | 680 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| 520 | 681 | 'ppco' => 'checkout', |
| @@ -521,9 +682,9 @@ | ||
| 521 | 682 | 'env_setting' => $env_setting, |
| 522 | 683 | 'event' => 'subscription_get_response', |
| 523 | 684 | 'subscription_id' => $subscription_id, |
| 524 | 685 | 'subscription' => $subscription_r, |
| 525 | - 'token' => $token, | |
| 686 | + 'token' => $log_token, | |
| 526 | 687 | )); |
| 527 | 688 | |
| 528 | 689 | $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0; |
| 529 | 690 | $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : ''; |
| @@ -568,13 +729,13 @@ | ||
| 568 | 729 | if($lpv !== '' && $lpc !== '') |
| 569 | 730 | $allow_expired_single_cycle = true; |
| 570 | 731 | } |
| 571 | 732 | |
| 572 | - if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle) | |
| 733 | + if(!$status) | |
| 573 | 734 | { |
| 574 | 735 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| 575 | 736 | 'ppco' => 'checkout', |
| 576 | - 'env_setting' => $env_setting, | |
| 737 | + 'env_setting' => $env_setting, | |
| 577 | 738 | 'event' => 'subscription_status_invalid', |
| 578 | 739 | 'subscription_id' => $subscription_id, |
| 579 | 740 | 'status' => $status, |
| 580 | 741 | )); |
| @@ -610,8 +771,66 @@ | ||
| 610 | 771 | echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch')); |
| 611 | 772 | exit(); |
| 612 | 773 | } |
| 613 | 774 | |
| 775 | + //260928.1538 Framework button and webhook share a single durable fulfillment path; do not create a second simulated IPN here. | |
| 776 | + if(strpos((string)$token['invoice'], 's2mb-') === 0) | |
| 777 | + { | |
| 778 | + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_button_subscription_fulfill($subscription, $token, 'browser'); | |
| 779 | + if(!empty($fulfillment['pending_activation'])) | |
| 780 | + { | |
| 781 | + echo wp_json_encode(array('pending_activation' => TRUE, 'subscription_id' => $subscription_id, 'status' => (string)$fulfillment['status'])); | |
| 782 | + exit(); | |
| 783 | + } | |
| 784 | + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post'])) | |
| 785 | + { | |
| 786 | + echo wp_json_encode(array('error' => !empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'subscription_fulfillment_failed')); | |
| 787 | + exit(); | |
| 788 | + } | |
| 789 | + echo wp_json_encode(array('rtn_url' => $fulfillment['rtn_url'], 'rtn_post' => $fulfillment['rtn_post'])); | |
| 790 | + exit(); | |
| 791 | + } | |
| 792 | + | |
| 793 | + if($gateway_checkout_id) | |
| 794 | + { | |
| 795 | + if(in_array($status, array('APPROVAL_PENDING', 'APPROVED'), TRUE)) | |
| 796 | + { | |
| 797 | + //260902.0200 Coordinator-backed Pro-Forms do not treat PayPal creation/approval-pending states as paid entitlement; the browser waits briefly for ACTIVE and the activation webhook remains an off-session fallback. | |
| 798 | + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status)); | |
| 799 | + echo wp_json_encode(array('pending_activation' => TRUE, 'subscription_id' => $subscription_id, 'status' => $status)); | |
| 800 | + exit(); | |
| 801 | + } | |
| 802 | + if($status !== 'ACTIVE' && !$allow_expired_single_cycle) | |
| 803 | + { | |
| 804 | + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( | |
| 805 | + 'ppco' => 'checkout', | |
| 806 | + 'env_setting' => $env_setting, | |
| 807 | + 'event' => 'subscription_status_invalid', | |
| 808 | + 'subscription_id' => $subscription_id, | |
| 809 | + 'status' => $status, | |
| 810 | + )); | |
| 811 | + | |
| 812 | + echo wp_json_encode(array('error' => 'subscription_status_invalid')); | |
| 813 | + exit(); | |
| 814 | + } | |
| 815 | + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status)); | |
| 816 | + } | |
| 817 | + else if(!in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), TRUE) && !$allow_expired_single_cycle) | |
| 818 | + { | |
| 819 | + //260902.0200 Preserve existing non-coordinator PayPal Checkout button behavior until those flows migrate onto Gateway Checkout and gain the same activation polling. | |
| 820 | + //260928.1645 Existing pre-migration browser tabs still carry the legacy PayPal Checkout token without Gateway Checkout identity. Preserve their original confirmation behavior until those in-flight tokens expire. | |
| 821 | + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( | |
| 822 | + 'ppco' => 'checkout', | |
| 823 | + 'env_setting' => $env_setting, | |
| 824 | + 'event' => 'subscription_status_invalid', | |
| 825 | + 'subscription_id' => $subscription_id, | |
| 826 | + 'status' => $status, | |
| 827 | + )); | |
| 828 | + | |
| 829 | + echo wp_json_encode(array('error' => 'subscription_status_invalid')); | |
| 830 | + exit(); | |
| 831 | + } | |
| 832 | + | |
| 614 | 833 | $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : ''; |
| 615 | 834 | $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : ''; |
| 616 | 835 | $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : ''; |
| 617 | 836 | |
| @@ -734,9 +953,9 @@ | ||
| 734 | 953 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| 735 | 954 | 'ppco' => 'checkout', |
| 736 | 955 | 'env_setting' => $env_setting, |
| 737 | 956 | 'event' => 'cancel_subscription_not_logged_in', |
| 738 | - 'token' => $token, | |
| 957 | + 'token' => $log_token, | |
| 739 | 958 | )); |
| 740 | 959 | |
| 741 | 960 | echo wp_json_encode(array('error' => 'not_logged_in')); |
| 742 | 961 | exit(); |
| @@ -766,9 +985,9 @@ | ||
| 766 | 985 | 'ppco' => 'checkout', |
| 767 | 986 | 'env_setting' => $env_setting, |
| 768 | 987 | 'event' => 'cancel_subscription_token_mismatch', |
| 769 | 988 | 'user_id' => $user_id, |
| 770 | - 'token' => $token, | |
| 989 | + 'token' => $log_token, | |
| 771 | 990 | )); |
| 772 | 991 | |
| 773 | 992 | echo wp_json_encode(array('error' => 'token_mismatch')); |
| 774 | 993 | exit(); |
| @@ -907,9 +1126,9 @@ | ||
| 907 | 1126 | 'ppco' => 'checkout', |
| 908 | 1127 | 'env_setting' => $env_setting, |
| 909 | 1128 | 'event' => 'create_order_response', |
| 910 | 1129 | 'order' => $order, |
| 911 | - 'token' => $token, | |
| 1130 | + 'token' => $log_token, | |
| 912 | 1131 | )); |
| 913 | 1132 | |
| 914 | 1133 | if(empty($order['id'])) |
| 915 | 1134 | { |
| @@ -917,17 +1136,52 @@ | ||
| 917 | 1136 | 'ppco' => 'checkout', |
| 918 | 1137 | 'env_setting' => $env_setting, |
| 919 | 1138 | 'event' => 'order_create_failed', |
| 920 | 1139 | 'order' => $order, |
| 921 | - 'token' => $token, | |
| 1140 | + 'token' => $log_token, | |
| 922 | 1141 | )); |
| 923 | 1142 | |
| 924 | - echo wp_json_encode(array('error' => 'order_create_failed')); | |
| 1143 | + $error = !empty($order['__error']) ? (string)$order['__error'] : 'order_create_failed'; | |
| 1144 | + $recoverable = ($error === 'gateway_checkout_busy'); | |
| 1145 | + //260902.0646 Only an overlapping request can populate a missing order ID asynchronously; an ambiguous provider create has no pre-approval webhook, so tell the customer to retry the same idempotent checkout instead of polling pointlessly. | |
| 1146 | + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'retryable' => ($error === 'order_create_unresolved'))); | |
| 925 | 1147 | exit(); |
| 926 | 1148 | } |
| 927 | 1149 | echo wp_json_encode(array('order_id' => $order['id'])); |
| 928 | 1150 | exit(); |
| 929 | 1151 | } |
| 1152 | + else if($op === 'get_order_status') | |
| 1153 | + { | |
| 1154 | + //260907.1820 This recovery endpoint is intentionally coordinator-only: the signed checkout token authorizes a local state read, while PayPal polling/retries remain server/webhook responsibilities. | |
| 1155 | + $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'] : ''; | |
| 1156 | + //260928.1703 Read fresh option state during capture-loss polling: a webhook may have fulfilled the checkout in another PHP worker moments earlier. | |
| 1157 | + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state_uncached($gateway_checkout_id) : FALSE; | |
| 1158 | + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment') | |
| 1159 | + { | |
| 1160 | + echo wp_json_encode(array('error' => 'gateway_checkout_invalid')); | |
| 1161 | + exit(); | |
| 1162 | + } | |
| 1163 | + | |
| 1164 | + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id); | |
| 1165 | + $fulfillment_result = is_array($private_context) && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']) ? $private_context['paypal_checkout']['fulfillment_result'] : array(); | |
| 1166 | + //260902.0635 Poll only local coordinator state while independent PayPal webhooks resolve delayed creates/captures; do not hammer the provider from the browser. | |
| 1167 | + $fulfilled = ((string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result['rtn_url']) && !empty($fulfillment_result['rtn_post'])); | |
| 1168 | + $response = array( | |
| 1169 | + 'order_id' => !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '', | |
| 1170 | + 'capture_id' => !empty($gateway_checkout['gateway_ids']['capture_id']) ? (string)$gateway_checkout['gateway_ids']['capture_id'] : '', | |
| 1171 | + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '', | |
| 1172 | + 'fulfillment_status' => !empty($gateway_checkout['fulfillment_status']) ? (string)$gateway_checkout['fulfillment_status'] : '', | |
| 1173 | + 'fulfilled' => $fulfilled, | |
| 1174 | + ); | |
| 1175 | + //260928.1703 A lost capture response can be recovered with the already-signed return handoff; only the original encrypted checkout token can reach this endpoint. | |
| 1176 | + if($fulfilled) | |
| 1177 | + { | |
| 1178 | + $response['rtn_url'] = $fulfillment_result['rtn_url']; | |
| 1179 | + $response['rtn_post'] = $fulfillment_result['rtn_post']; | |
| 1180 | + } | |
| 1181 | + echo wp_json_encode($response); | |
| 1182 | + exit(); | |
| 1183 | + } | |
| 930 | 1184 | else if($op === 'capture_order') |
| 931 | 1185 | { |
| 932 | 1186 | $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : ''; |
| 933 | 1187 | |
| @@ -935,8 +1189,23 @@ | ||
| 935 | 1189 | { |
| 936 | 1190 | echo wp_json_encode(array('error' => 'missing_order_id')); |
| 937 | 1191 | exit(); |
| 938 | 1192 | } |
| 1193 | + | |
| 1194 | + $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'] : ''; | |
| 1195 | + if($gateway_checkout_id) | |
| 1196 | + { | |
| 1197 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 1198 | + $private_context = $gateway_checkout ? c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id) : FALSE; | |
| 1199 | + $fulfillment_result = is_array($private_context) && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']) ? $private_context['paypal_checkout']['fulfillment_result'] : array(); | |
| 1200 | + if($gateway_checkout && (string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result['rtn_url']) && !empty($fulfillment_result['rtn_post'])) | |
| 1201 | + { | |
| 1202 | + //260902.0646 A webhook may have finished checkout while the browser was gone; return the saved browser result locally without touching PayPal or repeating fulfillment. | |
| 1203 | + echo wp_json_encode(array('rtn_url' => $fulfillment_result['rtn_url'], 'rtn_post' => $fulfillment_result['rtn_post'])); | |
| 1204 | + exit(); | |
| 1205 | + } | |
| 1206 | + } | |
| 1207 | + | |
| 939 | 1208 | $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token); |
| 940 | 1209 | |
| 941 | 1210 | $cap0 = (!empty($capture['purchase_units'][0]['payments']['captures'][0]) && is_array($capture['purchase_units'][0]['payments']['captures'][0])) ? $capture['purchase_units'][0]['payments']['captures'][0] : array(); |
| 942 | 1211 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| @@ -949,11 +1218,32 @@ | ||
| 949 | 1218 | 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '', |
| 950 | 1219 | 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '', |
| 951 | 1220 | 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '', |
| 952 | 1221 | 'capture' => $capture, |
| 953 | - 'token' => $token, | |
| 1222 | + 'token' => $log_token, | |
| 954 | 1223 | )); |
| 955 | 1224 | |
| 1225 | + if($gateway_checkout_id) | |
| 1226 | + { | |
| 1227 | + if(!empty($capture['__error'])) | |
| 1228 | + { | |
| 1229 | + $error = (string)$capture['__error']; | |
| 1230 | + $recoverable = in_array($error, array('capture_pending', 'order_capture_unresolved', 'gateway_checkout_busy'), TRUE); | |
| 1231 | + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'pending' => ($error === 'capture_pending'))); | |
| 1232 | + exit(); | |
| 1233 | + } | |
| 1234 | + | |
| 1235 | + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($capture, $token); | |
| 1236 | + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post'])) | |
| 1237 | + { | |
| 1238 | + echo wp_json_encode(array('error' => !empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'order_fulfillment_failed')); | |
| 1239 | + exit(); | |
| 1240 | + } | |
| 1241 | + | |
| 1242 | + echo wp_json_encode(array('rtn_url' => $fulfillment['rtn_url'], 'rtn_post' => $fulfillment['rtn_post'])); | |
| 1243 | + exit(); | |
| 1244 | + } | |
| 1245 | + | |
| 956 | 1246 | if(!empty($capture['__error'])) |
| 957 | 1247 | { |
| 958 | 1248 | echo wp_json_encode(array('error' => (string)$capture['__error'])); |
| 959 | 1249 | exit(); |
| @@ -983,9 +1273,9 @@ | ||
| 983 | 1273 | 'env_setting' => $env_setting, |
| 984 | 1274 | 'event' => 'capture_missing_fields', |
| 985 | 1275 | 'order_id' => $order_id, |
| 986 | 1276 | 'capture' => $capture, |
| 987 | - 'token' => $token, | |
| 1277 | + 'token' => $log_token, | |
| 988 | 1278 | )); |
| 989 | 1279 | |
| 990 | 1280 | echo wp_json_encode(array('error' => 'capture_missing_fields')); |
| 991 | 1281 | exit(); |
| @@ -999,9 +1289,9 @@ | ||
| 999 | 1289 | 'ppco' => 'checkout', |
| 1000 | 1290 | 'env_setting' => $env_setting, |
| 1001 | 1291 | 'event' => 'amount_mismatch', |
| 1002 | 1292 | 'order_id' => $order_id, |
| 1003 | - 'token' => $token, | |
| 1293 | + 'token' => $log_token, | |
| 1004 | 1294 | 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc), |
| 1005 | 1295 | )); |
| 1006 | 1296 | echo wp_json_encode(array('error' => 'amount_mismatch')); |
| 1007 | 1297 | exit(); |
| @@ -1012,9 +1302,9 @@ | ||
| 1012 | 1302 | 'ppco' => 'checkout', |
| 1013 | 1303 | 'env_setting' => $env_setting, |
| 1014 | 1304 | 'event' => 'currency_mismatch', |
| 1015 | 1305 | 'order_id' => $order_id, |
| 1016 | - 'token' => $token, | |
| 1306 | + 'token' => $log_token, | |
| 1017 | 1307 | 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc), |
| 1018 | 1308 | )); |
| 1019 | 1309 | echo wp_json_encode(array('error' => 'currency_mismatch')); |
| 1020 | 1310 | exit(); |
| @@ -1031,9 +1321,9 @@ | ||
| 1031 | 1321 | 'ppco' => 'checkout', |
| 1032 | 1322 | 'env_setting' => $env_setting, |
| 1033 | 1323 | 'event' => 'invoice_mismatch', |
| 1034 | 1324 | 'order_id' => $order_id, |
| 1035 | - 'token' => $token, | |
| 1325 | + 'token' => $log_token, | |
| 1036 | 1326 | 'invoice' => $cap_invoice_id, |
| 1037 | 1327 | )); |
| 1038 | 1328 | echo wp_json_encode(array('error' => 'invoice_mismatch')); |
| 1039 | 1329 | exit(); |
| @@ -1051,9 +1341,9 @@ | ||
| 1051 | 1341 | 'ppco' => 'checkout', |
| 1052 | 1342 | 'env_setting' => $env_setting, |
| 1053 | 1343 | 'event' => 'custom_mismatch', |
| 1054 | 1344 | 'order_id' => $order_id, |
| 1055 | - 'token' => $token, | |
| 1345 | + 'token' => $log_token, | |
| 1056 | 1346 | 'custom' => array( |
| 1057 | 1347 | 'token' => !empty($token['custom']) ? $token['custom'] : '', |
| 1058 | 1348 | 'paypal' => $cap_custom_id, |
| 1059 | 1349 | ), |