PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260927
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260927
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 +375 -244 260805 → 260927 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,
@@ -65,11 +65,9 @@
65 65 exit();
66 66 }
67 67 $raw = c_ws_plugin__s2member_utils_encryption::decrypt($t);
68 68
69 - //260204 Use the plugin's hardened unserialize routine:
70 - // - PHP 7+: allowed_classes => false
71 - // - PHP <7: blocks object payloads before calling unserialize()
69 + //260808 Safely unserialize the PayPal checkout token.
72 70 $token = c_ws_plugin__s2member_utils_arrays::maybe_unserialize($raw);
73 71
74 72 if(!is_array($token))
75 73 $token = false;
@@ -223,8 +221,14 @@
223 221 'capture' => $capture,
224 222 'token' => $token,
225 223 ));
226 224
225 + if(!empty($capture['__error']))
226 + {
227 + echo (string)$capture['__error'];
228 + exit();
229 + }
230 +
227 231 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
228 232 {
229 233 echo 'order_capture_failed';
230 234 exit();
@@ -229,11 +233,13 @@
229 233 echo 'order_capture_failed';
230 234 exit();
231 235 }
232 236
233 - $payer_email = !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '';
234 - $first_name = !empty($capture['payer']['name']['given_name']) ? (string)$capture['payer']['name']['given_name'] : '';
235 - $last_name = !empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '';
237 + //260818.0126 Keep submitted Pro-Form contact details for pro-emails; they may differ from the payer's PayPal profile.
238 + $is_pro_form = (!empty($token['s2member_paypal_proxy_use']) && (string)$token['s2member_paypal_proxy_use'] === 'pro-emails');
239 + $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 + $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'] : '');
241 + $last_name = ($is_pro_form && isset($token['last_name'])) ? (string)$token['last_name'] : (!empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '');
236 242
237 243 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
238 244 $pu_cc = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code'] : '';
239 245 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
@@ -274,36 +280,44 @@
274 280 'first_name' => $first_name,
275 281 'last_name' => $last_name,
276 282 );
277 283
284 + //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.
285 + if(isset($token['tax']))
286 + $paypal['tax'] = (string)$token['tax'];
287 +
278 288 $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
279 289 $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
280 290 $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.
281 291
282 - $notify_url = home_url('/?s2member_paypal_notify=1');
283 - $notify_post = array_merge($paypal, array(
284 - 's2member_paypal_proxy' => 'paypal',
285 - 's2member_paypal_proxy_use' => 'paypal_checkout',
286 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
287 - ));
288 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
292 + //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.
293 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
294 + $notify_extra = array();
289 295
290 - if(!is_array($notify_r))
296 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
297 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
298 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
299 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
300 +
301 + $notify_done_option = 's2m_ppco_capture_done_'.md5($pu_cap_id);
302 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $notify_done_option, $proxy_use, $notify_extra);
303 +
304 + if(empty($notify_result['ok']))
291 305 {
292 306 if($is_redirect_mode)
293 - echo 'notify_proxy_failed';
307 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
294 308 else
295 309 {
296 310 if(!headers_sent())
297 311 status_header(500);
298 312
299 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
313 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
300 314 }
301 315 exit();
302 316 }
303 317
304 - //260407 Framework PPCO replacements need the same old-subscription cancellation behavior without affecting independent CCAPS or specific post/page purchases.
305 - 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
318 + //260817 Only the request that actually performed fulfillment should trigger replacement-subscription cancellation.
319 + 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
306 320 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
307 321
308 322 $return_url = (string)$token['return'];
309 323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -308,16 +322,28 @@
308 322 $return_url = (string)$token['return'];
309 323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
310 324
311 325 $return_post = array_merge($paypal, array(
312 - 's2member_paypal_proxy' => 'paypal',
313 - 's2member_paypal_proxy_use' => 'paypal_checkout',
314 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
326 + 's2member_paypal_proxy' => 'paypal',
327 + 's2member_paypal_proxy_use' => $proxy_use,
315 328 ));
316 329
330 + //260817 Carry the already-resolved Pro-Form success URL inside the signed browser-return package.
331 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
332 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
333 +
334 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
335 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
336 + if(!$return_handoff)
337 + {
338 + echo 'return_handoff_failed';
339 + exit();
340 + }
341 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
342 +
317 343 // Auto-POST into s2Member's existing PayPal return handler.
318 344 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
319 - echo '<form id="s2m_ppco_rtn" method="post" action="'.esc_attr($return_url).'">';
345 + 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.
320 346 foreach($return_post as $k => $v)
321 347 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
322 348 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
323 349 exit();
@@ -404,61 +430,42 @@
404 430 'option_name2' => (string)$token['on1'],
405 431 'option_selection2' => (string)$token['os1'],
406 432 );
407 433
408 - //260406 Use the shared PayPal Checkout subscription-done option so checkout and webhooks agree on fallback suppression.
409 434 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
410 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
411 435
412 - if($option_ppco_subscr_time > 0 && (time() - $option_ppco_subscr_time) >= DAY_IN_SECONDS)
436 + //260818.0603 Share the success-only Notify lock/done marker with browser confirmation and webhook activation fallback.
437 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $option_ppco_subscr);
438 +
439 + if(empty($notify_result['ok']))
413 440 {
414 - delete_option($option_ppco_subscr);
415 - $option_ppco_subscr_time = 0;
441 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
442 + exit();
416 443 }
417 444
418 - if(!$option_ppco_subscr_time)
419 - {
420 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
421 - update_option($option_ppco_subscr, time(), false);
445 + //260818.0603 Only the request that completed Notify should cancel a replaced subscription; duplicates are already fulfilled.
446 + 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()))
447 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars);
422 448
423 - $notify_url = home_url('/?s2member_paypal_notify=1');
424 - $notify_post = array_merge($paypal, array(
425 - 's2member_paypal_proxy' => 'paypal',
426 - 's2member_paypal_proxy_use' => 'paypal_checkout',
427 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
428 - ));
429 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
430 -
431 - if(!is_array($notify_r))
432 - {
433 - if($is_redirect_mode)
434 - echo 'notify_proxy_failed';
435 - else
436 - {
437 - if(!headers_sent())
438 - status_header(500);
439 -
440 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
441 - }
442 - exit();
443 - }
444 -
445 - //260407 Framework PPCO replacements can also replace subscriptions created by other gateways
446 - if($old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars())) //260406.
447 - 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
448 - }
449 -
450 449 $return_url2 = (string)$token['return'];
451 450 $return_url2 = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url2);
452 451
453 452 $return_post2 = array_merge($paypal, array(
454 - 's2member_paypal_proxy' => 'paypal',
455 - 's2member_paypal_proxy_use' => 'paypal_checkout',
456 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
453 + 's2member_paypal_proxy' => 'paypal',
454 + 's2member_paypal_proxy_use' => 'paypal_checkout',
457 455 ));
458 456
457 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
458 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post2);
459 + if(!$return_handoff)
460 + {
461 + echo 'return_handoff_failed';
462 + exit();
463 + }
464 + $return_post2['s2member_paypal_checkout_handoff'] = $return_handoff;
465 +
459 466 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
460 - echo '<form id="s2m_ppco_rtn" method="post" action="'.esc_attr($return_url2).'">';
467 + 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.
461 468 foreach($return_post2 as $k => $v)
462 469 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
463 470 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
464 471 exit();
@@ -464,8 +471,66 @@
464 471 exit();
465 472 }
466 473 }
467 474
475 + if($op === 'create_subscription')
476 + {
477 + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
478 + {
479 + echo wp_json_encode(array('error' => 'not_subscription'));
480 + exit();
481 + }
482 +
483 + $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_create($token);
484 +
485 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
486 + 'ppco' => 'checkout',
487 + 'env_setting' => $env_setting,
488 + 'event' => 'create_subscription_response',
489 + 'subscription' => $subscription,
490 + 'token' => $token,
491 + ));
492 +
493 + if(empty($subscription['id']))
494 + {
495 + $error = !empty($subscription['__error']) ? (string)$subscription['__error'] : 'subscription_create_failed';
496 + $recoverable = in_array($error, array('subscription_create_unresolved', 'gateway_checkout_busy'), TRUE);
497 + //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.
498 + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable));
499 + exit();
500 + }
501 +
502 + //260901.2145 The browser receives only the already-persisted PayPal subscription ID; PayPal's JS SDK handles buyer approval from that server-created resource.
503 + echo wp_json_encode(array('subscription_id' => (string)$subscription['id']));
504 + exit();
505 + }
506 +
507 + if($op === 'get_subscription_id')
508 + {
509 + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
510 + {
511 + echo wp_json_encode(array('error' => 'not_subscription'));
512 + exit();
513 + }
514 +
515 + $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'] : '';
516 + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id) : FALSE;
517 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
518 + {
519 + echo wp_json_encode(array('error' => 'gateway_checkout_invalid'));
520 + exit();
521 + }
522 +
523 + $subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
524 + //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.
525 + echo wp_json_encode(array(
526 + 'subscription_id' => $subscription_id,
527 + 'pending' => !$subscription_id,
528 + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '',
529 + ));
530 + exit();
531 + }
532 +
468 533 if($op === 'get_plan_id')
469 534 {
470 535 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
471 536 {
@@ -506,8 +571,22 @@
506 571 {
507 572 echo wp_json_encode(array('error' => 'missing_subscription_id'));
508 573 exit();
509 574 }
575 +
576 + $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'] : '';
577 + if($gateway_checkout_id)
578 + {
579 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
580 + $expected_subscription_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
581 + //260901.2145 A coordinator-backed browser may confirm only the PayPal subscription that s2Member created and persisted for this logical checkout.
582 + if(!$expected_subscription_id || !hash_equals($expected_subscription_id, $subscription_id))
583 + {
584 + echo wp_json_encode(array('error' => 'gateway_checkout_subscription_mismatch'));
585 + exit();
586 + }
587 + }
588 +
510 589 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
511 590
512 591 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
513 592 'ppco' => 'checkout',
@@ -561,13 +640,13 @@
561 640 if($lpv !== '' && $lpc !== '')
562 641 $allow_expired_single_cycle = true;
563 642 }
564 643
565 - if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle)
644 + if(!$status)
566 645 {
567 646 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
568 647 'ppco' => 'checkout',
569 - 'env_setting' => $env_setting,
648 + 'env_setting' => $env_setting,
570 649 'event' => 'subscription_status_invalid',
571 650 'subscription_id' => $subscription_id,
572 651 'status' => $status,
573 652 ));
@@ -603,8 +682,48 @@
603 682 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
604 683 exit();
605 684 }
606 685
686 + if($gateway_checkout_id)
687 + {
688 + if(in_array($status, array('APPROVAL_PENDING', 'APPROVED'), TRUE))
689 + {
690 + //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.
691 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status));
692 + echo wp_json_encode(array('pending_activation' => TRUE, 'subscription_id' => $subscription_id, 'status' => $status));
693 + exit();
694 + }
695 + if($status !== 'ACTIVE' && !$allow_expired_single_cycle)
696 + {
697 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
698 + 'ppco' => 'checkout',
699 + 'env_setting' => $env_setting,
700 + 'event' => 'subscription_status_invalid',
701 + 'subscription_id' => $subscription_id,
702 + 'status' => $status,
703 + ));
704 +
705 + echo wp_json_encode(array('error' => 'subscription_status_invalid'));
706 + exit();
707 + }
708 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status));
709 + }
710 + else if(!in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), TRUE) && !$allow_expired_single_cycle)
711 + {
712 + //260902.0200 Preserve existing non-coordinator PayPal Checkout button behavior until those flows migrate onto Gateway Checkout and gain the same activation polling.
713 + //260907.2142 TO-DO: Migrate maintained Framework PayPal Checkout button/redirect flows onto Gateway Checkout before claiming cross-surface PPCO dedupe/idempotency parity, preserving the Pro-Form guarantees for durable provider identity, stable idempotent retries, monotonic final-state recovery, and shared browser/webhook fulfillment dedupe.
714 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
715 + 'ppco' => 'checkout',
716 + 'env_setting' => $env_setting,
717 + 'event' => 'subscription_status_invalid',
718 + 'subscription_id' => $subscription_id,
719 + 'status' => $status,
720 + ));
721 +
722 + echo wp_json_encode(array('error' => 'subscription_status_invalid'));
723 + exit();
724 + }
725 +
607 726 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
608 727 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
609 728 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
610 729
@@ -642,90 +761,56 @@
642 761 'option_name2' => (string)$token['on1'],
643 762 'option_selection2' => (string)$token['os1'],
644 763 );
645 764
646 - $ppco_dup_processed = false;
647 765 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
648 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
649 766
650 - if($option_ppco_subscr_time > 0 && (time() - $option_ppco_subscr_time) >= DAY_IN_SECONDS)
767 + //260818.0603 Mark the Subscription done only after Notify succeeds, using the same lock as webhook activation fallback.
768 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $option_ppco_subscr);
769 + $notify_code = !empty($notify_result['code']) ? (int)$notify_result['code'] : 0;
770 + $notify_msg = !empty($notify_result['message']) ? (string)$notify_result['message'] : '';
771 + $notify_body = !empty($notify_result['body']) ? (string)$notify_result['body'] : '';
772 +
773 + if(empty($notify_result['ok']))
651 774 {
652 - delete_option($option_ppco_subscr);
653 - $option_ppco_subscr_time = 0;
775 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
776 + 'ppco' => 'checkout',
777 + 'env_setting' => $env_setting,
778 + 'event' => 'notify_proxy_failed',
779 + 'subscription_id' => $subscription_id,
780 + 'code' => $notify_code,
781 + 'message' => $notify_msg,
782 + 'body' => $notify_body,
783 + 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed',
784 + ));
785 +
786 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
787 + exit();
654 788 }
655 789
656 - $ppco_dup_processed = ($option_ppco_subscr_time > 0);
657 -
658 - if($ppco_dup_processed)
790 + if(!empty($notify_result['duplicate']))
659 791 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
660 792 'ppco' => 'checkout',
661 - 'env_setting' => $env_setting,
793 + 'env_setting' => $env_setting,
662 794 'event' => 'duplicate_subscription_ignored',
663 795 'subscription_id' => $subscription_id,
664 796 'option' => $option_ppco_subscr,
665 797 ));
666 -
667 - if(!$ppco_dup_processed)
798 + else
668 799 {
669 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
670 - update_option($option_ppco_subscr, time(), false);
671 -
672 800 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
673 801 'ppco' => 'checkout',
674 - 'env_setting' => $env_setting,
675 - 'event' => 'idempotency_subscription_set',
802 + 'env_setting' => $env_setting,
803 + 'event' => 'notify_proxy_response',
676 804 'subscription_id' => $subscription_id,
677 - 'option' => $option_ppco_subscr,
678 - 'expires_secs' => DAY_IN_SECONDS,
805 + 'code' => $notify_code,
806 + 'message' => $notify_msg,
807 + 'body' => $notify_body,
679 808 ));
680 809
681 - $notify_url = home_url('/?s2member_paypal_notify=1');
682 - $notify_post = array_merge($paypal, array(
683 - 's2member_paypal_proxy' => 'paypal',
684 - 's2member_paypal_proxy_use' => 'paypal_checkout',
685 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
686 - ));
687 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
688 -
689 - if(!is_array($notify_r))
690 - $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
691 -
692 - $notify_code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
693 - $notify_msg = !empty($notify_r['message']) ? (string)$notify_r['message'] : '';
694 - $notify_body = !empty($notify_r['body']) ? $notify_r['body'] : '';
695 -
696 - if($notify_code >= 200 && $notify_code <= 299)
697 - {
698 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
699 - 'ppco' => 'checkout',
700 - 'env_setting' => $env_setting,
701 - 'event' => 'notify_proxy_response',
702 - 'subscription_id' => $subscription_id,
703 - 'url' => $notify_url,
704 - 'code' => $notify_code,
705 - 'message' => $notify_msg,
706 - 'body' => $notify_body,
707 - ));
708 -
709 - //260407 Framework PPCO AJAX replacements need the same gateway-aware old-subscription cancellation behavior.
710 - if($old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars())) //260406
711 - 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
712 - }
713 - else
714 - {
715 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
716 - 'ppco' => 'checkout',
717 - 'env_setting' => $env_setting,
718 - 'event' => 'notify_proxy_failed',
719 - 'subscription_id' => $subscription_id,
720 - 'url' => $notify_url,
721 - 'code' => $notify_code,
722 - 'message' => $notify_msg,
723 - 'body' => $notify_body,
724 - ));
725 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
726 - exit();
727 - }
810 + //260818.0603 Only successful first-pass fulfillment should trigger replacement-subscription cancellation.
811 + 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()))
812 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars);
728 813 }
729 814
730 815 $return_url = (string)$token['return'];
731 816 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -730,13 +815,24 @@
730 815 $return_url = (string)$token['return'];
731 816 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
732 817
733 818 $return_post = array_merge($paypal, array(
734 - 's2member_paypal_proxy' => 'paypal',
735 - 's2member_paypal_proxy_use' => 'paypal_checkout',
736 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
819 + 's2member_paypal_proxy' => 'paypal',
820 + 's2member_paypal_proxy_use' => 'paypal_checkout',
737 821 ));
738 822
823 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
824 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
825 + if(!$return_handoff)
826 + {
827 + if(!headers_sent())
828 + status_header(500);
829 +
830 + echo wp_json_encode(array('error' => 'return_handoff_failed'));
831 + exit();
832 + }
833 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
834 +
739 835 echo wp_json_encode(array(
740 836 'rtn_url' => $return_url,
741 837 'rtn_post' => $return_post,
742 838 ));
@@ -810,47 +906,37 @@
810 906 $reason = sanitize_text_field($reason);
811 907 if(!$reason)
812 908 $reason = 'Cancelled by subscriber.';
813 909
814 - //260517 Get PayPal Checkout subscription details before cancelling locally.
815 - $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details($subscr_id);
816 - $subscription_status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
817 - $next_billing_time = !empty($subscription['billing_info']['next_billing_time']) ? (string)$subscription['billing_info']['next_billing_time'] : '';
818 - $next_billing_ts = ($next_billing_time) ? strtotime($next_billing_time) : 0;
910 + //260819.0417 Resolve the active subscription through whichever configured PayPal API family owns it.
911 + $ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id);
912 + $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();
819 913
820 - if(!empty($subscription['__error']) || empty($subscription['id']) || (string)$subscription['id'] !== (string)$subscr_id || $subscription_status !== 'ACTIVE' || !$next_billing_ts || $next_billing_ts <= time())
821 - {
822 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
823 - 'ppco' => 'checkout',
824 - 'env_setting'=> $env_setting,
825 - 'event' => 'cancel_subscription_details_unusable',
826 - 'user_id' => $user_id,
827 - 'subscr_id' => $subscr_id,
828 - 'status' => $subscription_status,
829 - 'next' => $next_billing_time,
830 - 'code' => !empty($subscription['__code']) ? (int)$subscription['__code'] : 0,
831 - ));
914 + $next_billing_time = '';
915 + $eot = c_ws_plugin__s2member_utils_users::get_user_eot($user_id, TRUE, 'next');
916 + if(is_array($eot) && !empty($eot['type']) && $eot['type'] === 'next' && !empty($eot['time']) && (int)$eot['time'] > time())
917 + $next_billing_time = gmdate('Y-m-d\TH:i:s\Z', (int)$eot['time']);
832 918
833 - echo wp_json_encode(array('error' => 'subscription_details_unusable'));
834 - exit();
835 - }
919 + $cancelled = c_ws_plugin__s2member_utilities::cancel_gateway_subscription(
920 + 'paypal',
921 + $subscr_id,
922 + (string)get_user_option('s2member_subscr_baid', $user_id),
923 + (string)get_user_option('s2member_subscr_cid', $user_id),
924 + $ipn_signup_vars,
925 + TRUE,
926 + $reason
927 + );
836 928
837 - $r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_cancel($subscr_id, $reason);
838 -
839 - $code = !empty($r['code']) ? (int)$r['code'] : 0;
840 - $body = !empty($r['body']) ? (string)$r['body'] : '';
841 -
842 929 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
843 - 'ppco' => 'checkout',
930 + 'ppco' => 'checkout',
844 931 'env_setting' => $env_setting,
845 - 'event' => 'cancel_subscription_response',
846 - 'user_id' => $user_id,
847 - 'subscr_id'=> $subscr_id,
848 - 'code' => $code,
849 - 'body' => $body,
932 + 'event' => 'cancel_subscription_response',
933 + 'user_id' => $user_id,
934 + 'subscr_id' => $subscr_id,
935 + 'accepted' => $cancelled ? 1 : 0,
850 936 ));
851 937
852 - if($code === 204 || ($code >= 200 && $code <= 299))
938 + if($cancelled)
853 939 {
854 940 // Immediately feed s2Member's existing cancel handler (webhooks may be missing in MVP sites).
855 941 $paypal = array(
856 942 'txn_type' => 'subscr_cancel',
@@ -873,10 +959,9 @@
873 959 'payer_email' => (string)wp_get_current_user()->user_email,
874 960 );
875 961
876 962 //260517 Enrich with stored signup vars so legacy cancel handler can match and compute EOT.
877 - if(($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id)) && is_array($ipn_signup_vars)
878 - && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id)
963 + if($ipn_signup_vars)
879 964 {
880 965 if(!empty($ipn_signup_vars['item_number']))
881 966 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
882 967
@@ -947,14 +1032,40 @@
947 1032 'order' => $order,
948 1033 'token' => $token,
949 1034 ));
950 1035
951 - echo wp_json_encode(array('error' => 'order_create_failed'));
1036 + $error = !empty($order['__error']) ? (string)$order['__error'] : 'order_create_failed';
1037 + $recoverable = ($error === 'gateway_checkout_busy');
1038 + //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.
1039 + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'retryable' => ($error === 'order_create_unresolved')));
952 1040 exit();
953 1041 }
954 1042 echo wp_json_encode(array('order_id' => $order['id']));
955 1043 exit();
956 1044 }
1045 + else if($op === 'get_order_status')
1046 + {
1047 + //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.
1048 + $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'] : '';
1049 + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id) : FALSE;
1050 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1051 + {
1052 + echo wp_json_encode(array('error' => 'gateway_checkout_invalid'));
1053 + exit();
1054 + }
1055 +
1056 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
1057 + $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();
1058 + //260902.0635 Poll only local coordinator state while independent PayPal webhooks resolve delayed creates/captures; do not hammer the provider from the browser.
1059 + echo wp_json_encode(array(
1060 + 'order_id' => !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '',
1061 + 'capture_id' => !empty($gateway_checkout['gateway_ids']['capture_id']) ? (string)$gateway_checkout['gateway_ids']['capture_id'] : '',
1062 + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '',
1063 + 'fulfillment_status' => !empty($gateway_checkout['fulfillment_status']) ? (string)$gateway_checkout['fulfillment_status'] : '',
1064 + 'fulfilled' => ((string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result)),
1065 + ));
1066 + exit();
1067 + }
957 1068 else if($op === 'capture_order')
958 1069 {
959 1070 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
960 1071
@@ -962,8 +1073,23 @@
962 1073 {
963 1074 echo wp_json_encode(array('error' => 'missing_order_id'));
964 1075 exit();
965 1076 }
1077 +
1078 + $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'] : '';
1079 + if($gateway_checkout_id)
1080 + {
1081 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1082 + $private_context = $gateway_checkout ? c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id) : FALSE;
1083 + $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();
1084 + if($gateway_checkout && (string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result['rtn_url']) && !empty($fulfillment_result['rtn_post']))
1085 + {
1086 + //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.
1087 + echo wp_json_encode(array('rtn_url' => $fulfillment_result['rtn_url'], 'rtn_post' => $fulfillment_result['rtn_post']));
1088 + exit();
1089 + }
1090 + }
1091 +
966 1092 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
967 1093
968 1094 $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();
969 1095 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
@@ -979,8 +1105,35 @@
979 1105 'capture' => $capture,
980 1106 'token' => $token,
981 1107 ));
982 1108
1109 + if($gateway_checkout_id)
1110 + {
1111 + if(!empty($capture['__error']))
1112 + {
1113 + $error = (string)$capture['__error'];
1114 + $recoverable = in_array($error, array('capture_pending', 'order_capture_unresolved', 'gateway_checkout_busy'), TRUE);
1115 + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'pending' => ($error === 'capture_pending')));
1116 + exit();
1117 + }
1118 +
1119 + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($capture, $token);
1120 + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post']))
1121 + {
1122 + echo wp_json_encode(array('error' => !empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'order_fulfillment_failed'));
1123 + exit();
1124 + }
1125 +
1126 + echo wp_json_encode(array('rtn_url' => $fulfillment['rtn_url'], 'rtn_post' => $fulfillment['rtn_post']));
1127 + exit();
1128 + }
1129 +
1130 + if(!empty($capture['__error']))
1131 + {
1132 + echo wp_json_encode(array('error' => (string)$capture['__error']));
1133 + exit();
1134 + }
1135 +
983 1136 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
984 1137 {
985 1138 echo wp_json_encode(array('error' => 'order_capture_failed'));
986 1139 exit();
@@ -1111,74 +1264,54 @@
1111 1264 'option_name2' => (string)$token['on1'],
1112 1265 'option_selection2' => (string)$token['os1'],
1113 1266 );
1114 1267
1115 - // Idempotency: prevent double-processing of the same PayPal capture ID.
1116 - $ppco_dup_processed = false;
1117 - if($pu_cap_id)
1118 - {
1119 - $transient_ppco_capture = 's2m_ppco_'.md5('s2member_transient_ppco_capture_'.$pu_cap_id);
1120 - $ppco_dup_processed = (bool)get_transient($transient_ppco_capture);
1268 + //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.
1269 + if(isset($token['tax']))
1270 + $paypal['tax'] = (string)$token['tax'];
1121 1271
1122 - if(!$ppco_dup_processed)
1272 + $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
1273 + $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
1274 + $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.
1275 +
1276 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
1277 + $notify_extra = array();
1278 +
1279 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
1280 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
1281 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1282 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
1283 +
1284 + $notify_done_option = 's2m_ppco_capture_done_'.md5($pu_cap_id);
1285 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $notify_done_option, $proxy_use, $notify_extra);
1286 +
1287 + if(empty($notify_result['ok']))
1123 1288 {
1124 - //260404 Keep PayPal Checkout dedupe/fallback transients below 30 days for object-cache compatibility.
1125 - set_transient($transient_ppco_capture, time(), DAY_IN_SECONDS);
1126 -
1127 1289 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1128 - 'ppco' => 'checkout',
1290 + 'ppco' => 'checkout',
1129 1291 'env_setting' => $env_setting,
1130 - 'event' => 'idempotency_capture_set',
1131 - 'order_id' => $order_id,
1132 - 'txn_id' => $pu_cap_id,
1133 - 'transient' => $transient_ppco_capture,
1134 - 'expires_secs' => DAY_IN_SECONDS,
1292 + 'event' => 'notify_proxy_failed',
1293 + 'order_id' => $order_id,
1294 + 'txn_id' => $pu_cap_id,
1295 + 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1296 + 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1297 + 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1135 1298 ));
1299 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
1300 + exit();
1136 1301 }
1137 - else
1138 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1139 - 'ppco' => 'checkout',
1140 - 'env_setting' => $env_setting,
1141 - 'event' => 'duplicate_capture_ignored',
1142 - 'order_id' => $order_id,
1143 - 'txn_id' => $pu_cap_id,
1144 - ));
1145 - }
1146 1302
1147 - if(!$ppco_dup_processed)
1148 - {
1149 - $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
1150 - $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
1151 - $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.
1152 -
1153 - // 1) Fire the existing IPN handler via proxy (provisions access, emails, logs, etc).
1154 - $notify_url = home_url('/?s2member_paypal_notify=1');
1155 - $notify_post = array_merge($paypal, array(
1156 - 's2member_paypal_proxy' => 'paypal',
1157 - 's2member_paypal_proxy_use' => 'paypal_checkout',
1158 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
1159 - ));
1160 - $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
1161 -
1162 - if(!is_array($notify_r))
1163 - $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
1164 -
1165 - $notify_code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
1166 - $notify_msg = !empty($notify_r['message']) ? (string)$notify_r['message'] : '';
1167 - $notify_body = !empty($notify_r['body']) ? $notify_r['body'] : '';
1168 -
1169 - if($notify_code >= 200 && $notify_code <= 299)
1303 + if(!empty($notify_result['processed']))
1170 1304 {
1171 1305 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1172 - 'ppco' => 'checkout',
1306 + 'ppco' => 'checkout',
1173 1307 'env_setting' => $env_setting,
1174 - 'event' => 'notify_proxy_response',
1175 - 'order_id' => $order_id,
1176 - 'txn_id' => $pu_cap_id,
1177 - 'url' => $notify_url,
1178 - 'code' => $notify_code,
1179 - 'message' => $notify_msg,
1180 - 'body' => $notify_body,
1308 + 'event' => 'notify_proxy_response',
1309 + 'order_id' => $order_id,
1310 + 'txn_id' => $pu_cap_id,
1311 + 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1312 + 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1313 + 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1181 1314 ));
1182 1315
1183 1316 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1184 1317 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,25 +1316,8 @@
1183 1316 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1184 1317 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
1185 1318 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
1186 1319 }
1187 - else
1188 - {
1189 - c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1190 - 'ppco' => 'checkout',
1191 - 'env_setting' => $env_setting,
1192 - 'event' => 'notify_proxy_failed',
1193 - 'order_id' => $order_id,
1194 - 'txn_id' => $pu_cap_id,
1195 - 'url' => $notify_url,
1196 - 'code' => $notify_code,
1197 - 'message' => $notify_msg,
1198 - 'body' => $notify_body,
1199 - ));
1200 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
1201 - exit();
1202 - }
1203 - }
1204 1320
1205 1321 // 2) Send the user through the existing Return handler via POST (sets cookies, thank-you UX, reg tokens, etc).
1206 1322 $return_url = (string)$token['return'];
1207 1323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -1206,12 +1322,27 @@
1206 1322 $return_url = (string)$token['return'];
1207 1323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
1208 1324
1209 1325 $return_post = array_merge($paypal, array(
1210 - 's2member_paypal_proxy' => 'paypal',
1211 - 's2member_paypal_proxy_use' => 'paypal_checkout',
1212 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
1326 + 's2member_paypal_proxy' => 'paypal',
1327 + 's2member_paypal_proxy_use' => $proxy_use,
1213 1328 ));
1329 +
1330 + //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.
1331 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1332 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
1333 +
1334 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
1335 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
1336 + if(!$return_handoff)
1337 + {
1338 + if(!headers_sent())
1339 + status_header(500);
1340 +
1341 + echo wp_json_encode(array('error' => 'return_handoff_failed'));
1342 + exit();
1343 + }
1344 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
1214 1345
1215 1346 echo wp_json_encode(array(
1216 1347 'rtn_url' => $return_url,
1217 1348 'rtn_post' => $return_post,