PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 261001
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v261001
261001 260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 All 191 releases
← All changes | src/includes/classes/paypal-checkout-in.inc.php +508 -261 260805 → 261001 View file →
@@ -4,9 +4,9 @@
4 4 * s2Member's PayPal Checkout (REST) handler.
5 5 *
6 6 * Server-side entrypoint for PayPal Checkout operations used by s2Member shortcodes:
7 7 * - Buy Now: create_order + capture_order (one-time payments).
8 - * - Subscriptions (membership level): get_plan_id + confirm_subscription.
8 + * - Subscriptions (membership level): create_subscription/get_plan_id + confirm_subscription.
9 9 * - output="url|anchor": redirect/return flow (does not create orders on page load).
10 10 * - Optional: cancel_subscription (on-site cancel for logged-in users).
11 11 *
12 12 * Successful operations are proxied into s2Member's existing PayPal notify/return handlers,
@@ -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;
@@ -78,8 +76,11 @@
78 76 {
79 77 echo wp_json_encode(array('error' => 'invalid_token'));
80 78 exit();
81 79 }
80 + //260928.1645 Never write a reusable signed Gateway Checkout browser token to PayPal debug logs; the encrypted shortcode token remains available to the handler itself.
81 + $log_token = $token;
82 + unset($log_token['gateway_checkout_token']);
82 83 if(!empty($token['exp']) && is_numeric($token['exp']) && time() > (int)$token['exp'])
83 84 {
84 85 echo wp_json_encode(array('error' => 'token_expired'));
85 86 exit();
@@ -94,8 +95,24 @@
94 95 echo wp_json_encode(array('error' => 'token_checksum_mismatch'));
95 96 exit();
96 97 }
97 98
99 + //260928.1520 Framework button shortcodes use the same durable coordinator as Pro-Forms, initialized before any provider-side create operation and required for later browser return/confirmation.
100 + if(strpos((string)$token['invoice'], 's2mb-') === 0 && $op !== 'cancel')
101 + {
102 + $create_allowed = in_array($op, array('create_order', 'create_subscription', 'get_plan_id', 'redirect'), TRUE);
103 + $prepared = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_button_gateway_checkout_prepare($token, $create_allowed);
104 + if(empty($prepared['ok']))
105 + {
106 + $error = !empty($prepared['error']) ? (string)$prepared['error'] : 'gateway_checkout_unavailable';
107 + if($is_redirect_mode)
108 + echo esc_html($error);
109 + else
110 + echo wp_json_encode(array('error' => $error));
111 + exit();
112 + }
113 + }
114 +
98 115 if($token['ip'] !== c_ws_plugin__s2member_utils_ip::current())
99 116 {
100 117 //260414 PayPal Checkout browser returns can legitimately arrive with a different client IP; log it, but do not fail the token.
101 118 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
@@ -101,9 +118,9 @@
101 118 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
102 119 'ppco' => 'checkout',
103 120 'env_setting' => $env_setting,
104 121 'event' => 'token_ip_mismatch',
105 - 'token' => $token,
122 + 'token' => $log_token,
106 123 'ip' => c_ws_plugin__s2member_utils_ip::current(),
107 124 ));
108 125 }
109 126
@@ -145,9 +162,9 @@
145 162 'ppco' => 'checkout',
146 163 'env_setting' => $env_setting,
147 164 'event' => 'redirect_order_create_response',
148 165 'order' => $order,
149 - 'token' => $token,
166 + 'token' => $log_token,
150 167 ));
151 168
152 169 $approve_url = '';
153 170 if(!empty($order['links']) && is_array($order['links']))
@@ -158,8 +175,18 @@
158 175 if($rel === 'approve' || $rel === 'payer-action' || $rel === 'approval_url')
159 176 $approve_url = (string)$link['href'];
160 177 }
161 178
179 + //260928.1605 A resumed Gateway Checkout returns its existing provider ID, not the original create response links; fetch the provider resource rather than create another chargeable order.
180 + if(!$approve_url && !empty($order['id']) && strpos((string)$token['invoice'], 's2mb-') === 0)
181 + {
182 + $order_details = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_details((string)$order['id']);
183 + if(empty($order_details['__error']) && !empty($order_details['links']) && is_array($order_details['links']))
184 + foreach($order_details['links'] as $link)
185 + if(!empty($link['rel']) && !empty($link['href']) && in_array(strtolower((string)$link['rel']), array('approve', 'payer-action', 'approval_url'), TRUE))
186 + $approve_url = (string)$link['href'];
187 + }
188 +
162 189 if(!$approve_url)
163 190 {
164 191 echo 'order_approval_url_missing';
165 192 exit();
@@ -176,9 +203,9 @@
176 203 'ppco' => 'checkout',
177 204 'env_setting' => $env_setting,
178 205 'event' => 'redirect_subscription_create_response',
179 206 'subscription' => $subscription,
180 - 'token' => $token,
207 + 'token' => $log_token,
181 208 ));
182 209
183 210 $approve_url = '';
184 211 if(!empty($subscription['links']) && is_array($subscription['links']))
@@ -185,8 +212,18 @@
185 212 foreach($subscription['links'] as $link)
186 213 if(!empty($link['rel']) && !empty($link['href']) && strtolower((string)$link['rel']) === 'approve')
187 214 $approve_url = (string)$link['href'];
188 215
216 + //260928.1605 Reuse the same persisted PayPal subscription on repeated redirect clicks. A details GET may supply the approval link without starting a second subscription.
217 + if(!$approve_url && !empty($subscription['id']) && strpos((string)$token['invoice'], 's2mb-') === 0)
218 + {
219 + $subscription_details = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details((string)$subscription['id']);
220 + if(empty($subscription_details['__error']) && !empty($subscription_details['links']) && is_array($subscription_details['links']))
221 + foreach($subscription_details['links'] as $link)
222 + if(!empty($link['rel']) && !empty($link['href']) && strtolower((string)$link['rel']) === 'approve')
223 + $approve_url = (string)$link['href'];
224 + }
225 +
189 226 if(!$approve_url)
190 227 {
191 228 echo 'subscription_approval_url_missing';
192 229 exit();
@@ -220,11 +257,17 @@
220 257 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
221 258 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
222 259 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
223 260 'capture' => $capture,
224 - 'token' => $token,
261 + 'token' => $log_token,
225 262 ));
226 263
264 + if(!empty($capture['__error']))
265 + {
266 + echo (string)$capture['__error'];
267 + exit();
268 + }
269 +
227 270 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
228 271 {
229 272 echo 'order_capture_failed';
230 273 exit();
@@ -229,12 +272,31 @@
229 272 echo 'order_capture_failed';
230 273 exit();
231 274 }
232 275
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'] : '';
276 + //260928.1538 Framework button redirect purchases use the shared coordinator fulfillment instead of a second hand-written notify/return path.
277 + if(strpos((string)$token['invoice'], 's2mb-') === 0)
278 + {
279 + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($capture, $token);
280 + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post']))
281 + {
282 + echo esc_html(!empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'order_fulfillment_failed');
283 + exit();
284 + }
285 + echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
286 + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($fulfillment['rtn_url']).'">';
287 + foreach($fulfillment['rtn_post'] as $k => $v)
288 + echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
289 + echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
290 + exit();
291 + }
236 292
293 + //260818.0126 Keep submitted Pro-Form contact details for pro-emails; they may differ from the payer's PayPal profile.
294 + $is_pro_form = (!empty($token['s2member_paypal_proxy_use']) && (string)$token['s2member_paypal_proxy_use'] === 'pro-emails');
295 + $payer_email = ($is_pro_form && isset($token['payer_email'])) ? sanitize_email((string)$token['payer_email']) : (!empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '');
296 + $first_name = ($is_pro_form && isset($token['first_name'])) ? (string)$token['first_name'] : (!empty($capture['payer']['name']['given_name']) ? (string)$capture['payer']['name']['given_name'] : '');
297 + $last_name = ($is_pro_form && isset($token['last_name'])) ? (string)$token['last_name'] : (!empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '');
298 +
237 299 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
238 300 $pu_cc = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code'] : '';
239 301 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
240 302
@@ -274,36 +336,44 @@
274 336 'first_name' => $first_name,
275 337 'last_name' => $last_name,
276 338 );
277 339
340 + //260817.2119 Preserve Pro-Form tax in the simulated IPN so existing fulfillment and email logic receives the same calculated values as the legacy Pro flow.
341 + if(isset($token['tax']))
342 + $paypal['tax'] = (string)$token['tax'];
343 +
278 344 $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
279 345 $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
280 346 $can_cancel_old_subscr = (!$is_independent_ccaps_sale && !$is_specific_post_page_sale); //260407 Only membership replacement-style PPCO purchases should cancel an existing recurring subscription here.
281 347
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);
348 + //260817.2119 Keep normal Checkout defaults while allowing an encrypted Pro-Form token to request its existing email, coupon, and success-URL handling during the internal Notify call.
349 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
350 + $notify_extra = array();
289 351
290 - if(!is_array($notify_r))
352 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
353 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
354 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
355 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
356 +
357 + $notify_done_option = 's2m_ppco_capture_done_'.md5($pu_cap_id);
358 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $notify_done_option, $proxy_use, $notify_extra);
359 +
360 + if(empty($notify_result['ok']))
291 361 {
292 362 if($is_redirect_mode)
293 - echo 'notify_proxy_failed';
363 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
294 364 else
295 365 {
296 366 if(!headers_sent())
297 367 status_header(500);
298 368
299 - echo wp_json_encode(array('error' => 'notify_proxy_failed'));
369 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
300 370 }
301 371 exit();
302 372 }
303 373
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
374 + //260817 Only the request that actually performed fulfillment should trigger replacement-subscription cancellation.
375 + if(!empty($notify_result['processed']) && $can_cancel_old_subscr && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $pu_cap_id), get_defined_vars())) //260406
306 376 c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars); //260407
307 377
308 378 $return_url = (string)$token['return'];
309 379 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -308,16 +378,28 @@
308 378 $return_url = (string)$token['return'];
309 379 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
310 380
311 381 $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(),
382 + 's2member_paypal_proxy' => 'paypal',
383 + 's2member_paypal_proxy_use' => $proxy_use,
315 384 ));
316 385
386 + //260817 Carry the already-resolved Pro-Form success URL inside the signed browser-return package.
387 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
388 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
389 +
390 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
391 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
392 + if(!$return_handoff)
393 + {
394 + echo 'return_handoff_failed';
395 + exit();
396 + }
397 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
398 +
317 399 // Auto-POST into s2Member's existing PayPal return handler.
318 400 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).'">';
401 + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($return_url).'">'; //260817 Keep the signed browser-return payload encoding stable.
320 402 foreach($return_post as $k => $v)
321 403 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
322 404 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
323 405 exit();
@@ -339,9 +421,9 @@
339 421 'event' => 'subscription_get_response',
340 422 'subscription_id' => $subscription_id,
341 423 'code' => !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0,
342 424 'body' => !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '',
343 - 'token' => $token,
425 + 'token' => $log_token,
344 426 ));
345 427
346 428 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
347 429 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
@@ -365,8 +447,41 @@
365 447 echo 'subscription_custom_id_mismatch';
366 448 exit();
367 449 }
368 450
451 + //260928.1538 A returned Framework button and an ACTIVATED webhook resolve the same provider subscription against the same saved purchase token.
452 + if(strpos((string)$token['invoice'], 's2mb-') === 0)
453 + {
454 + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_button_subscription_fulfill($subscription, $token, 'return');
455 + if(!empty($fulfillment['pending_activation']))
456 + {
457 + //260928.1703 PayPal can redirect before ACTIVE (or while the webhook holds the checkout lock). Recheck the same signed return, without creating another subscription or showing a false payment failure.
458 + $wait_attempt = isset($_GET['s2member_paypal_checkout_wait']) ? max(0, (int)$_GET['s2member_paypal_checkout_wait']) : 0;
459 + echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /><title>PayPal subscription confirmation</title></head><body>';
460 + echo '<p>PayPal is confirming your subscription. Please do not start a second checkout.</p>';
461 + if($wait_attempt < 12)
462 + {
463 + $poll_url = add_query_arg(array('subscription_id' => $subscription_id, 's2member_paypal_checkout_wait' => $wait_attempt + 1), $return_url);
464 + echo '<script type="text/javascript">setTimeout(function(){window.location.replace('.wp_json_encode($poll_url, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT).');},2000);</script>';
465 + }
466 + else
467 + echo '<p>Confirmation is taking longer than expected. If PayPal activates the subscription, s2Member will complete it through the webhook; check your registration email before trying again.</p>';
468 + echo '</body></html>';
469 + exit();
470 + }
471 + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post']))
472 + {
473 + echo esc_html(!empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'subscription_fulfillment_failed');
474 + exit();
475 + }
476 + echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
477 + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($fulfillment['rtn_url']).'">';
478 + foreach($fulfillment['rtn_post'] as $k => $v)
479 + echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
480 + echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
481 + exit();
482 + }
483 +
369 484 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
370 485 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
371 486 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
372 487
@@ -404,61 +519,42 @@
404 519 'option_name2' => (string)$token['on1'],
405 520 'option_selection2' => (string)$token['os1'],
406 521 );
407 522
408 - //260406 Use the shared PayPal Checkout subscription-done option so checkout and webhooks agree on fallback suppression.
409 523 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
410 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
411 524
412 - if($option_ppco_subscr_time > 0 && (time() - $option_ppco_subscr_time) >= DAY_IN_SECONDS)
525 + //260818.0603 Share the success-only Notify lock/done marker with browser confirmation and webhook activation fallback.
526 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $option_ppco_subscr);
527 +
528 + if(empty($notify_result['ok']))
413 529 {
414 - delete_option($option_ppco_subscr);
415 - $option_ppco_subscr_time = 0;
530 + echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
531 + exit();
416 532 }
417 533
418 - if(!$option_ppco_subscr_time)
419 - {
420 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
421 - update_option($option_ppco_subscr, time(), false);
534 + //260818.0603 Only the request that completed Notify should cancel a replaced subscription; duplicates are already fulfilled.
535 + if(!empty($notify_result['processed']) && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars()))
536 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars);
422 537
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 538 $return_url2 = (string)$token['return'];
451 539 $return_url2 = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url2);
452 540
453 541 $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(),
542 + 's2member_paypal_proxy' => 'paypal',
543 + 's2member_paypal_proxy_use' => 'paypal_checkout',
457 544 ));
458 545
546 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
547 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post2);
548 + if(!$return_handoff)
549 + {
550 + echo 'return_handoff_failed';
551 + exit();
552 + }
553 + $return_post2['s2member_paypal_checkout_handoff'] = $return_handoff;
554 +
459 555 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).'">';
556 + echo '<form id="s2m_ppco_rtn" method="post" accept-charset="UTF-8" action="'.esc_attr($return_url2).'">'; //260817 Keep the signed browser-return payload encoding stable.
461 557 foreach($return_post2 as $k => $v)
462 558 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
463 559 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
464 560 exit();
@@ -464,8 +560,66 @@
464 560 exit();
465 561 }
466 562 }
467 563
564 + if($op === 'create_subscription')
565 + {
566 + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
567 + {
568 + echo wp_json_encode(array('error' => 'not_subscription'));
569 + exit();
570 + }
571 +
572 + $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_create($token);
573 +
574 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
575 + 'ppco' => 'checkout',
576 + 'env_setting' => $env_setting,
577 + 'event' => 'create_subscription_response',
578 + 'subscription' => $subscription,
579 + 'token' => $log_token,
580 + ));
581 +
582 + if(empty($subscription['id']))
583 + {
584 + $error = !empty($subscription['__error']) ? (string)$subscription['__error'] : 'subscription_create_failed';
585 + $recoverable = in_array($error, array('subscription_create_unresolved', 'gateway_checkout_busy'), TRUE);
586 + //260902.0200 Let coordinator-backed browser flows briefly wait for webhook repair only when creation is genuinely unresolved/in progress; deterministic failures remain immediate errors.
587 + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable));
588 + exit();
589 + }
590 +
591 + //260901.2145 The browser receives only the already-persisted PayPal subscription ID; PayPal's JS SDK handles buyer approval from that server-created resource.
592 + echo wp_json_encode(array('subscription_id' => (string)$subscription['id']));
593 + exit();
594 + }
595 +
596 + if($op === 'get_subscription_id')
597 + {
598 + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
599 + {
600 + echo wp_json_encode(array('error' => 'not_subscription'));
601 + exit();
602 + }
603 +
604 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
605 + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id) : FALSE;
606 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription')
607 + {
608 + echo wp_json_encode(array('error' => 'gateway_checkout_invalid'));
609 + exit();
610 + }
611 +
612 + $subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
613 + //260902.0200 This poll reads only local coordinator state; PayPal is not called repeatedly while a CREATED webhook has a chance to repair an ambiguous create response.
614 + echo wp_json_encode(array(
615 + 'subscription_id' => $subscription_id,
616 + 'pending' => !$subscription_id,
617 + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '',
618 + ));
619 + exit();
620 + }
621 +
468 622 if($op === 'get_plan_id')
469 623 {
470 624 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
471 625 {
@@ -479,9 +633,9 @@
479 633 'ppco' => 'checkout',
480 634 'env_setting' => $env_setting,
481 635 'event' => 'get_plan_id_response',
482 636 'plan_id' => $plan_id,
483 - 'token' => $token,
637 + 'token' => $log_token,
484 638 ));
485 639
486 640 if(!$plan_id)
487 641 {
@@ -506,8 +660,22 @@
506 660 {
507 661 echo wp_json_encode(array('error' => 'missing_subscription_id'));
508 662 exit();
509 663 }
664 +
665 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
666 + if($gateway_checkout_id)
667 + {
668 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
669 + $expected_subscription_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : '';
670 + //260901.2145 A coordinator-backed browser may confirm only the PayPal subscription that s2Member created and persisted for this logical checkout.
671 + if(!$expected_subscription_id || !hash_equals($expected_subscription_id, $subscription_id))
672 + {
673 + echo wp_json_encode(array('error' => 'gateway_checkout_subscription_mismatch'));
674 + exit();
675 + }
676 + }
677 +
510 678 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
511 679
512 680 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
513 681 'ppco' => 'checkout',
@@ -514,9 +682,9 @@
514 682 'env_setting' => $env_setting,
515 683 'event' => 'subscription_get_response',
516 684 'subscription_id' => $subscription_id,
517 685 'subscription' => $subscription_r,
518 - 'token' => $token,
686 + 'token' => $log_token,
519 687 ));
520 688
521 689 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
522 690 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
@@ -561,13 +729,13 @@
561 729 if($lpv !== '' && $lpc !== '')
562 730 $allow_expired_single_cycle = true;
563 731 }
564 732
565 - if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle)
733 + if(!$status)
566 734 {
567 735 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
568 736 'ppco' => 'checkout',
569 - 'env_setting' => $env_setting,
737 + 'env_setting' => $env_setting,
570 738 'event' => 'subscription_status_invalid',
571 739 'subscription_id' => $subscription_id,
572 740 'status' => $status,
573 741 ));
@@ -603,8 +771,66 @@
603 771 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
604 772 exit();
605 773 }
606 774
775 + //260928.1538 Framework button and webhook share a single durable fulfillment path; do not create a second simulated IPN here.
776 + if(strpos((string)$token['invoice'], 's2mb-') === 0)
777 + {
778 + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_button_subscription_fulfill($subscription, $token, 'browser');
779 + if(!empty($fulfillment['pending_activation']))
780 + {
781 + echo wp_json_encode(array('pending_activation' => TRUE, 'subscription_id' => $subscription_id, 'status' => (string)$fulfillment['status']));
782 + exit();
783 + }
784 + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post']))
785 + {
786 + echo wp_json_encode(array('error' => !empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'subscription_fulfillment_failed'));
787 + exit();
788 + }
789 + echo wp_json_encode(array('rtn_url' => $fulfillment['rtn_url'], 'rtn_post' => $fulfillment['rtn_post']));
790 + exit();
791 + }
792 +
793 + if($gateway_checkout_id)
794 + {
795 + if(in_array($status, array('APPROVAL_PENDING', 'APPROVED'), TRUE))
796 + {
797 + //260902.0200 Coordinator-backed Pro-Forms do not treat PayPal creation/approval-pending states as paid entitlement; the browser waits briefly for ACTIVE and the activation webhook remains an off-session fallback.
798 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status));
799 + echo wp_json_encode(array('pending_activation' => TRUE, 'subscription_id' => $subscription_id, 'status' => $status));
800 + exit();
801 + }
802 + if($status !== 'ACTIVE' && !$allow_expired_single_cycle)
803 + {
804 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
805 + 'ppco' => 'checkout',
806 + 'env_setting' => $env_setting,
807 + 'event' => 'subscription_status_invalid',
808 + 'subscription_id' => $subscription_id,
809 + 'status' => $status,
810 + ));
811 +
812 + echo wp_json_encode(array('error' => 'subscription_status_invalid'));
813 + exit();
814 + }
815 + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status));
816 + }
817 + else if(!in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), TRUE) && !$allow_expired_single_cycle)
818 + {
819 + //260902.0200 Preserve existing non-coordinator PayPal Checkout button behavior until those flows migrate onto Gateway Checkout and gain the same activation polling.
820 + //260928.1645 Existing pre-migration browser tabs still carry the legacy PayPal Checkout token without Gateway Checkout identity. Preserve their original confirmation behavior until those in-flight tokens expire.
821 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
822 + 'ppco' => 'checkout',
823 + 'env_setting' => $env_setting,
824 + 'event' => 'subscription_status_invalid',
825 + 'subscription_id' => $subscription_id,
826 + 'status' => $status,
827 + ));
828 +
829 + echo wp_json_encode(array('error' => 'subscription_status_invalid'));
830 + exit();
831 + }
832 +
607 833 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
608 834 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
609 835 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
610 836
@@ -642,90 +868,56 @@
642 868 'option_name2' => (string)$token['on1'],
643 869 'option_selection2' => (string)$token['os1'],
644 870 );
645 871
646 - $ppco_dup_processed = false;
647 872 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
648 - $option_ppco_subscr_time = (int)get_option($option_ppco_subscr, 0);
649 873
650 - if($option_ppco_subscr_time > 0 && (time() - $option_ppco_subscr_time) >= DAY_IN_SECONDS)
874 + //260818.0603 Mark the Subscription done only after Notify succeeds, using the same lock as webhook activation fallback.
875 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $option_ppco_subscr);
876 + $notify_code = !empty($notify_result['code']) ? (int)$notify_result['code'] : 0;
877 + $notify_msg = !empty($notify_result['message']) ? (string)$notify_result['message'] : '';
878 + $notify_body = !empty($notify_result['body']) ? (string)$notify_result['body'] : '';
879 +
880 + if(empty($notify_result['ok']))
651 881 {
652 - delete_option($option_ppco_subscr);
653 - $option_ppco_subscr_time = 0;
882 + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
883 + 'ppco' => 'checkout',
884 + 'env_setting' => $env_setting,
885 + 'event' => 'notify_proxy_failed',
886 + 'subscription_id' => $subscription_id,
887 + 'code' => $notify_code,
888 + 'message' => $notify_msg,
889 + 'body' => $notify_body,
890 + 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed',
891 + ));
892 +
893 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
894 + exit();
654 895 }
655 896
656 - $ppco_dup_processed = ($option_ppco_subscr_time > 0);
657 -
658 - if($ppco_dup_processed)
897 + if(!empty($notify_result['duplicate']))
659 898 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
660 899 'ppco' => 'checkout',
661 - 'env_setting' => $env_setting,
900 + 'env_setting' => $env_setting,
662 901 'event' => 'duplicate_subscription_ignored',
663 902 'subscription_id' => $subscription_id,
664 903 'option' => $option_ppco_subscr,
665 904 ));
666 -
667 - if(!$ppco_dup_processed)
905 + else
668 906 {
669 - if(!add_option($option_ppco_subscr, time(), '', 'no'))
670 - update_option($option_ppco_subscr, time(), false);
671 -
672 907 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
673 908 'ppco' => 'checkout',
674 - 'env_setting' => $env_setting,
675 - 'event' => 'idempotency_subscription_set',
909 + 'env_setting' => $env_setting,
910 + 'event' => 'notify_proxy_response',
676 911 'subscription_id' => $subscription_id,
677 - 'option' => $option_ppco_subscr,
678 - 'expires_secs' => DAY_IN_SECONDS,
912 + 'code' => $notify_code,
913 + 'message' => $notify_msg,
914 + 'body' => $notify_body,
679 915 ));
680 916
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 - }
917 + //260818.0603 Only successful first-pass fulfillment should trigger replacement-subscription cancellation.
918 + if(!empty($notify_result['processed']) && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $subscription_id), get_defined_vars()))
919 + c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars);
728 920 }
729 921
730 922 $return_url = (string)$token['return'];
731 923 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -730,13 +922,24 @@
730 922 $return_url = (string)$token['return'];
731 923 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
732 924
733 925 $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(),
926 + 's2member_paypal_proxy' => 'paypal',
927 + 's2member_paypal_proxy_use' => 'paypal_checkout',
737 928 ));
738 929
930 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
931 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
932 + if(!$return_handoff)
933 + {
934 + if(!headers_sent())
935 + status_header(500);
936 +
937 + echo wp_json_encode(array('error' => 'return_handoff_failed'));
938 + exit();
939 + }
940 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
941 +
739 942 echo wp_json_encode(array(
740 943 'rtn_url' => $return_url,
741 944 'rtn_post' => $return_post,
742 945 ));
@@ -750,9 +953,9 @@
750 953 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
751 954 'ppco' => 'checkout',
752 955 'env_setting' => $env_setting,
753 956 'event' => 'cancel_subscription_not_logged_in',
754 - 'token' => $token,
957 + 'token' => $log_token,
755 958 ));
756 959
757 960 echo wp_json_encode(array('error' => 'not_logged_in'));
758 961 exit();
@@ -782,9 +985,9 @@
782 985 'ppco' => 'checkout',
783 986 'env_setting' => $env_setting,
784 987 'event' => 'cancel_subscription_token_mismatch',
785 988 'user_id' => $user_id,
786 - 'token' => $token,
989 + 'token' => $log_token,
787 990 ));
788 991
789 992 echo wp_json_encode(array('error' => 'token_mismatch'));
790 993 exit();
@@ -810,47 +1013,37 @@
810 1013 $reason = sanitize_text_field($reason);
811 1014 if(!$reason)
812 1015 $reason = 'Cancelled by subscriber.';
813 1016
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;
1017 + //260819.0417 Resolve the active subscription through whichever configured PayPal API family owns it.
1018 + $ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id);
1019 + $ipn_signup_vars = (is_array($ipn_signup_vars) && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id) ? $ipn_signup_vars : array();
819 1020
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 - ));
1021 + $next_billing_time = '';
1022 + $eot = c_ws_plugin__s2member_utils_users::get_user_eot($user_id, TRUE, 'next');
1023 + if(is_array($eot) && !empty($eot['type']) && $eot['type'] === 'next' && !empty($eot['time']) && (int)$eot['time'] > time())
1024 + $next_billing_time = gmdate('Y-m-d\TH:i:s\Z', (int)$eot['time']);
832 1025
833 - echo wp_json_encode(array('error' => 'subscription_details_unusable'));
834 - exit();
835 - }
1026 + $cancelled = c_ws_plugin__s2member_utilities::cancel_gateway_subscription(
1027 + 'paypal',
1028 + $subscr_id,
1029 + (string)get_user_option('s2member_subscr_baid', $user_id),
1030 + (string)get_user_option('s2member_subscr_cid', $user_id),
1031 + $ipn_signup_vars,
1032 + TRUE,
1033 + $reason
1034 + );
836 1035
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 1036 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
843 - 'ppco' => 'checkout',
1037 + 'ppco' => 'checkout',
844 1038 '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,
1039 + 'event' => 'cancel_subscription_response',
1040 + 'user_id' => $user_id,
1041 + 'subscr_id' => $subscr_id,
1042 + 'accepted' => $cancelled ? 1 : 0,
850 1043 ));
851 1044
852 - if($code === 204 || ($code >= 200 && $code <= 299))
1045 + if($cancelled)
853 1046 {
854 1047 // Immediately feed s2Member's existing cancel handler (webhooks may be missing in MVP sites).
855 1048 $paypal = array(
856 1049 'txn_type' => 'subscr_cancel',
@@ -873,10 +1066,9 @@
873 1066 'payer_email' => (string)wp_get_current_user()->user_email,
874 1067 );
875 1068
876 1069 //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)
1070 + if($ipn_signup_vars)
879 1071 {
880 1072 if(!empty($ipn_signup_vars['item_number']))
881 1073 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
882 1074
@@ -934,9 +1126,9 @@
934 1126 'ppco' => 'checkout',
935 1127 'env_setting' => $env_setting,
936 1128 'event' => 'create_order_response',
937 1129 'order' => $order,
938 - 'token' => $token,
1130 + 'token' => $log_token,
939 1131 ));
940 1132
941 1133 if(empty($order['id']))
942 1134 {
@@ -944,17 +1136,52 @@
944 1136 'ppco' => 'checkout',
945 1137 'env_setting' => $env_setting,
946 1138 'event' => 'order_create_failed',
947 1139 'order' => $order,
948 - 'token' => $token,
1140 + 'token' => $log_token,
949 1141 ));
950 1142
951 - echo wp_json_encode(array('error' => 'order_create_failed'));
1143 + $error = !empty($order['__error']) ? (string)$order['__error'] : 'order_create_failed';
1144 + $recoverable = ($error === 'gateway_checkout_busy');
1145 + //260902.0646 Only an overlapping request can populate a missing order ID asynchronously; an ambiguous provider create has no pre-approval webhook, so tell the customer to retry the same idempotent checkout instead of polling pointlessly.
1146 + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'retryable' => ($error === 'order_create_unresolved')));
952 1147 exit();
953 1148 }
954 1149 echo wp_json_encode(array('order_id' => $order['id']));
955 1150 exit();
956 1151 }
1152 + else if($op === 'get_order_status')
1153 + {
1154 + //260907.1820 This recovery endpoint is intentionally coordinator-only: the signed checkout token authorizes a local state read, while PayPal polling/retries remain server/webhook responsibilities.
1155 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
1156 + //260928.1703 Read fresh option state during capture-loss polling: a webhook may have fulfilled the checkout in another PHP worker moments earlier.
1157 + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state_uncached($gateway_checkout_id) : FALSE;
1158 + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment')
1159 + {
1160 + echo wp_json_encode(array('error' => 'gateway_checkout_invalid'));
1161 + exit();
1162 + }
1163 +
1164 + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
1165 + $fulfillment_result = is_array($private_context) && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']) ? $private_context['paypal_checkout']['fulfillment_result'] : array();
1166 + //260902.0635 Poll only local coordinator state while independent PayPal webhooks resolve delayed creates/captures; do not hammer the provider from the browser.
1167 + $fulfilled = ((string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result['rtn_url']) && !empty($fulfillment_result['rtn_post']));
1168 + $response = array(
1169 + 'order_id' => !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '',
1170 + 'capture_id' => !empty($gateway_checkout['gateway_ids']['capture_id']) ? (string)$gateway_checkout['gateway_ids']['capture_id'] : '',
1171 + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '',
1172 + 'fulfillment_status' => !empty($gateway_checkout['fulfillment_status']) ? (string)$gateway_checkout['fulfillment_status'] : '',
1173 + 'fulfilled' => $fulfilled,
1174 + );
1175 + //260928.1703 A lost capture response can be recovered with the already-signed return handoff; only the original encrypted checkout token can reach this endpoint.
1176 + if($fulfilled)
1177 + {
1178 + $response['rtn_url'] = $fulfillment_result['rtn_url'];
1179 + $response['rtn_post'] = $fulfillment_result['rtn_post'];
1180 + }
1181 + echo wp_json_encode($response);
1182 + exit();
1183 + }
957 1184 else if($op === 'capture_order')
958 1185 {
959 1186 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
960 1187
@@ -962,8 +1189,23 @@
962 1189 {
963 1190 echo wp_json_encode(array('error' => 'missing_order_id'));
964 1191 exit();
965 1192 }
1193 +
1194 + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : '';
1195 + if($gateway_checkout_id)
1196 + {
1197 + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id);
1198 + $private_context = $gateway_checkout ? c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id) : FALSE;
1199 + $fulfillment_result = is_array($private_context) && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']) ? $private_context['paypal_checkout']['fulfillment_result'] : array();
1200 + if($gateway_checkout && (string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result['rtn_url']) && !empty($fulfillment_result['rtn_post']))
1201 + {
1202 + //260902.0646 A webhook may have finished checkout while the browser was gone; return the saved browser result locally without touching PayPal or repeating fulfillment.
1203 + echo wp_json_encode(array('rtn_url' => $fulfillment_result['rtn_url'], 'rtn_post' => $fulfillment_result['rtn_post']));
1204 + exit();
1205 + }
1206 + }
1207 +
966 1208 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
967 1209
968 1210 $cap0 = (!empty($capture['purchase_units'][0]['payments']['captures'][0]) && is_array($capture['purchase_units'][0]['payments']['captures'][0])) ? $capture['purchase_units'][0]['payments']['captures'][0] : array();
969 1211 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
@@ -976,11 +1218,38 @@
976 1218 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
977 1219 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
978 1220 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
979 1221 'capture' => $capture,
980 - 'token' => $token,
1222 + 'token' => $log_token,
981 1223 ));
982 1224
1225 + if($gateway_checkout_id)
1226 + {
1227 + if(!empty($capture['__error']))
1228 + {
1229 + $error = (string)$capture['__error'];
1230 + $recoverable = in_array($error, array('capture_pending', 'order_capture_unresolved', 'gateway_checkout_busy'), TRUE);
1231 + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'pending' => ($error === 'capture_pending')));
1232 + exit();
1233 + }
1234 +
1235 + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($capture, $token);
1236 + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post']))
1237 + {
1238 + echo wp_json_encode(array('error' => !empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'order_fulfillment_failed'));
1239 + exit();
1240 + }
1241 +
1242 + echo wp_json_encode(array('rtn_url' => $fulfillment['rtn_url'], 'rtn_post' => $fulfillment['rtn_post']));
1243 + exit();
1244 + }
1245 +
1246 + if(!empty($capture['__error']))
1247 + {
1248 + echo wp_json_encode(array('error' => (string)$capture['__error']));
1249 + exit();
1250 + }
1251 +
983 1252 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
984 1253 {
985 1254 echo wp_json_encode(array('error' => 'order_capture_failed'));
986 1255 exit();
@@ -1004,9 +1273,9 @@
1004 1273 'env_setting' => $env_setting,
1005 1274 'event' => 'capture_missing_fields',
1006 1275 'order_id' => $order_id,
1007 1276 'capture' => $capture,
1008 - 'token' => $token,
1277 + 'token' => $log_token,
1009 1278 ));
1010 1279
1011 1280 echo wp_json_encode(array('error' => 'capture_missing_fields'));
1012 1281 exit();
@@ -1020,9 +1289,9 @@
1020 1289 'ppco' => 'checkout',
1021 1290 'env_setting' => $env_setting,
1022 1291 'event' => 'amount_mismatch',
1023 1292 'order_id' => $order_id,
1024 - 'token' => $token,
1293 + 'token' => $log_token,
1025 1294 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1026 1295 ));
1027 1296 echo wp_json_encode(array('error' => 'amount_mismatch'));
1028 1297 exit();
@@ -1033,9 +1302,9 @@
1033 1302 'ppco' => 'checkout',
1034 1303 'env_setting' => $env_setting,
1035 1304 'event' => 'currency_mismatch',
1036 1305 'order_id' => $order_id,
1037 - 'token' => $token,
1306 + 'token' => $log_token,
1038 1307 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1039 1308 ));
1040 1309 echo wp_json_encode(array('error' => 'currency_mismatch'));
1041 1310 exit();
@@ -1052,9 +1321,9 @@
1052 1321 'ppco' => 'checkout',
1053 1322 'env_setting' => $env_setting,
1054 1323 'event' => 'invoice_mismatch',
1055 1324 'order_id' => $order_id,
1056 - 'token' => $token,
1325 + 'token' => $log_token,
1057 1326 'invoice' => $cap_invoice_id,
1058 1327 ));
1059 1328 echo wp_json_encode(array('error' => 'invoice_mismatch'));
1060 1329 exit();
@@ -1072,9 +1341,9 @@
1072 1341 'ppco' => 'checkout',
1073 1342 'env_setting' => $env_setting,
1074 1343 'event' => 'custom_mismatch',
1075 1344 'order_id' => $order_id,
1076 - 'token' => $token,
1345 + 'token' => $log_token,
1077 1346 'custom' => array(
1078 1347 'token' => !empty($token['custom']) ? $token['custom'] : '',
1079 1348 'paypal' => $cap_custom_id,
1080 1349 ),
@@ -1111,74 +1380,54 @@
1111 1380 'option_name2' => (string)$token['on1'],
1112 1381 'option_selection2' => (string)$token['os1'],
1113 1382 );
1114 1383
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);
1384 + //260827.0051 Keep AJAX capture fulfillment aligned with the redirect capture path so Pro-Form tax, email/coupon routing, and resolved success URLs survive the shared Framework handler.
1385 + if(isset($token['tax']))
1386 + $paypal['tax'] = (string)$token['tax'];
1121 1387
1122 - if(!$ppco_dup_processed)
1388 + $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
1389 + $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
1390 + $can_cancel_old_subscr = (!$is_independent_ccaps_sale && !$is_specific_post_page_sale); //260407 Only membership replacement-style PPCO purchases should cancel an existing recurring subscription here.
1391 +
1392 + $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
1393 + $notify_extra = array();
1394 +
1395 + if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
1396 + $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
1397 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1398 + $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
1399 +
1400 + $notify_done_option = 's2m_ppco_capture_done_'.md5($pu_cap_id);
1401 + $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $notify_done_option, $proxy_use, $notify_extra);
1402 +
1403 + if(empty($notify_result['ok']))
1123 1404 {
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 1405 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1128 - 'ppco' => 'checkout',
1406 + 'ppco' => 'checkout',
1129 1407 '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,
1408 + 'event' => 'notify_proxy_failed',
1409 + 'order_id' => $order_id,
1410 + 'txn_id' => $pu_cap_id,
1411 + 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1412 + 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1413 + 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1135 1414 ));
1415 + echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
1416 + exit();
1136 1417 }
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 1418
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)
1419 + if(!empty($notify_result['processed']))
1170 1420 {
1171 1421 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1172 - 'ppco' => 'checkout',
1422 + 'ppco' => 'checkout',
1173 1423 '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,
1424 + 'event' => 'notify_proxy_response',
1425 + 'order_id' => $order_id,
1426 + 'txn_id' => $pu_cap_id,
1427 + 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1428 + 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1429 + 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1181 1430 ));
1182 1431
1183 1432 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1184 1433 if($can_cancel_old_subscr && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $pu_cap_id), get_defined_vars())) //260406
@@ -1183,25 +1432,8 @@
1183 1432 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1184 1433 if($can_cancel_old_subscr && $old__subscr_id && apply_filters('s2member_pro_cancels_old_rp_before_new_rp', ($old__subscr_id !== $pu_cap_id), get_defined_vars())) //260406
1185 1434 c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars); //260407
1186 1435 }
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 1436
1205 1437 // 2) Send the user through the existing Return handler via POST (sets cookies, thank-you UX, reg tokens, etc).
1206 1438 $return_url = (string)$token['return'];
1207 1439 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
@@ -1206,12 +1438,27 @@
1206 1438 $return_url = (string)$token['return'];
1207 1439 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
1208 1440
1209 1441 $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(),
1442 + 's2member_paypal_proxy' => 'paypal',
1443 + 's2member_paypal_proxy_use' => $proxy_use,
1213 1444 ));
1445 +
1446 + //260827.0051 Carry the Pro-Form's resolved success URL inside the signed browser return; Specific Post/Page uses the Notify response body for its generated access URL.
1447 + if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1448 + $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
1449 +
1450 + //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
1451 + $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
1452 + if(!$return_handoff)
1453 + {
1454 + if(!headers_sent())
1455 + status_header(500);
1456 +
1457 + echo wp_json_encode(array('error' => 'return_handoff_failed'));
1458 + exit();
1459 + }
1460 + $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
1214 1461
1215 1462 echo wp_json_encode(array(
1216 1463 'rtn_url' => $return_url,
1217 1464 'rtn_post' => $return_post,