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