| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | * s2Member's PayPal Checkout (REST) handler. |
| 5 | 5 | * |
| 6 | 6 | * Server-side entrypoint for PayPal Checkout operations used by s2Member shortcodes: |
| 7 | 7 | * - Buy Now: create_order + capture_order (one-time payments). |
| 8 | - * - Subscriptions (membership level): get_plan_id + confirm_subscription. | |
| 8 | + * - Subscriptions (membership level): create_subscription/get_plan_id + confirm_subscription. | |
| 9 | 9 | * - output="url|anchor": redirect/return flow (does not create orders on page load). |
| 10 | 10 | * - Optional: cancel_subscription (on-site cancel for logged-in users). |
| 11 | 11 | * |
| 12 | 12 | * Successful operations are proxied into s2Member's existing PayPal notify/return handlers, |
| @@ -471,8 +471,66 @@ | ||
| 471 | 471 | exit(); |
| 472 | 472 | } |
| 473 | 473 | } |
| 474 | 474 | |
| 475 | + if($op === 'create_subscription') | |
| 476 | + { | |
| 477 | + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN') | |
| 478 | + { | |
| 479 | + echo wp_json_encode(array('error' => 'not_subscription')); | |
| 480 | + exit(); | |
| 481 | + } | |
| 482 | + | |
| 483 | + $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_create($token); | |
| 484 | + | |
| 485 | + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( | |
| 486 | + 'ppco' => 'checkout', | |
| 487 | + 'env_setting' => $env_setting, | |
| 488 | + 'event' => 'create_subscription_response', | |
| 489 | + 'subscription' => $subscription, | |
| 490 | + 'token' => $token, | |
| 491 | + )); | |
| 492 | + | |
| 493 | + if(empty($subscription['id'])) | |
| 494 | + { | |
| 495 | + $error = !empty($subscription['__error']) ? (string)$subscription['__error'] : 'subscription_create_failed'; | |
| 496 | + $recoverable = in_array($error, array('subscription_create_unresolved', 'gateway_checkout_busy'), TRUE); | |
| 497 | + //260902.0200 Let coordinator-backed browser flows briefly wait for webhook repair only when creation is genuinely unresolved/in progress; deterministic failures remain immediate errors. | |
| 498 | + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable)); | |
| 499 | + exit(); | |
| 500 | + } | |
| 501 | + | |
| 502 | + //260901.2145 The browser receives only the already-persisted PayPal subscription ID; PayPal's JS SDK handles buyer approval from that server-created resource. | |
| 503 | + echo wp_json_encode(array('subscription_id' => (string)$subscription['id'])); | |
| 504 | + exit(); | |
| 505 | + } | |
| 506 | + | |
| 507 | + if($op === 'get_subscription_id') | |
| 508 | + { | |
| 509 | + if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN') | |
| 510 | + { | |
| 511 | + echo wp_json_encode(array('error' => 'not_subscription')); | |
| 512 | + exit(); | |
| 513 | + } | |
| 514 | + | |
| 515 | + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : ''; | |
| 516 | + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id) : FALSE; | |
| 517 | + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'subscription') | |
| 518 | + { | |
| 519 | + echo wp_json_encode(array('error' => 'gateway_checkout_invalid')); | |
| 520 | + exit(); | |
| 521 | + } | |
| 522 | + | |
| 523 | + $subscription_id = !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : ''; | |
| 524 | + //260902.0200 This poll reads only local coordinator state; PayPal is not called repeatedly while a CREATED webhook has a chance to repair an ambiguous create response. | |
| 525 | + echo wp_json_encode(array( | |
| 526 | + 'subscription_id' => $subscription_id, | |
| 527 | + 'pending' => !$subscription_id, | |
| 528 | + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '', | |
| 529 | + )); | |
| 530 | + exit(); | |
| 531 | + } | |
| 532 | + | |
| 475 | 533 | if($op === 'get_plan_id') |
| 476 | 534 | { |
| 477 | 535 | if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN') |
| 478 | 536 | { |
| @@ -513,8 +571,22 @@ | ||
| 513 | 571 | { |
| 514 | 572 | echo wp_json_encode(array('error' => 'missing_subscription_id')); |
| 515 | 573 | exit(); |
| 516 | 574 | } |
| 575 | + | |
| 576 | + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : ''; | |
| 577 | + if($gateway_checkout_id) | |
| 578 | + { | |
| 579 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 580 | + $expected_subscription_id = $gateway_checkout && !empty($gateway_checkout['gateway_ids']['subscription_id']) ? (string)$gateway_checkout['gateway_ids']['subscription_id'] : ''; | |
| 581 | + //260901.2145 A coordinator-backed browser may confirm only the PayPal subscription that s2Member created and persisted for this logical checkout. | |
| 582 | + if(!$expected_subscription_id || !hash_equals($expected_subscription_id, $subscription_id)) | |
| 583 | + { | |
| 584 | + echo wp_json_encode(array('error' => 'gateway_checkout_subscription_mismatch')); | |
| 585 | + exit(); | |
| 586 | + } | |
| 587 | + } | |
| 588 | + | |
| 517 | 589 | $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id)); |
| 518 | 590 | |
| 519 | 591 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| 520 | 592 | 'ppco' => 'checkout', |
| @@ -568,13 +640,13 @@ | ||
| 568 | 640 | if($lpv !== '' && $lpc !== '') |
| 569 | 641 | $allow_expired_single_cycle = true; |
| 570 | 642 | } |
| 571 | 643 | |
| 572 | - if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle) | |
| 644 | + if(!$status) | |
| 573 | 645 | { |
| 574 | 646 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| 575 | 647 | 'ppco' => 'checkout', |
| 576 | - 'env_setting' => $env_setting, | |
| 648 | + 'env_setting' => $env_setting, | |
| 577 | 649 | 'event' => 'subscription_status_invalid', |
| 578 | 650 | 'subscription_id' => $subscription_id, |
| 579 | 651 | 'status' => $status, |
| 580 | 652 | )); |
| @@ -610,8 +682,48 @@ | ||
| 610 | 682 | echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch')); |
| 611 | 683 | exit(); |
| 612 | 684 | } |
| 613 | 685 | |
| 686 | + if($gateway_checkout_id) | |
| 687 | + { | |
| 688 | + if(in_array($status, array('APPROVAL_PENDING', 'APPROVED'), TRUE)) | |
| 689 | + { | |
| 690 | + //260902.0200 Coordinator-backed Pro-Forms do not treat PayPal creation/approval-pending states as paid entitlement; the browser waits briefly for ACTIVE and the activation webhook remains an off-session fallback. | |
| 691 | + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status)); | |
| 692 | + echo wp_json_encode(array('pending_activation' => TRUE, 'subscription_id' => $subscription_id, 'status' => $status)); | |
| 693 | + exit(); | |
| 694 | + } | |
| 695 | + if($status !== 'ACTIVE' && !$allow_expired_single_cycle) | |
| 696 | + { | |
| 697 | + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( | |
| 698 | + 'ppco' => 'checkout', | |
| 699 | + 'env_setting' => $env_setting, | |
| 700 | + 'event' => 'subscription_status_invalid', | |
| 701 | + 'subscription_id' => $subscription_id, | |
| 702 | + 'status' => $status, | |
| 703 | + )); | |
| 704 | + | |
| 705 | + echo wp_json_encode(array('error' => 'subscription_status_invalid')); | |
| 706 | + exit(); | |
| 707 | + } | |
| 708 | + c_ws_plugin__s2member_gateway_checkouts::update($gateway_checkout_id, array('gateway_status' => $status)); | |
| 709 | + } | |
| 710 | + else if(!in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), TRUE) && !$allow_expired_single_cycle) | |
| 711 | + { | |
| 712 | + //260902.0200 Preserve existing non-coordinator PayPal Checkout button behavior until those flows migrate onto Gateway Checkout and gain the same activation polling. | |
| 713 | + //260907.2142 TO-DO: Migrate maintained Framework PayPal Checkout button/redirect flows onto Gateway Checkout before claiming cross-surface PPCO dedupe/idempotency parity, preserving the Pro-Form guarantees for durable provider identity, stable idempotent retries, monotonic final-state recovery, and shared browser/webhook fulfillment dedupe. | |
| 714 | + c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( | |
| 715 | + 'ppco' => 'checkout', | |
| 716 | + 'env_setting' => $env_setting, | |
| 717 | + 'event' => 'subscription_status_invalid', | |
| 718 | + 'subscription_id' => $subscription_id, | |
| 719 | + 'status' => $status, | |
| 720 | + )); | |
| 721 | + | |
| 722 | + echo wp_json_encode(array('error' => 'subscription_status_invalid')); | |
| 723 | + exit(); | |
| 724 | + } | |
| 725 | + | |
| 614 | 726 | $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : ''; |
| 615 | 727 | $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : ''; |
| 616 | 728 | $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : ''; |
| 617 | 729 | |
| @@ -920,14 +1032,40 @@ | ||
| 920 | 1032 | 'order' => $order, |
| 921 | 1033 | 'token' => $token, |
| 922 | 1034 | )); |
| 923 | 1035 | |
| 924 | - echo wp_json_encode(array('error' => 'order_create_failed')); | |
| 1036 | + $error = !empty($order['__error']) ? (string)$order['__error'] : 'order_create_failed'; | |
| 1037 | + $recoverable = ($error === 'gateway_checkout_busy'); | |
| 1038 | + //260902.0646 Only an overlapping request can populate a missing order ID asynchronously; an ambiguous provider create has no pre-approval webhook, so tell the customer to retry the same idempotent checkout instead of polling pointlessly. | |
| 1039 | + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'retryable' => ($error === 'order_create_unresolved'))); | |
| 925 | 1040 | exit(); |
| 926 | 1041 | } |
| 927 | 1042 | echo wp_json_encode(array('order_id' => $order['id'])); |
| 928 | 1043 | exit(); |
| 929 | 1044 | } |
| 1045 | + else if($op === 'get_order_status') | |
| 1046 | + { | |
| 1047 | + //260907.1820 This recovery endpoint is intentionally coordinator-only: the signed checkout token authorizes a local state read, while PayPal polling/retries remain server/webhook responsibilities. | |
| 1048 | + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : ''; | |
| 1049 | + $gateway_checkout = $gateway_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id) : FALSE; | |
| 1050 | + if(!$gateway_checkout || (string)$gateway_checkout['gateway'] !== 'paypal_checkout' || (string)$gateway_checkout['operation'] !== 'payment') | |
| 1051 | + { | |
| 1052 | + echo wp_json_encode(array('error' => 'gateway_checkout_invalid')); | |
| 1053 | + exit(); | |
| 1054 | + } | |
| 1055 | + | |
| 1056 | + $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id); | |
| 1057 | + $fulfillment_result = is_array($private_context) && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']) ? $private_context['paypal_checkout']['fulfillment_result'] : array(); | |
| 1058 | + //260902.0635 Poll only local coordinator state while independent PayPal webhooks resolve delayed creates/captures; do not hammer the provider from the browser. | |
| 1059 | + echo wp_json_encode(array( | |
| 1060 | + 'order_id' => !empty($gateway_checkout['gateway_ids']['order_id']) ? (string)$gateway_checkout['gateway_ids']['order_id'] : '', | |
| 1061 | + 'capture_id' => !empty($gateway_checkout['gateway_ids']['capture_id']) ? (string)$gateway_checkout['gateway_ids']['capture_id'] : '', | |
| 1062 | + 'status' => !empty($gateway_checkout['gateway_status']) ? (string)$gateway_checkout['gateway_status'] : '', | |
| 1063 | + 'fulfillment_status' => !empty($gateway_checkout['fulfillment_status']) ? (string)$gateway_checkout['fulfillment_status'] : '', | |
| 1064 | + 'fulfilled' => ((string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result)), | |
| 1065 | + )); | |
| 1066 | + exit(); | |
| 1067 | + } | |
| 930 | 1068 | else if($op === 'capture_order') |
| 931 | 1069 | { |
| 932 | 1070 | $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : ''; |
| 933 | 1071 | |
| @@ -935,8 +1073,23 @@ | ||
| 935 | 1073 | { |
| 936 | 1074 | echo wp_json_encode(array('error' => 'missing_order_id')); |
| 937 | 1075 | exit(); |
| 938 | 1076 | } |
| 1077 | + | |
| 1078 | + $gateway_checkout_id = !empty($token['gateway_checkout_id']) && c_ws_plugin__s2member_gateway_checkouts::valid_id((string)$token['gateway_checkout_id']) ? (string)$token['gateway_checkout_id'] : ''; | |
| 1079 | + if($gateway_checkout_id) | |
| 1080 | + { | |
| 1081 | + $gateway_checkout = c_ws_plugin__s2member_gateway_checkouts::load_state($gateway_checkout_id); | |
| 1082 | + $private_context = $gateway_checkout ? c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id) : FALSE; | |
| 1083 | + $fulfillment_result = is_array($private_context) && !empty($private_context['paypal_checkout']['fulfillment_result']) && is_array($private_context['paypal_checkout']['fulfillment_result']) ? $private_context['paypal_checkout']['fulfillment_result'] : array(); | |
| 1084 | + if($gateway_checkout && (string)$gateway_checkout['fulfillment_status'] === 'fulfilled' && !empty($fulfillment_result['rtn_url']) && !empty($fulfillment_result['rtn_post'])) | |
| 1085 | + { | |
| 1086 | + //260902.0646 A webhook may have finished checkout while the browser was gone; return the saved browser result locally without touching PayPal or repeating fulfillment. | |
| 1087 | + echo wp_json_encode(array('rtn_url' => $fulfillment_result['rtn_url'], 'rtn_post' => $fulfillment_result['rtn_post'])); | |
| 1088 | + exit(); | |
| 1089 | + } | |
| 1090 | + } | |
| 1091 | + | |
| 939 | 1092 | $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token); |
| 940 | 1093 | |
| 941 | 1094 | $cap0 = (!empty($capture['purchase_units'][0]['payments']['captures'][0]) && is_array($capture['purchase_units'][0]['payments']['captures'][0])) ? $capture['purchase_units'][0]['payments']['captures'][0] : array(); |
| 942 | 1095 | c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array( |
| @@ -951,8 +1104,29 @@ | ||
| 951 | 1104 | 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '', |
| 952 | 1105 | 'capture' => $capture, |
| 953 | 1106 | 'token' => $token, |
| 954 | 1107 | )); |
| 1108 | + | |
| 1109 | + if($gateway_checkout_id) | |
| 1110 | + { | |
| 1111 | + if(!empty($capture['__error'])) | |
| 1112 | + { | |
| 1113 | + $error = (string)$capture['__error']; | |
| 1114 | + $recoverable = in_array($error, array('capture_pending', 'order_capture_unresolved', 'gateway_checkout_busy'), TRUE); | |
| 1115 | + echo wp_json_encode(array('error' => $error, 'recoverable' => $recoverable, 'pending' => ($error === 'capture_pending'))); | |
| 1116 | + exit(); | |
| 1117 | + } | |
| 1118 | + | |
| 1119 | + $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($capture, $token); | |
| 1120 | + if(empty($fulfillment['ok']) || empty($fulfillment['rtn_url']) || empty($fulfillment['rtn_post'])) | |
| 1121 | + { | |
| 1122 | + echo wp_json_encode(array('error' => !empty($fulfillment['error']) ? (string)$fulfillment['error'] : 'order_fulfillment_failed')); | |
| 1123 | + exit(); | |
| 1124 | + } | |
| 1125 | + | |
| 1126 | + echo wp_json_encode(array('rtn_url' => $fulfillment['rtn_url'], 'rtn_post' => $fulfillment['rtn_post'])); | |
| 1127 | + exit(); | |
| 1128 | + } | |
| 955 | 1129 | |
| 956 | 1130 | if(!empty($capture['__error'])) |
| 957 | 1131 | { |
| 958 | 1132 | echo wp_json_encode(array('error' => (string)$capture['__error'])); |