PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 261001
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v261001
261001 260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 All 191 releases
← All changes | src/includes/classes/paypal-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 ),