← 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, |