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 +507 -258 260814 → 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,11 +257,17 @@
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
264 + if(!empty($capture['__error']))
265 + {
266 + echo (string)$capture['__error'];
267 + exit();
268 + }
269 +
225 270 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
226 271 {
227 272 echo 'order_capture_failed';
228 273 exit();
@@ -227,12 +272,31 @@
227 272 echo 'order_capture_failed';
228 273 exit();
229 274 }
230 275
231 - $payer_email = !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '';
232 - $first_name = !empty($capture['payer']['name']['given_name']) ? (string)$capture['payer']['name']['given_name'] : '';
233 - $last_name = !empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '';
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 + }
234 292
293 + //260818.0126 Keep submitted Pro-Form contact details for pro-emails; they may differ from the payer's PayPal profile.
294 + $is_pro_form = (!empty($token['s2member_paypal_proxy_use']) && (string)$token['s2member_paypal_proxy_use'] === 'pro-emails');
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'] : '');
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'] : '');
297 + $last_name = ($is_pro_form && isset($token['last_name'])) ? (string)$token['last_name'] : (!empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '');
298 +
235 299 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
236 300 $pu_cc = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code'] : '';
237 301 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
238 302
@@ -272,36 +336,44 @@
272 336 'first_name' => $first_name,
273 337 'last_name' => $last_name,
274 338 );
275 339
340 + //260817.2119 Preserve Pro-Form tax in the simulated IPN so existing fulfillment and email logic receives the same calculated values as the legacy Pro flow.
341 + if(isset($token['tax']))
342 + $paypal['tax'] = (string)$token['tax'];
343 +
276 344 $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
277 345 $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
278 346 $can_cancel_old_subscr = (!$is_independent_ccaps_sale && !$is_specific_post_page_sale); //260407 Only membership replacement-style PPCO purchases should cancel an existing recurring subscription here.
279 347
280 - $notify_url = home_url('/?s2member_paypal_notify=1');
281 - $notify_post = array_merge($paypal, array(
282 - 's2member_paypal_proxy' => 'paypal',
283 - 's2member_paypal_proxy_use' => 'paypal_checkout',
284 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
285 - ));
286 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
348 + //260817.2119 Keep normal Checkout defaults while allowing an encrypted Pro-Form token to request its existing email, coupon, and success-URL handling during the internal Notify call.
349 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
350 + $notify_extra = array();
287 351
288 - if(!is_array($notify_r))
352 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
353 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
354 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
355 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
356 +
357 + $notify_done_option = 's2m_ppco_capture_done_'.md5($pu_cap_id);
358 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $notify_done_option, $proxy_use, $notify_extra);
359 +
360 + if(empty($notify_result['ok']))
289 361 {
290 362 if($is_redirect_mode)
291 - echo 'notify_proxy_failed';
363 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
292 364 else
293 365 {
294 366 if(!headers_sent())
295 367 status_header(500);
296 368
297 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
369 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
298 370 }
299 371 exit();
300 372 }
301 373
302 - //260407 Framework PPCO replacements need the same old-subscription cancellation behavior without affecting independent CCAPS or specific post/page purchases.
303 - if($can_cancel_old_subscr && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $pu_cap_id), get_defined_vars())) //260406
374 + //260817 Only the request that actually performed fulfillment should trigger replacement-subscription cancellation.
375 + if(!empty($notify_result['processed']) && $can_cancel_old_subscr && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $pu_cap_id), get_defined_vars())) //260406
304 376 c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars); //260407
305 377
306 378 $return_url = (string)$token['return'];
307 379 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -306,16 +378,28 @@
306 378 $return_url = (string)$token['return'];
307 379 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
308 380
309 381 $return_post = array_merge($paypal, array(
310 - 's2member_paypal_proxy' => 'paypal',
311 - 's2member_paypal_proxy_use' => 'paypal_checkout',
312 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
382 + 's2member_paypal_proxy' => 'paypal',
383 + 's2member_paypal_proxy_use' => $proxy_use,
313 384 ));
314 385
386 + //260817 Carry the already-resolved Pro-Form success URL inside the signed browser-return package.
387 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
388 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
389 +
390 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
391 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
392 + if(!$return_handoff)
393 + {
394 + echo 'return_handoff_failed';
395 + exit();
396 + }
397 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
398 +
315 399 // Auto-POST into s2Member's existing PayPal return handler.
316 400 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
317 - echo '<form id="s2m_ppco_rtn" method="post" action="'.esc_attr($return_url).'">';
401 + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($return_url).'">'; //260817 Keep the signed browser-return payload encoding stable.
318 402 foreach($return_post as $k => $v)
319 403 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
320 404 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
321 405 exit();
@@ -337,9 +421,9 @@
337 421 'event' => 'subscription_get_response',
338 422 'subscription_id' => $subscription_id,
339 423 'code' => !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0,
340 424 'body' => !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '',
341 - 'token' => $token,
425 + 'token' => $log_token,
342 426 ));
343 427
344 428 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
345 429 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
@@ -363,8 +447,41 @@
363 447 echo 'subscription_custom_id_mismatch';
364 448 exit();
365 449 }
366 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 +
367 484 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
368 485 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
369 486 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
370 487
@@ -402,61 +519,42 @@
402 519 'option_name2' => (string)$token['on1'],
403 520 'option_selection2' => (string)$token['os1'],
404 521 );
405 522
406 - //260406 Use the shared PayPal Checkout subscription-done option so checkout and webhooks agree on fallback suppression.
407 523 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
408 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
409 524
410 - if($option_ppco_subscr_time > 0 && (time() - $option_ppco_subscr_time) >= DAY_IN_SECONDS)
525 + //260818.0603 Share the success-only Notify lock/done marker with browser confirmation and webhook activation fallback.
526 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $option_ppco_subscr);
527 +
528 + if(empty($notify_result['ok']))
411 529 {
412 - delete_option($option_ppco_subscr);
413 - $option_ppco_subscr_time = 0;
530 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
531 + exit();
414 532 }
415 533
416 - if(!$option_ppco_subscr_time)
417 - {
418 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
419 - update_option($option_ppco_subscr, time(), false);
534 + //260818.0603 Only the request that completed Notify should cancel a replaced subscription; duplicates are already fulfilled.
535 + if(!empty($notify_result['processed']) && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars()))
536 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars);
420 537
421 - $notify_url = home_url('/?s2member_paypal_notify=1');
422 - $notify_post = array_merge($paypal, array(
423 - 's2member_paypal_proxy' => 'paypal',
424 - 's2member_paypal_proxy_use' => 'paypal_checkout',
425 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
426 - ));
427 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
428 -
429 - if(!is_array($notify_r))
430 - {
431 - if($is_redirect_mode)
432 - echo 'notify_proxy_failed';
433 - else
434 - {
435 - if(!headers_sent())
436 - status_header(500);
437 -
438 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
439 - }
440 - exit();
441 - }
442 -
443 - //260407 Framework PPCO replacements can also replace subscriptions created by other gateways
444 - if($old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars())) //260406.
445 - c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars); //260407
446 - }
447 -
448 538 $return_url2 = (string)$token['return'];
449 539 $return_url2 = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url2);
450 540
451 541 $return_post2 = array_merge($paypal, array(
452 - 's2member_paypal_proxy' => 'paypal',
453 - 's2member_paypal_proxy_use' => 'paypal_checkout',
454 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
542 + 's2member_paypal_proxy' => 'paypal',
543 + 's2member_paypal_proxy_use' => 'paypal_checkout',
455 544 ));
456 545
546 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
547 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post2);
548 + if(!$return_handoff)
549 + {
550 + echo 'return_handoff_failed';
551 + exit();
552 + }
553 + $return_post2['s2member_paypal_checkout_handoff'] = $return_handoff;
554 +
457 555 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
458 - echo '<form id="s2m_ppco_rtn" method="post" action="'.esc_attr($return_url2).'">';
556 + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($return_url2).'">'; //260817 Keep the signed browser-return payload encoding stable.
459 557 foreach($return_post2 as $k => $v)
460 558 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
461 559 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
462 560 exit();
@@ -462,8 +560,66 @@
462 560 exit();
463 561 }
464 562 }
465 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 +
466 622 if($op === 'get_plan_id')
467 623 {
468 624 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
469 625 {
@@ -477,9 +633,9 @@
477 633 'ppco' => 'checkout',
478 634 'env_setting' => $env_setting,
479 635 'event' => 'get_plan_id_response',
480 636 'plan_id' => $plan_id,
481 - 'token' => $token,
637 + 'token' => $log_token,
482 638 ));
483 639
484 640 if(!$plan_id)
485 641 {
@@ -504,8 +660,22 @@
504 660 {
505 661 echo wp_json_encode(array('error' => 'missing_subscription_id'));
506 662 exit();
507 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 +
508 678 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
509 679
510 680 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
511 681 'ppco' => 'checkout',
@@ -512,9 +682,9 @@
512 682 'env_setting' => $env_setting,
513 683 'event' => 'subscription_get_response',
514 684 'subscription_id' => $subscription_id,
515 685 'subscription' => $subscription_r,
516 - 'token' => $token,
686 + 'token' => $log_token,
517 687 ));
518 688
519 689 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
520 690 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
@@ -559,13 +729,13 @@
559 729 if($lpv !== '' && $lpc !== '')
560 730 $allow_expired_single_cycle = true;
561 731 }
562 732
563 - if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle)
733 + if(!$status)
564 734 {
565 735 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
566 736 'ppco' => 'checkout',
567 - 'env_setting' => $env_setting,
737 + 'env_setting' => $env_setting,
568 738 'event' => 'subscription_status_invalid',
569 739 'subscription_id' => $subscription_id,
570 740 'status' => $status,
571 741 ));
@@ -601,8 +771,66 @@
601 771 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
602 772 exit();
603 773 }
604 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 +
605 833 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
606 834 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
607 835 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
608 836
@@ -640,90 +868,56 @@
640 868 'option_name2' => (string)$token['on1'],
641 869 'option_selection2' => (string)$token['os1'],
642 870 );
643 871
644 - $ppco_dup_processed = false;
645 872 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
646 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
647 873
648 - if($option_ppco_subscr_time > 0 && (time() - $option_ppco_subscr_time) >= DAY_IN_SECONDS)
874 + //260818.0603 Mark the Subscription done only after Notify succeeds, using the same lock as webhook activation fallback.
875 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $option_ppco_subscr);
876 + $notify_code = !empty($notify_result['code']) ? (int)$notify_result['code'] : 0;
877 + $notify_msg = !empty($notify_result['message']) ? (string)$notify_result['message'] : '';
878 + $notify_body = !empty($notify_result['body']) ? (string)$notify_result['body'] : '';
879 +
880 + if(empty($notify_result['ok']))
649 881 {
650 - delete_option($option_ppco_subscr);
651 - $option_ppco_subscr_time = 0;
882 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
883 + 'ppco' => 'checkout',
884 + 'env_setting' => $env_setting,
885 + 'event' => 'notify_proxy_failed',
886 + 'subscription_id' => $subscription_id,
887 + 'code' => $notify_code,
888 + 'message' => $notify_msg,
889 + 'body' => $notify_body,
890 + 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed',
891 + ));
892 +
893 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
894 + exit();
652 895 }
653 896
654 - $ppco_dup_processed = ($option_ppco_subscr_time > 0);
655 -
656 - if($ppco_dup_processed)
897 + if(!empty($notify_result['duplicate']))
657 898 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
658 899 'ppco' => 'checkout',
659 - 'env_setting' => $env_setting,
900 + 'env_setting' => $env_setting,
660 901 'event' => 'duplicate_subscription_ignored',
661 902 'subscription_id' => $subscription_id,
662 903 'option' => $option_ppco_subscr,
663 904 ));
664 -
665 - if(!$ppco_dup_processed)
905 + else
666 906 {
667 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
668 - update_option($option_ppco_subscr, time(), false);
669 -
670 907 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
671 908 'ppco' => 'checkout',
672 - 'env_setting' => $env_setting,
673 - 'event' => 'idempotency_subscription_set',
909 + 'env_setting' => $env_setting,
910 + 'event' => 'notify_proxy_response',
674 911 'subscription_id' => $subscription_id,
675 - 'option' => $option_ppco_subscr,
676 - 'expires_secs' => DAY_IN_SECONDS,
912 + 'code' => $notify_code,
913 + 'message' => $notify_msg,
914 + 'body' => $notify_body,
677 915 ));
678 916
679 - $notify_url = home_url('/?s2member_paypal_notify=1');
680 - $notify_post = array_merge($paypal, array(
681 - 's2member_paypal_proxy' => 'paypal',
682 - 's2member_paypal_proxy_use' => 'paypal_checkout',
683 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
684 - ));
685 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
686 -
687 - if(!is_array($notify_r))
688 - $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
689 -
690 - $notify_code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
691 - $notify_msg = !empty($notify_r['message']) ? (string)$notify_r['message'] : '';
692 - $notify_body = !empty($notify_r['body']) ? $notify_r['body'] : '';
693 -
694 - if($notify_code >= 200 && $notify_code <= 299)
695 - {
696 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
697 - 'ppco' => 'checkout',
698 - 'env_setting' => $env_setting,
699 - 'event' => 'notify_proxy_response',
700 - 'subscription_id' => $subscription_id,
701 - 'url' => $notify_url,
702 - 'code' => $notify_code,
703 - 'message' => $notify_msg,
704 - 'body' => $notify_body,
705 - ));
706 -
707 - //260407 Framework PPCO AJAX replacements need the same gateway-aware old-subscription cancellation behavior.
708 - if($old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars())) //260406
709 - c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars); //260407
710 - }
711 - else
712 - {
713 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
714 - 'ppco' => 'checkout',
715 - 'env_setting' => $env_setting,
716 - 'event' => 'notify_proxy_failed',
717 - 'subscription_id' => $subscription_id,
718 - 'url' => $notify_url,
719 - 'code' => $notify_code,
720 - 'message' => $notify_msg,
721 - 'body' => $notify_body,
722 - ));
723 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
724 - exit();
725 - }
917 + //260818.0603 Only successful first-pass fulfillment should trigger replacement-subscription cancellation.
918 + if(!empty($notify_result['processed']) && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars()))
919 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars);
726 920 }
727 921
728 922 $return_url = (string)$token['return'];
729 923 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -728,13 +922,24 @@
728 922 $return_url = (string)$token['return'];
729 923 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
730 924
731 925 $return_post = array_merge($paypal, array(
732 - 's2member_paypal_proxy' => 'paypal',
733 - 's2member_paypal_proxy_use' => 'paypal_checkout',
734 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
926 + 's2member_paypal_proxy' => 'paypal',
927 + 's2member_paypal_proxy_use' => 'paypal_checkout',
735 928 ));
736 929
930 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
931 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
932 + if(!$return_handoff)
933 + {
934 + if(!headers_sent())
935 + status_header(500);
936 +
937 + echo wp_json_encode(array('error' => 'return_handoff_failed'));
938 + exit();
939 + }
940 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
941 +
737 942 echo wp_json_encode(array(
738 943 'rtn_url' => $return_url,
739 944 'rtn_post' => $return_post,
740 945 ));
@@ -748,9 +953,9 @@
748 953 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
749 954 'ppco' => 'checkout',
750 955 'env_setting' => $env_setting,
751 956 'event' => 'cancel_subscription_not_logged_in',
752 - 'token' => $token,
957 + 'token' => $log_token,
753 958 ));
754 959
755 960 echo wp_json_encode(array('error' => 'not_logged_in'));
756 961 exit();
@@ -780,9 +985,9 @@
780 985 'ppco' => 'checkout',
781 986 'env_setting' => $env_setting,
782 987 'event' => 'cancel_subscription_token_mismatch',
783 988 'user_id' => $user_id,
784 - 'token' => $token,
989 + 'token' => $log_token,
785 990 ));
786 991
787 992 echo wp_json_encode(array('error' => 'token_mismatch'));
788 993 exit();
@@ -808,47 +1013,37 @@
808 1013 $reason = sanitize_text_field($reason);
809 1014 if(!$reason)
810 1015 $reason = 'Cancelled by subscriber.';
811 1016
812 - //260517 Get PayPal Checkout subscription details before cancelling locally.
813 - $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details($subscr_id);
814 - $subscription_status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
815 - $next_billing_time = !empty($subscription['billing_info']['next_billing_time']) ? (string)$subscription['billing_info']['next_billing_time'] : '';
816 - $next_billing_ts = ($next_billing_time) ? strtotime($next_billing_time) : 0;
1017 + //260819.0417 Resolve the active subscription through whichever configured PayPal API family owns it.
1018 + $ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id);
1019 + $ipn_signup_vars = (is_array($ipn_signup_vars) && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id) ? $ipn_signup_vars : array();
817 1020
818 - if(!empty($subscription['__error']) || empty($subscription['id']) || (string)$subscription['id'] !== (string)$subscr_id || $subscription_status !== 'ACTIVE' || !$next_billing_ts || $next_billing_ts <= time())
819 - {
820 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
821 - 'ppco' => 'checkout',
822 - 'env_setting'=> $env_setting,
823 - 'event' => 'cancel_subscription_details_unusable',
824 - 'user_id' => $user_id,
825 - 'subscr_id' => $subscr_id,
826 - 'status' => $subscription_status,
827 - 'next' => $next_billing_time,
828 - 'code' => !empty($subscription['__code']) ? (int)$subscription['__code'] : 0,
829 - ));
1021 + $next_billing_time = '';
1022 + $eot = c_ws_plugin__s2member_utils_users::get_user_eot($user_id, TRUE, 'next');
1023 + if(is_array($eot) && !empty($eot['type']) && $eot['type'] === 'next' && !empty($eot['time']) && (int)$eot['time'] > time())
1024 + $next_billing_time = gmdate('Y-m-d\TH:i:s\Z', (int)$eot['time']);
830 1025
831 - echo wp_json_encode(array('error' => 'subscription_details_unusable'));
832 - exit();
833 - }
1026 + $cancelled = c_ws_plugin__s2member_utilities::cancel_gateway_subscription(
1027 + 'paypal',
1028 + $subscr_id,
1029 + (string)get_user_option('s2member_subscr_baid', $user_id),
1030 + (string)get_user_option('s2member_subscr_cid', $user_id),
1031 + $ipn_signup_vars,
1032 + TRUE,
1033 + $reason
1034 + );
834 1035
835 - $r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_cancel($subscr_id, $reason);
836 -
837 - $code = !empty($r['code']) ? (int)$r['code'] : 0;
838 - $body = !empty($r['body']) ? (string)$r['body'] : '';
839 -
840 1036 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
841 - 'ppco' => 'checkout',
1037 + 'ppco' => 'checkout',
842 1038 'env_setting' => $env_setting,
843 - 'event' => 'cancel_subscription_response',
844 - 'user_id' => $user_id,
845 - 'subscr_id'=> $subscr_id,
846 - 'code' => $code,
847 - 'body' => $body,
1039 + 'event' => 'cancel_subscription_response',
1040 + 'user_id' => $user_id,
1041 + 'subscr_id' => $subscr_id,
1042 + 'accepted' => $cancelled ? 1 : 0,
848 1043 ));
849 1044
850 - if($code === 204 || ($code >= 200 && $code <= 299))
1045 + if($cancelled)
851 1046 {
852 1047 // Immediately feed s2Member's existing cancel handler (webhooks may be missing in MVP sites).
853 1048 $paypal = array(
854 1049 'txn_type' => 'subscr_cancel',
@@ -871,10 +1066,9 @@
871 1066 'payer_email' => (string)wp_get_current_user()->user_email,
872 1067 );
873 1068
874 1069 //260517 Enrich with stored signup vars so legacy cancel handler can match and compute EOT.
875 - if(($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id)) && is_array($ipn_signup_vars)
876 - && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id)
1070 + if($ipn_signup_vars)
877 1071 {
878 1072 if(!empty($ipn_signup_vars['item_number']))
879 1073 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
880 1074
@@ -932,9 +1126,9 @@
932 1126 'ppco' => 'checkout',
933 1127 'env_setting' => $env_setting,
934 1128 'event' => 'create_order_response',
935 1129 'order' => $order,
936 - 'token' => $token,
1130 + 'token' => $log_token,
937 1131 ));
938 1132
939 1133 if(empty($order['id']))
940 1134 {
@@ -942,17 +1136,52 @@
942 1136 'ppco' => 'checkout',
943 1137 'env_setting' => $env_setting,
944 1138 'event' => 'order_create_failed',
945 1139 'order' => $order,
946 - 'token' => $token,
1140 + 'token' => $log_token,
947 1141 ));
948 1142
949 - 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')));
950 1147 exit();
951 1148 }
952 1149 echo wp_json_encode(array('order_id' => $order['id']));
953 1150 exit();
954 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 + }
955 1184 else if($op === 'capture_order')
956 1185 {
957 1186 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
958 1187
@@ -960,8 +1189,23 @@
960 1189 {
961 1190 echo wp_json_encode(array('error' => 'missing_order_id'));
962 1191 exit();
963 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 +
964 1208 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
965 1209
966 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();
967 1211 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
@@ -974,11 +1218,38 @@
974 1218 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
975 1219 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
976 1220 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
977 1221 'capture' => $capture,
978 - 'token' => $token,
1222 + 'token' => $log_token,
979 1223 ));
980 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 +
1246 + if(!empty($capture['__error']))
1247 + {
1248 + echo wp_json_encode(array('error' => (string)$capture['__error']));
1249 + exit();
1250 + }
1251 +
981 1252 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
982 1253 {
983 1254 echo wp_json_encode(array('error' => 'order_capture_failed'));
984 1255 exit();
@@ -1002,9 +1273,9 @@
1002 1273 'env_setting' => $env_setting,
1003 1274 'event' => 'capture_missing_fields',
1004 1275 'order_id' => $order_id,
1005 1276 'capture' => $capture,
1006 - 'token' => $token,
1277 + 'token' => $log_token,
1007 1278 ));
1008 1279
1009 1280 echo wp_json_encode(array('error' => 'capture_missing_fields'));
1010 1281 exit();
@@ -1018,9 +1289,9 @@
1018 1289 'ppco' => 'checkout',
1019 1290 'env_setting' => $env_setting,
1020 1291 'event' => 'amount_mismatch',
1021 1292 'order_id' => $order_id,
1022 - 'token' => $token,
1293 + 'token' => $log_token,
1023 1294 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1024 1295 ));
1025 1296 echo wp_json_encode(array('error' => 'amount_mismatch'));
1026 1297 exit();
@@ -1031,9 +1302,9 @@
1031 1302 'ppco' => 'checkout',
1032 1303 'env_setting' => $env_setting,
1033 1304 'event' => 'currency_mismatch',
1034 1305 'order_id' => $order_id,
1035 - 'token' => $token,
1306 + 'token' => $log_token,
1036 1307 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1037 1308 ));
1038 1309 echo wp_json_encode(array('error' => 'currency_mismatch'));
1039 1310 exit();
@@ -1050,9 +1321,9 @@
1050 1321 'ppco' => 'checkout',
1051 1322 'env_setting' => $env_setting,
1052 1323 'event' => 'invoice_mismatch',
1053 1324 'order_id' => $order_id,
1054 - 'token' => $token,
1325 + 'token' => $log_token,
1055 1326 'invoice' => $cap_invoice_id,
1056 1327 ));
1057 1328 echo wp_json_encode(array('error' => 'invoice_mismatch'));
1058 1329 exit();
@@ -1070,9 +1341,9 @@
1070 1341 'ppco' => 'checkout',
1071 1342 'env_setting' => $env_setting,
1072 1343 'event' => 'custom_mismatch',
1073 1344 'order_id' => $order_id,
1074 - 'token' => $token,
1345 + 'token' => $log_token,
1075 1346 'custom' => array(
1076 1347 'token' => !empty($token['custom']) ? $token['custom'] : '',
1077 1348 'paypal' => $cap_custom_id,
1078 1349 ),
@@ -1109,74 +1380,54 @@
1109 1380 'option_name2' => (string)$token['on1'],
1110 1381 'option_selection2' => (string)$token['os1'],
1111 1382 );
1112 1383
1113 - // Idempotency: prevent double-processing of the same PayPal capture ID.
1114 - $ppco_dup_processed = false;
1115 - if($pu_cap_id)
1116 - {
1117 - $transient_ppco_capture = 's2m_ppco_'.md5('s2member_transient_ppco_capture_'.$pu_cap_id);
1118 - $ppco_dup_processed = (bool)get_transient($transient_ppco_capture);
1384 + //260827.0051 Keep AJAX capture fulfillment aligned with the redirect capture path so Pro-Form tax, email/coupon routing, and resolved success URLs survive the shared Framework handler.
1385 + if(isset($token['tax']))
1386 + $paypal['tax'] = (string)$token['tax'];
1119 1387
1120 - if(!$ppco_dup_processed)
1388 + $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
1389 + $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
1390 + $can_cancel_old_subscr = (!$is_independent_ccaps_sale && !$is_specific_post_page_sale); //260407 Only membership replacement-style PPCO purchases should cancel an existing recurring subscription here.
1391 +
1392 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
1393 + $notify_extra = array();
1394 +
1395 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
1396 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
1397 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1398 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
1399 +
1400 + $notify_done_option = 's2m_ppco_capture_done_'.md5($pu_cap_id);
1401 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $notify_done_option, $proxy_use, $notify_extra);
1402 +
1403 + if(empty($notify_result['ok']))
1121 1404 {
1122 - //260404 Keep PayPal Checkout dedupe/fallback transients below 30 days for object-cache compatibility.
1123 - set_transient($transient_ppco_capture, time(), DAY_IN_SECONDS);
1124 -
1125 1405 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1126 - 'ppco' => 'checkout',
1406 + 'ppco' => 'checkout',
1127 1407 'env_setting' => $env_setting,
1128 - 'event' => 'idempotency_capture_set',
1129 - 'order_id' => $order_id,
1130 - 'txn_id' => $pu_cap_id,
1131 - 'transient' => $transient_ppco_capture,
1132 - 'expires_secs' => DAY_IN_SECONDS,
1408 + 'event' => 'notify_proxy_failed',
1409 + 'order_id' => $order_id,
1410 + 'txn_id' => $pu_cap_id,
1411 + 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1412 + 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1413 + 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1133 1414 ));
1415 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
1416 + exit();
1134 1417 }
1135 - else
1136 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1137 - 'ppco' => 'checkout',
1138 - 'env_setting' => $env_setting,
1139 - 'event' => 'duplicate_capture_ignored',
1140 - 'order_id' => $order_id,
1141 - 'txn_id' => $pu_cap_id,
1142 - ));
1143 - }
1144 1418
1145 - if(!$ppco_dup_processed)
1146 - {
1147 - $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
1148 - $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
1149 - $can_cancel_old_subscr = (!$is_independent_ccaps_sale && !$is_specific_post_page_sale); //260407 Only membership replacement-style PPCO purchases should cancel an existing recurring subscription here.
1150 -
1151 - // 1) Fire the existing IPN handler via proxy (provisions access, emails, logs, etc).
1152 - $notify_url = home_url('/?s2member_paypal_notify=1');
1153 - $notify_post = array_merge($paypal, array(
1154 - 's2member_paypal_proxy' => 'paypal',
1155 - 's2member_paypal_proxy_use' => 'paypal_checkout',
1156 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
1157 - ));
1158 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
1159 -
1160 - if(!is_array($notify_r))
1161 - $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
1162 -
1163 - $notify_code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
1164 - $notify_msg = !empty($notify_r['message']) ? (string)$notify_r['message'] : '';
1165 - $notify_body = !empty($notify_r['body']) ? $notify_r['body'] : '';
1166 -
1167 - if($notify_code >= 200 && $notify_code <= 299)
1419 + if(!empty($notify_result['processed']))
1168 1420 {
1169 1421 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1170 - 'ppco' => 'checkout',
1422 + 'ppco' => 'checkout',
1171 1423 'env_setting' => $env_setting,
1172 - 'event' => 'notify_proxy_response',
1173 - 'order_id' => $order_id,
1174 - 'txn_id' => $pu_cap_id,
1175 - 'url' => $notify_url,
1176 - 'code' => $notify_code,
1177 - 'message' => $notify_msg,
1178 - 'body' => $notify_body,
1424 + 'event' => 'notify_proxy_response',
1425 + 'order_id' => $order_id,
1426 + 'txn_id' => $pu_cap_id,
1427 + 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1428 + 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1429 + 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1179 1430 ));
1180 1431
1181 1432 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1182 1433 if($can_cancel_old_subscr && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $pu_cap_id), get_defined_vars())) //260406
@@ -1181,25 +1432,8 @@
1181 1432 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1182 1433 if($can_cancel_old_subscr && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $pu_cap_id), get_defined_vars())) //260406
1183 1434 c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars); //260407
1184 1435 }
1185 - else
1186 - {
1187 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1188 - 'ppco' => 'checkout',
1189 - 'env_setting' => $env_setting,
1190 - 'event' => 'notify_proxy_failed',
1191 - 'order_id' => $order_id,
1192 - 'txn_id' => $pu_cap_id,
1193 - 'url' => $notify_url,
1194 - 'code' => $notify_code,
1195 - 'message' => $notify_msg,
1196 - 'body' => $notify_body,
1197 - ));
1198 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
1199 - exit();
1200 - }
1201 - }
1202 1436
1203 1437 // 2) Send the user through the existing Return handler via POST (sets cookies, thank-you UX, reg tokens, etc).
1204 1438 $return_url = (string)$token['return'];
1205 1439 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -1204,12 +1438,27 @@
1204 1438 $return_url = (string)$token['return'];
1205 1439 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
1206 1440
1207 1441 $return_post = array_merge($paypal, array(
1208 - 's2member_paypal_proxy' => 'paypal',
1209 - 's2member_paypal_proxy_use' => 'paypal_checkout',
1210 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
1442 + 's2member_paypal_proxy' => 'paypal',
1443 + 's2member_paypal_proxy_use' => $proxy_use,
1211 1444 ));
1445 +
1446 + //260827.0051 Carry the Pro-Form's resolved success URL inside the signed browser return; Specific Post/Page uses the Notify response body for its generated access URL.
1447 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1448 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
1449 +
1450 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
1451 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
1452 + if(!$return_handoff)
1453 + {
1454 + if(!headers_sent())
1455 + status_header(500);
1456 +
1457 + echo wp_json_encode(array('error' => 'return_handoff_failed'));
1458 + exit();
1459 + }
1460 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
1212 1461
1213 1462 echo wp_json_encode(array(
1214 1463 'rtn_url' => $return_url,
1215 1464 'rtn_post' => $return_post,