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
261011 261001 260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 All 192 releases
← All changes | src/includes/classes/paypal-checkout-in.inc.php +374 -241 260814 → 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,
@@ -221,8 +221,14 @@
221 221 'capture' => $capture,
222 222 'token' => $token,
223 223 ));
224 224
225 + if(!empty($capture['__error']))
226 + {
227 + echo (string)$capture['__error'];
228 + exit();
229 + }
230 +
225 231 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
226 232 {
227 233 echo 'order_capture_failed';
228 234 exit();
@@ -227,11 +233,13 @@
227 233 echo 'order_capture_failed';
228 234 exit();
229 235 }
230 236
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'] : '';
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'] : '');
234 242
235 243 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
236 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'] : '';
237 245 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
@@ -272,36 +280,44 @@
272 280 'first_name' => $first_name,
273 281 'last_name' => $last_name,
274 282 );
275 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 +
276 288 $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
277 289 $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
278 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.
279 291
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);
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();
287 295
288 - 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']))
289 305 {
290 306 if($is_redirect_mode)
291 - echo 'notify_proxy_failed';
307 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
292 308 else
293 309 {
294 310 if(!headers_sent())
295 311 status_header(500);
296 312
297 - 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'));
298 314 }
299 315 exit();
300 316 }
301 317
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
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
304 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
305 321
306 322 $return_url = (string)$token['return'];
307 323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -306,16 +322,28 @@
306 322 $return_url = (string)$token['return'];
307 323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
308 324
309 325 $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(),
326 + 's2member_paypal_proxy' => 'paypal',
327 + 's2member_paypal_proxy_use' => $proxy_use,
313 328 ));
314 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 +
315 343 // Auto-POST into s2Member's existing PayPal return handler.
316 344 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).'">';
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.
318 346 foreach($return_post as $k => $v)
319 347 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
320 348 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
321 349 exit();
@@ -402,61 +430,42 @@
402 430 'option_name2' => (string)$token['on1'],
403 431 'option_selection2' => (string)$token['os1'],
404 432 );
405 433
406 - //260406 Use the shared PayPal Checkout subscription-done option so checkout and webhooks agree on fallback suppression.
407 434 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
408 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
409 435
410 - 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']))
411 440 {
412 - delete_option($option_ppco_subscr);
413 - $option_ppco_subscr_time = 0;
441 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
442 + exit();
414 443 }
415 444
416 - if(!$option_ppco_subscr_time)
417 - {
418 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
419 - 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);
420 448
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 449 $return_url2 = (string)$token['return'];
449 450 $return_url2 = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url2);
450 451
451 452 $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(),
453 + 's2member_paypal_proxy' => 'paypal',
454 + 's2member_paypal_proxy_use' => 'paypal_checkout',
455 455 ));
456 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 +
457 466 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).'">';
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.
459 468 foreach($return_post2 as $k => $v)
460 469 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
461 470 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
462 471 exit();
@@ -462,8 +471,66 @@
462 471 exit();
463 472 }
464 473 }
465 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 +
466 533 if($op === 'get_plan_id')
467 534 {
468 535 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
469 536 {
@@ -504,8 +571,22 @@
504 571 {
505 572 echo wp_json_encode(array('error' => 'missing_subscription_id'));
506 573 exit();
507 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 +
508 589 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
509 590
510 591 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
511 592 'ppco' => 'checkout',
@@ -559,13 +640,13 @@
559 640 if($lpv !== '' && $lpc !== '')
560 641 $allow_expired_single_cycle = true;
561 642 }
562 643
563 - if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle)
644 + if(!$status)
564 645 {
565 646 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
566 647 'ppco' => 'checkout',
567 - 'env_setting' => $env_setting,
648 + 'env_setting' => $env_setting,
568 649 'event' => 'subscription_status_invalid',
569 650 'subscription_id' => $subscription_id,
570 651 'status' => $status,
571 652 ));
@@ -601,8 +682,48 @@
601 682 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
602 683 exit();
603 684 }
604 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 +
605 726 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
606 727 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
607 728 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
608 729
@@ -640,90 +761,56 @@
640 761 'option_name2' => (string)$token['on1'],
641 762 'option_selection2' => (string)$token['os1'],
642 763 );
643 764
644 - $ppco_dup_processed = false;
645 765 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
646 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
647 766
648 - 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']))
649 774 {
650 - delete_option($option_ppco_subscr);
651 - $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();
652 788 }
653 789
654 - $ppco_dup_processed = ($option_ppco_subscr_time > 0);
655 -
656 - if($ppco_dup_processed)
790 + if(!empty($notify_result['duplicate']))
657 791 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
658 792 'ppco' => 'checkout',
659 - 'env_setting' => $env_setting,
793 + 'env_setting' => $env_setting,
660 794 'event' => 'duplicate_subscription_ignored',
661 795 'subscription_id' => $subscription_id,
662 796 'option' => $option_ppco_subscr,
663 797 ));
664 -
665 - if(!$ppco_dup_processed)
798 + else
666 799 {
667 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
668 - update_option($option_ppco_subscr, time(), false);
669 -
670 800 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
671 801 'ppco' => 'checkout',
672 - 'env_setting' => $env_setting,
673 - 'event' => 'idempotency_subscription_set',
802 + 'env_setting' => $env_setting,
803 + 'event' => 'notify_proxy_response',
674 804 'subscription_id' => $subscription_id,
675 - 'option' => $option_ppco_subscr,
676 - 'expires_secs' => DAY_IN_SECONDS,
805 + 'code' => $notify_code,
806 + 'message' => $notify_msg,
807 + 'body' => $notify_body,
677 808 ));
678 809
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 - }
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);
726 813 }
727 814
728 815 $return_url = (string)$token['return'];
729 816 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -728,13 +815,24 @@
728 815 $return_url = (string)$token['return'];
729 816 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
730 817
731 818 $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(),
819 + 's2member_paypal_proxy' => 'paypal',
820 + 's2member_paypal_proxy_use' => 'paypal_checkout',
735 821 ));
736 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 +
737 835 echo wp_json_encode(array(
738 836 'rtn_url' => $return_url,
739 837 'rtn_post' => $return_post,
740 838 ));
@@ -808,47 +906,37 @@
808 906 $reason = sanitize_text_field($reason);
809 907 if(!$reason)
810 908 $reason = 'Cancelled by subscriber.';
811 909
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;
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();
817 913
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 - ));
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']);
830 918
831 - echo wp_json_encode(array('error' => 'subscription_details_unusable'));
832 - exit();
833 - }
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 + );
834 928
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 929 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
841 - 'ppco' => 'checkout',
930 + 'ppco' => 'checkout',
842 931 '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,
932 + 'event' => 'cancel_subscription_response',
933 + 'user_id' => $user_id,
934 + 'subscr_id' => $subscr_id,
935 + 'accepted' => $cancelled ? 1 : 0,
848 936 ));
849 937
850 - if($code === 204 || ($code >= 200 && $code <= 299))
938 + if($cancelled)
851 939 {
852 940 // Immediately feed s2Member's existing cancel handler (webhooks may be missing in MVP sites).
853 941 $paypal = array(
854 942 'txn_type' => 'subscr_cancel',
@@ -871,10 +959,9 @@
871 959 'payer_email' => (string)wp_get_current_user()->user_email,
872 960 );
873 961
874 962 //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)
963 + if($ipn_signup_vars)
877 964 {
878 965 if(!empty($ipn_signup_vars['item_number']))
879 966 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
880 967
@@ -945,14 +1032,40 @@
945 1032 'order' => $order,
946 1033 'token' => $token,
947 1034 ));
948 1035
949 - 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')));
950 1040 exit();
951 1041 }
952 1042 echo wp_json_encode(array('order_id' => $order['id']));
953 1043 exit();
954 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 + }
955 1068 else if($op === 'capture_order')
956 1069 {
957 1070 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
958 1071
@@ -960,8 +1073,23 @@
960 1073 {
961 1074 echo wp_json_encode(array('error' => 'missing_order_id'));
962 1075 exit();
963 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 +
964 1092 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
965 1093
966 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();
967 1095 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
@@ -977,8 +1105,35 @@
977 1105 'capture' => $capture,
978 1106 'token' => $token,
979 1107 ));
980 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 +
981 1136 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
982 1137 {
983 1138 echo wp_json_encode(array('error' => 'order_capture_failed'));
984 1139 exit();
@@ -1109,74 +1264,54 @@
1109 1264 'option_name2' => (string)$token['on1'],
1110 1265 'option_selection2' => (string)$token['os1'],
1111 1266 );
1112 1267
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);
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'];
1119 1271
1120 - 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']))
1121 1288 {
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 1289 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1126 - 'ppco' => 'checkout',
1290 + 'ppco' => 'checkout',
1127 1291 '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,
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'] : '',
1133 1298 ));
1299 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
1300 + exit();
1134 1301 }
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 1302
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)
1303 + if(!empty($notify_result['processed']))
1168 1304 {
1169 1305 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1170 - 'ppco' => 'checkout',
1306 + 'ppco' => 'checkout',
1171 1307 '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,
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'] : '',
1179 1314 ));
1180 1315
1181 1316 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1182 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
@@ -1181,25 +1316,8 @@
1181 1316 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1182 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 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
1184 1319 }
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 1320
1203 1321 // 2) Send the user through the existing Return handler via POST (sets cookies, thank-you UX, reg tokens, etc).
1204 1322 $return_url = (string)$token['return'];
1205 1323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -1204,12 +1322,27 @@
1204 1322 $return_url = (string)$token['return'];
1205 1323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
1206 1324
1207 1325 $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(),
1326 + 's2member_paypal_proxy' => 'paypal',
1327 + 's2member_paypal_proxy_use' => $proxy_use,
1211 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;
1212 1345
1213 1346 echo wp_json_encode(array(
1214 1347 'rtn_url' => $return_url,
1215 1348 'rtn_post' => $return_post,