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
260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 All 190 releases
← All changes | src/includes/classes/paypal-checkout-in.inc.php +178 -4 260829 → 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,
@@ -471,8 +471,66 @@
471 471 exit();
472 472 }
473 473 }
474 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 +
475 533 if($op === 'get_plan_id')
476 534 {
477 535 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
478 536 {
@@ -513,8 +571,22 @@
513 571 {
514 572 echo wp_json_encode(array('error' => 'missing_subscription_id'));
515 573 exit();
516 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 +
517 589 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
518 590
519 591 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
520 592 'ppco' => 'checkout',
@@ -568,13 +640,13 @@
568 640 if($lpv !== '' && $lpc !== '')
569 641 $allow_expired_single_cycle = true;
570 642 }
571 643
572 - if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle)
644 + if(!$status)
573 645 {
574 646 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
575 647 'ppco' => 'checkout',
576 - 'env_setting' => $env_setting,
648 + 'env_setting' => $env_setting,
577 649 'event' => 'subscription_status_invalid',
578 650 'subscription_id' => $subscription_id,
579 651 'status' => $status,
580 652 ));
@@ -610,8 +682,48 @@
610 682 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
611 683 exit();
612 684 }
613 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 +
614 726 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
615 727 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
616 728 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
617 729
@@ -920,14 +1032,40 @@
920 1032 'order' => $order,
921 1033 'token' => $token,
922 1034 ));
923 1035
924 - 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')));
925 1040 exit();
926 1041 }
927 1042 echo wp_json_encode(array('order_id' => $order['id']));
928 1043 exit();
929 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 + }
930 1068 else if($op === 'capture_order')
931 1069 {
932 1070 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
933 1071
@@ -935,8 +1073,23 @@
935 1073 {
936 1074 echo wp_json_encode(array('error' => 'missing_order_id'));
937 1075 exit();
938 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 +
939 1092 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
940 1093
941 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();
942 1095 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
@@ -951,8 +1104,29 @@
951 1104 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
952 1105 'capture' => $capture,
953 1106 'token' => $token,
954 1107 ));
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 + }
955 1129
956 1130 if(!empty($capture['__error']))
957 1131 {
958 1132 echo wp_json_encode(array('error' => (string)$capture['__error']));