PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 261001
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v261001
261001 260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 All 191 releases
s2member / src / includes / classes / paypal-checkout-in.inc.php

paypal-checkout-in.inc.php in s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions 261001, at src/includes/classes/paypal-checkout-in.inc.php

1,474 lines 68.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreFile
3 /**
4 * s2Member's PayPal Checkout (REST) handler.
5 *
6 * Server-side entrypoint for PayPal Checkout operations used by s2Member shortcodes:
7 * - Buy Now: create_order + capture_order (one-time payments).
8 * - Subscriptions (membership level): create_subscription/get_plan_id + confirm_subscription.
9 * - output="url|anchor": redirect/return flow (does not create orders on page load).
10 * - Optional: cancel_subscription (on-site cancel for logged-in users).
11 *
12 * Successful operations are proxied into s2Member's existing PayPal notify/return handlers,
13 * preserving legacy provisioning behavior (level/ccaps/EOT/etc.) without rewriting it.
14 *
15 * @package s2Member\PayPal
16 * @since 260101
17 */
18 if(!defined('WPINC')) // MUST have WordPress.
19 exit ('Do not access this file directly.');
20
21 if(!class_exists('c_ws_plugin__s2member_paypal_checkout_in'))
22 {
23 class c_ws_plugin__s2member_paypal_checkout_in
24 {
25 public static function paypal_checkout()
26 {
27 if(empty($_REQUEST['s2member_paypal_checkout']))
28 return;
29
30 @set_time_limit(0);
31 @ini_set('memory_limit', apply_filters('admin_memory_limit', WP_MAX_MEMORY_LIMIT));
32 @ini_set('display_errors', '0');
33
34 $op = !empty($_REQUEST['s2member_paypal_checkout_op']) ? strtolower(trim(stripslashes((string)$_REQUEST['s2member_paypal_checkout_op']))) : '';
35 $t = !empty($_REQUEST['s2member_paypal_checkout_t']) ? trim(stripslashes((string)$_REQUEST['s2member_paypal_checkout_t'])) : '';
36
37 $is_redirect_mode = in_array($op, array('redirect', 'return', 'cancel'), true);
38
39 $env_setting = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
40
41 if(!headers_sent())
42 {
43 nocache_headers();
44 if($is_redirect_mode)
45 header('Content-Type: text/html; charset=UTF-8');
46 else
47 header('Content-Type: application/json; charset=UTF-8');
48 }
49
50 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
51 'ppco' => 'checkout',
52 'env_setting' => $env_setting,
53 'event' => 'request',
54 'get' => $_GET,
55 'post' => $_POST,
56 'method' => !empty($_SERVER['REQUEST_METHOD']) ? $_SERVER['REQUEST_METHOD'] : '',
57 'ip' => !empty($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '',
58 'ua' => !empty($_SERVER['HTTP_USER_AGENT']) ? $_SERVER['HTTP_USER_AGENT'] : '',
59 'referer' => !empty($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : '',
60 ));
61
62 if(!$op || !$t)
63 {
64 echo wp_json_encode(array('error' => 'missing_op_or_token'));
65 exit();
66 }
67 $raw = c_ws_plugin__s2member_utils_encryption::decrypt($t);
68
69 //260808 Safely unserialize the PayPal checkout token.
70 $token = c_ws_plugin__s2member_utils_arrays::maybe_unserialize($raw);
71
72 if(!is_array($token))
73 $token = false;
74
75 if(!$token || !is_array($token))
76 {
77 echo wp_json_encode(array('error' => 'invalid_token'));
78 exit();
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']);
83 if(!empty($token['exp']) && is_numeric($token['exp']) && time() > (int)$token['exp'])
84 {
85 echo wp_json_encode(array('error' => 'token_expired'));
86 exit();
87 }
88 if(empty($token['invoice']) || empty($token['ip']) || empty($token['item_number']) || empty($token['checksum']))
89 {
90 echo wp_json_encode(array('error' => 'token_incomplete'));
91 exit();
92 }
93 if($token['checksum'] !== md5($token['invoice'].$token['ip'].$token['item_number']))
94 {
95 echo wp_json_encode(array('error' => 'token_checksum_mismatch'));
96 exit();
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
115 if($token['ip'] !== c_ws_plugin__s2member_utils_ip::current())
116 {
117 //260414 PayPal Checkout browser returns can legitimately arrive with a different client IP; log it, but do not fail the token.
118 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
119 'ppco' => 'checkout',
120 'env_setting' => $env_setting,
121 'event' => 'token_ip_mismatch',
122 'token' => $log_token,
123 'ip' => c_ws_plugin__s2member_utils_ip::current(),
124 ));
125 }
126
127 $old__subscr_gateway = !empty($token['old__subscr_gateway']) ? (string)$token['old__subscr_gateway'] : '';
128 $old__subscr_id = !empty($token['old__subscr_id']) ? (string)$token['old__subscr_id'] : '';
129 $old__subscr_baid = !empty($token['old__subscr_baid']) ? (string)$token['old__subscr_baid'] : '';
130 $old__subscr_cid = !empty($token['old__subscr_cid']) ? (string)$token['old__subscr_cid'] : '';
131 $old__ipn_signup_vars = (!empty($token['old__ipn_signup_vars']) && is_array($token['old__ipn_signup_vars'])) ? $token['old__ipn_signup_vars'] : array(); //260408 Use the old context captured before the buyer left for PayPal.
132
133 // output="anchor|url" support: redirect-mode endpoints (GET).
134 if($op === 'redirect' || $op === 'return' || $op === 'cancel')
135 {
136 // NOTE: These endpoints are intended for output="anchor|url" shortcode formats.
137 // They redirect to PayPal approval URLs, then auto-POST into s2Member's existing PayPal notify + return handlers.
138
139 if($op === 'cancel')
140 {
141 $cancel = !empty($token['cancel']) ? (string)$token['cancel'] : home_url('/');
142 $cancel = wp_validate_redirect($cancel, home_url('/'));
143 wp_redirect($cancel);
144 exit();
145 }
146
147 $endpoint = home_url('/?s2member_paypal_checkout=1');
148 $return_url = $endpoint.'&s2member_paypal_checkout_op=return&s2member_paypal_checkout_t='.rawurlencode($t);
149 $cancel_url = $endpoint.'&s2member_paypal_checkout_op=cancel&s2member_paypal_checkout_t='.rawurlencode($t);
150
151 if($op === 'redirect')
152 {
153 $pp_token = $token;
154 $pp_token['return'] = $return_url;
155 $pp_token['cancel'] = $cancel_url;
156
157 if((!isset($pp_token['rr']) || (string)$pp_token['rr'] === '') || strtoupper((string)$pp_token['rr']) === 'BN')
158 {
159 $order = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_create($pp_token);
160
161 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
162 'ppco' => 'checkout',
163 'env_setting' => $env_setting,
164 'event' => 'redirect_order_create_response',
165 'order' => $order,
166 'token' => $log_token,
167 ));
168
169 $approve_url = '';
170 if(!empty($order['links']) && is_array($order['links']))
171 foreach($order['links'] as $link)
172 if(!empty($link['rel']) && !empty($link['href']))
173 {
174 $rel = strtolower((string)$link['rel']);
175 if($rel === 'approve' || $rel === 'payer-action' || $rel === 'approval_url')
176 $approve_url = (string)$link['href'];
177 }
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
189 if(!$approve_url)
190 {
191 echo 'order_approval_url_missing';
192 exit();
193 }
194
195 wp_redirect($approve_url);
196 exit();
197 }
198 else
199 {
200 $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_create($pp_token);
201
202 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
203 'ppco' => 'checkout',
204 'env_setting' => $env_setting,
205 'event' => 'redirect_subscription_create_response',
206 'subscription' => $subscription,
207 'token' => $log_token,
208 ));
209
210 $approve_url = '';
211 if(!empty($subscription['links']) && is_array($subscription['links']))
212 foreach($subscription['links'] as $link)
213 if(!empty($link['rel']) && !empty($link['href']) && strtolower((string)$link['rel']) === 'approve')
214 $approve_url = (string)$link['href'];
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
226 if(!$approve_url)
227 {
228 echo 'subscription_approval_url_missing';
229 exit();
230 }
231
232 wp_redirect($approve_url);
233 exit();
234 }
235 }
236
237 // Return URL: PayPal redirects here after approval.
238 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
239 {
240 $order_id = !empty($_GET['token']) ? trim(stripslashes((string)$_GET['token'])) : '';
241 if(!$order_id)
242 {
243 echo 'missing_order_id';
244 exit();
245 }
246
247 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
248
249 $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();
250 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
251 'ppco' => 'checkout',
252 'env_setting' => $env_setting,
253 'event' => 'capture_response',
254 'order_id' => $order_id,
255 'status' => !empty($capture['status']) ? (string)$capture['status'] : '',
256 'capture_id' => !empty($cap0['id']) ? (string)$cap0['id'] : '',
257 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
258 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
259 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
260 'capture' => $capture,
261 'token' => $log_token,
262 ));
263
264 if(!empty($capture['__error']))
265 {
266 echo (string)$capture['__error'];
267 exit();
268 }
269
270 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
271 {
272 echo 'order_capture_failed';
273 exit();
274 }
275
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 }
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
299 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
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'] : '';
301 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
302
303 if(!$payer_email || !$pu_amount || !$pu_cc || !$pu_cap_id)
304 {
305 echo 'capture_missing_fields';
306 exit();
307 }
308
309 //260228 Normalize amount strings before comparison (e.g. 20 vs 20.00).
310 if(!empty($token['amount']) && number_format((float)$token['amount'], 2, '.', '') !== number_format((float)$pu_amount, 2, '.', ''))
311 {
312 echo 'amount_mismatch';
313 exit();
314 }
315 if(!empty($token['cc']) && strtoupper((string)$token['cc']) !== strtoupper((string)$pu_cc))
316 {
317 echo 'currency_mismatch';
318 exit();
319 }
320
321 $paypal = array(
322 'txn_type' => 'web_accept',
323 'payment_status' => 'Completed',
324 'txn_id' => $pu_cap_id,
325 'mc_gross' => $pu_amount,
326 'mc_currency' => $pu_cc,
327 'invoice' => (string)$token['invoice'],
328 'custom' => (string)$token['custom'],
329 'item_name' => (string)$token['item_name'],
330 'item_number' => (string)$token['item_number'],
331 'option_name1' => (string)$token['on0'],
332 'option_selection1' => (string)$token['os0'],
333 'option_name2' => (string)$token['on1'],
334 'option_selection2' => (string)$token['os1'],
335 'payer_email' => $payer_email,
336 'first_name' => $first_name,
337 'last_name' => $last_name,
338 );
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
344 $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
345 $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
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.
347
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();
351
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']))
361 {
362 if($is_redirect_mode)
363 echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
364 else
365 {
366 if(!headers_sent())
367 status_header(500);
368
369 echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
370 }
371 exit();
372 }
373
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
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
377
378 $return_url = (string)$token['return'];
379 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
380
381 $return_post = array_merge($paypal, array(
382 's2member_paypal_proxy' => 'paypal',
383 's2member_paypal_proxy_use' => $proxy_use,
384 ));
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
399 // Auto-POST into s2Member's existing PayPal return handler.
400 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
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.
402 foreach($return_post as $k => $v)
403 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
404 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
405 exit();
406 }
407 else
408 {
409 $subscription_id = !empty($_GET['subscription_id']) ? trim(stripslashes((string)$_GET['subscription_id'])) : '';
410 if(!$subscription_id)
411 {
412 echo 'missing_subscription_id';
413 exit();
414 }
415
416 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
417
418 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
419 'ppco' => 'checkout',
420 'env_setting' => $env_setting,
421 'event' => 'subscription_get_response',
422 'subscription_id' => $subscription_id,
423 'code' => !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0,
424 'body' => !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '',
425 'token' => $log_token,
426 ));
427
428 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
429 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
430
431 $subscription = array();
432 if($subscription_body)
433 $subscription = json_decode($subscription_body, true);
434
435 if(!is_array($subscription))
436 $subscription = array();
437
438 if($subscription_code < 200 || $subscription_code > 299 || empty($subscription['id']))
439 {
440 echo 'subscription_get_failed';
441 exit();
442 }
443
444 $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
445 if($custom_id && (string)$token['invoice'] && $custom_id !== (string)$token['invoice'])
446 {
447 echo 'subscription_custom_id_mismatch';
448 exit();
449 }
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
484 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
485 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
486 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
487
488 $paypal = array(
489 'txn_type' => 'subscr_signup',
490 'payment_status' => 'Completed',
491 'subscr_gateway' => 'paypal',
492
493 'txn_id' => $subscription_id,
494 'subscr_id' => $subscription_id,
495 'subscr_baid' => $subscription_id,
496 'subscr_cid' => $subscription_id,
497
498 'mc_gross' => (string)$token['amount'],
499 'mc_currency' => strtoupper((string)$token['cc']),
500
501 'period1' => (!empty($token['tp']) && !empty($token['tt'])) ? ((string)$token['tp'].' '.strtoupper((string)$token['tt'])) : '0 D',
502 'mc_amount1' => (!empty($token['tp']) && !empty($token['tt'])) ? (string)$token['ta'] : '0.00',
503
504 'period3' => ((string)$token['rp'].' '.strtoupper((string)$token['rt'])),
505 'mc_amount3' => (string)$token['amount'],
506 'recurring' => ((isset($token['rr']) && (string)$token['rr'] === '1') ? '1' : '0'),
507
508 'invoice' => (string)$token['invoice'],
509 'custom' => (string)$token['custom'],
510 'item_name' => (string)$token['item_name'],
511 'item_number' => (string)$token['item_number'],
512
513 'payer_email' => $subscriber_email,
514 'first_name' => $first_name,
515 'last_name' => $last_name,
516
517 'option_name1' => (string)$token['on0'],
518 'option_selection1' => (string)$token['os0'],
519 'option_name2' => (string)$token['on1'],
520 'option_selection2' => (string)$token['os1'],
521 );
522
523 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
524
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']))
529 {
530 echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
531 exit();
532 }
533
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);
537
538 $return_url2 = (string)$token['return'];
539 $return_url2 = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url2);
540
541 $return_post2 = array_merge($paypal, array(
542 's2member_paypal_proxy' => 'paypal',
543 's2member_paypal_proxy_use' => 'paypal_checkout',
544 ));
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
555 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
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.
557 foreach($return_post2 as $k => $v)
558 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
559 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
560 exit();
561 }
562 }
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
622 if($op === 'get_plan_id')
623 {
624 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
625 {
626 echo wp_json_encode(array('error' => 'not_subscription'));
627 exit();
628 }
629
630 $plan_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_plan_get_id($token);
631
632 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
633 'ppco' => 'checkout',
634 'env_setting' => $env_setting,
635 'event' => 'get_plan_id_response',
636 'plan_id' => $plan_id,
637 'token' => $log_token,
638 ));
639
640 if(!$plan_id)
641 {
642 echo wp_json_encode(array('error' => 'plan_create_failed'));
643 exit();
644 }
645
646 echo wp_json_encode(array('plan_id' => $plan_id));
647 exit();
648 }
649
650 if($op === 'confirm_subscription')
651 {
652 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
653 {
654 echo wp_json_encode(array('error' => 'not_subscription'));
655 exit();
656 }
657 $subscription_id = !empty($_POST['subscription_id']) ? trim(stripslashes((string)$_POST['subscription_id'])) : '';
658
659 if(!$subscription_id)
660 {
661 echo wp_json_encode(array('error' => 'missing_subscription_id'));
662 exit();
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
678 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
679
680 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
681 'ppco' => 'checkout',
682 'env_setting' => $env_setting,
683 'event' => 'subscription_get_response',
684 'subscription_id' => $subscription_id,
685 'subscription' => $subscription_r,
686 'token' => $log_token,
687 ));
688
689 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
690 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
691
692 $subscription = array();
693 if($subscription_body)
694 $subscription = json_decode($subscription_body, true);
695
696 if(!is_array($subscription))
697 $subscription = array();
698
699 if($subscription_code < 200 || $subscription_code > 299 || empty($subscription['id']))
700 {
701 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
702 'ppco' => 'checkout',
703 'env_setting' => $env_setting,
704 'event' => 'subscription_get_failed',
705 'subscription_id' => $subscription_id,
706 'code' => $subscription_code,
707 'body' => $subscription_body,
708 ));
709 echo wp_json_encode(array('error' => 'subscription_get_failed'));
710 exit();
711 }
712 $status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
713
714 $is_single_cycle = (isset($token['rr']) && (string)$token['rr'] === '0');
715 $allow_expired_single_cycle = false;
716
717 // PayPal can complete a single-cycle subscription immediately, returning status=EXPIRED after payment.
718 if($is_single_cycle && $status === 'EXPIRED')
719 {
720 $lpv = '';
721 $lpc = '';
722
723 if(!empty($subscription['billing_info']['last_payment']['amount']['value']))
724 $lpv = (string)$subscription['billing_info']['last_payment']['amount']['value'];
725
726 if(!empty($subscription['billing_info']['last_payment']['amount']['currency_code']))
727 $lpc = strtoupper((string)$subscription['billing_info']['last_payment']['amount']['currency_code']);
728
729 if($lpv !== '' && $lpc !== '')
730 $allow_expired_single_cycle = true;
731 }
732
733 if(!$status)
734 {
735 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
736 'ppco' => 'checkout',
737 'env_setting' => $env_setting,
738 'event' => 'subscription_status_invalid',
739 'subscription_id' => $subscription_id,
740 'status' => $status,
741 ));
742
743 echo wp_json_encode(array('error' => 'subscription_status_invalid'));
744 exit();
745 }
746 $expected_plan_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_plan_get_id($token);
747 if($expected_plan_id && !empty($subscription['plan_id']) && (string)$subscription['plan_id'] !== (string)$expected_plan_id)
748 {
749 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
750 'ppco' => 'checkout',
751 'env_setting' => $env_setting,
752 'event' => 'plan_mismatch',
753 'subscription_id' => $subscription_id,
754 'expected' => $expected_plan_id,
755 'actual' => (string)$subscription['plan_id'],
756 ));
757 echo wp_json_encode(array('error' => 'plan_mismatch'));
758 exit();
759 }
760 $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
761 if($custom_id && $custom_id !== (string)$token['invoice'])
762 {
763 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
764 'ppco' => 'checkout',
765 'env_setting' => $env_setting,
766 'event' => 'subscription_custom_id_mismatch',
767 'subscription_id' => $subscription_id,
768 'expected' => (string)$token['invoice'],
769 'actual' => $custom_id,
770 ));
771 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
772 exit();
773 }
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
833 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
834 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
835 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
836
837 $paypal = array(
838 'txn_type' => 'subscr_signup',
839 'payment_status' => 'Completed',
840 'subscr_gateway' => 'paypal',
841
842 'txn_id' => $subscription_id,
843 'subscr_id' => $subscription_id,
844 'subscr_baid' => $subscription_id,
845 'subscr_cid' => $subscription_id,
846
847 'mc_gross' => (string)$token['amount'],
848 'mc_currency' => strtoupper((string)$token['cc']),
849
850 'period1' => (!empty($token['tp']) && !empty($token['tt'])) ? ((string)$token['tp'].' '.strtoupper((string)$token['tt'])) : '0 D',
851 'mc_amount1' => (!empty($token['tp']) && !empty($token['tt'])) ? (string)$token['ta'] : '0.00',
852
853 'period3' => ((string)$token['rp'].' '.strtoupper((string)$token['rt'])),
854 'mc_amount3' => (string)$token['amount'],
855 'recurring' => ((isset($token['rr']) && (string)$token['rr'] === '1') ? '1' : '0'),
856
857 'invoice' => (string)$token['invoice'],
858 'custom' => (string)$token['custom'],
859 'item_name' => (string)$token['item_name'],
860 'item_number' => (string)$token['item_number'],
861
862 'payer_email' => $subscriber_email,
863 'first_name' => $first_name,
864 'last_name' => $last_name,
865
866 'option_name1' => (string)$token['on0'],
867 'option_selection1' => (string)$token['os0'],
868 'option_name2' => (string)$token['on1'],
869 'option_selection2' => (string)$token['os1'],
870 );
871
872 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
873
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']))
881 {
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();
895 }
896
897 if(!empty($notify_result['duplicate']))
898 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
899 'ppco' => 'checkout',
900 'env_setting' => $env_setting,
901 'event' => 'duplicate_subscription_ignored',
902 'subscription_id' => $subscription_id,
903 'option' => $option_ppco_subscr,
904 ));
905 else
906 {
907 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
908 'ppco' => 'checkout',
909 'env_setting' => $env_setting,
910 'event' => 'notify_proxy_response',
911 'subscription_id' => $subscription_id,
912 'code' => $notify_code,
913 'message' => $notify_msg,
914 'body' => $notify_body,
915 ));
916
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);
920 }
921
922 $return_url = (string)$token['return'];
923 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
924
925 $return_post = array_merge($paypal, array(
926 's2member_paypal_proxy' => 'paypal',
927 's2member_paypal_proxy_use' => 'paypal_checkout',
928 ));
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
942 echo wp_json_encode(array(
943 'rtn_url' => $return_url,
944 'rtn_post' => $return_post,
945 ));
946 exit();
947 }
948
949 if($op === 'cancel_subscription')
950 {
951 if(!is_user_logged_in())
952 {
953 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
954 'ppco' => 'checkout',
955 'env_setting' => $env_setting,
956 'event' => 'cancel_subscription_not_logged_in',
957 'token' => $log_token,
958 ));
959
960 echo wp_json_encode(array('error' => 'not_logged_in'));
961 exit();
962 }
963 $user_id = (int)get_current_user_id();
964
965 $nonce = !empty($_POST['s2member_paypal_checkout_nonce']) ? trim(stripslashes((string)$_POST['s2member_paypal_checkout_nonce'])) : '';
966 if(!$nonce || !wp_verify_nonce($nonce, 's2m_ppco_cancel_'.$user_id))
967 {
968 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
969 'ppco' => 'checkout',
970 'env_setting' => $env_setting,
971 'event' => 'cancel_subscription_bad_nonce',
972 'user_id'=> $user_id,
973 ));
974
975 echo wp_json_encode(array('error' => 'bad_nonce'));
976 exit();
977 }
978
979 $token_user_id = !empty($token['user_id']) ? (int)$token['user_id'] : 0;
980 $token_subscr_id = !empty($token['subscr_id']) ? (string)$token['subscr_id'] : '';
981
982 if(!$token_user_id || $token_user_id !== $user_id || !$token_subscr_id)
983 {
984 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
985 'ppco' => 'checkout',
986 'env_setting' => $env_setting,
987 'event' => 'cancel_subscription_token_mismatch',
988 'user_id' => $user_id,
989 'token' => $log_token,
990 ));
991
992 echo wp_json_encode(array('error' => 'token_mismatch'));
993 exit();
994 }
995
996 $subscr_id = (string)get_user_option('s2member_subscr_id', $user_id);
997 if(!$subscr_id || $subscr_id !== $token_subscr_id)
998 {
999 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1000 'ppco' => 'checkout',
1001 'env_setting' => $env_setting,
1002 'event' => 'cancel_subscription_user_mismatch',
1003 'user_id' => $user_id,
1004 'user_subscr'=> $subscr_id,
1005 'token_subscr'=> $token_subscr_id,
1006 ));
1007
1008 echo wp_json_encode(array('error' => 'user_mismatch'));
1009 exit();
1010 }
1011
1012 $reason = !empty($_POST['reason']) ? trim(stripslashes((string)$_POST['reason'])) : 'Cancelled by subscriber.';
1013 $reason = sanitize_text_field($reason);
1014 if(!$reason)
1015 $reason = 'Cancelled by subscriber.';
1016
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();
1020
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']);
1025
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 );
1035
1036 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1037 'ppco' => 'checkout',
1038 'env_setting' => $env_setting,
1039 'event' => 'cancel_subscription_response',
1040 'user_id' => $user_id,
1041 'subscr_id' => $subscr_id,
1042 'accepted' => $cancelled ? 1 : 0,
1043 ));
1044
1045 if($cancelled)
1046 {
1047 // Immediately feed s2Member's existing cancel handler (webhooks may be missing in MVP sites).
1048 $paypal = array(
1049 'txn_type' => 'subscr_cancel',
1050 'payment_status' => 'Completed',
1051 'subscr_gateway' => 'paypal',
1052
1053 'txn_id' => $subscr_id,
1054 'subscr_id' => $subscr_id,
1055 'custom' => (string)get_user_option('s2member_custom', $user_id),
1056
1057 // Help legacy notify logic resolve user in some fallback cases.
1058 'mp_id' => $subscr_id,
1059 'recurring_payment_id' => $subscr_id,
1060
1061 //260517 Provide safe defaults when signup vars are missing.
1062 'item_number' => (string)c_ws_plugin__s2member_user_access::user_access_level(wp_get_current_user()),
1063 'item_name' => 'PayPal Checkout Subscription',
1064
1065 // Best-effort payer email for logs/fallback logic.
1066 'payer_email' => (string)wp_get_current_user()->user_email,
1067 );
1068
1069 //260517 Enrich with stored signup vars so legacy cancel handler can match and compute EOT.
1070 if($ipn_signup_vars)
1071 {
1072 if(!empty($ipn_signup_vars['item_number']))
1073 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
1074
1075 if(!empty($ipn_signup_vars['item_name']))
1076 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
1077
1078 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
1079 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
1080
1081 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
1082 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
1083 }
1084
1085 $notify_url = home_url('/?s2member_paypal_notify=1');
1086 $notify_post = array_merge($paypal, array(
1087 'proxy_user_id' => $user_id, //260517
1088 'proxy_next_billing_time' => $next_billing_time, //260517
1089 's2member_paypal_proxy' => 'paypal',
1090 's2member_paypal_proxy_use' => 'paypal_checkout',
1091 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
1092 ));
1093
1094 $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
1095
1096 if(!is_array($notify_r))
1097 $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
1098
1099 $notify_code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
1100 if(!($notify_code >= 200 && $notify_code <= 299))
1101 {
1102 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1103 'ppco' => 'checkout',
1104 'env_setting' => $env_setting,
1105 'event' => 'cancel_subscription_notify_failed',
1106 'user_id' => $user_id,
1107 'subscr_id' => $subscr_id,
1108 'notify_code' => $notify_code,
1109 'notify_msg' => !empty($notify_r['message']) ? (string)$notify_r['message'] : '',
1110 ));
1111 }
1112
1113 echo wp_json_encode(array('ok' => 1));
1114 exit();
1115 }
1116
1117 echo wp_json_encode(array('error' => 'cancel_failed'));
1118 exit();
1119 }
1120
1121 if($op === 'create_order')
1122 {
1123 $order = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_create($token);
1124
1125 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1126 'ppco' => 'checkout',
1127 'env_setting' => $env_setting,
1128 'event' => 'create_order_response',
1129 'order' => $order,
1130 'token' => $log_token,
1131 ));
1132
1133 if(empty($order['id']))
1134 {
1135 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1136 'ppco' => 'checkout',
1137 'env_setting' => $env_setting,
1138 'event' => 'order_create_failed',
1139 'order' => $order,
1140 'token' => $log_token,
1141 ));
1142
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')));
1147 exit();
1148 }
1149 echo wp_json_encode(array('order_id' => $order['id']));
1150 exit();
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 }
1184 else if($op === 'capture_order')
1185 {
1186 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
1187
1188 if(!$order_id)
1189 {
1190 echo wp_json_encode(array('error' => 'missing_order_id'));
1191 exit();
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
1208 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
1209
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();
1211 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1212 'ppco' => 'checkout',
1213 'env_setting' => $env_setting,
1214 'event' => 'capture_response',
1215 'order_id' => $order_id,
1216 'status' => !empty($capture['status']) ? (string)$capture['status'] : '',
1217 'capture_id' => !empty($cap0['id']) ? (string)$cap0['id'] : '',
1218 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
1219 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
1220 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
1221 'capture' => $capture,
1222 'token' => $log_token,
1223 ));
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
1252 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
1253 {
1254 echo wp_json_encode(array('error' => 'order_capture_failed'));
1255 exit();
1256 }
1257
1258 /*
1259 * Build PayPal-like variables to feed s2Member's existing IPN + Return handlers.
1260 */
1261 $payer_email = !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '';
1262 $first_name = !empty($capture['payer']['name']['given_name']) ? (string)$capture['payer']['name']['given_name'] : '';
1263 $last_name = !empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '';
1264
1265 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
1266 $pu_cc = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code'] : '';
1267 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
1268
1269 if(!$payer_email || !$pu_amount || !$pu_cc || !$pu_cap_id)
1270 {
1271 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1272 'ppco' => 'checkout',
1273 'env_setting' => $env_setting,
1274 'event' => 'capture_missing_fields',
1275 'order_id' => $order_id,
1276 'capture' => $capture,
1277 'token' => $log_token,
1278 ));
1279
1280 echo wp_json_encode(array('error' => 'capture_missing_fields'));
1281 exit();
1282 }
1283
1284 // Extra safety: enforce token matches amount/currency/invoice/custom if provided.
1285 //260228 Normalize amount strings before comparison (e.g. 20 vs 20.00).
1286 if(!empty($token['amount']) && number_format((float)$token['amount'], 2, '.', '') !== number_format((float)$pu_amount, 2, '.', ''))
1287 {
1288 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1289 'ppco' => 'checkout',
1290 'env_setting' => $env_setting,
1291 'event' => 'amount_mismatch',
1292 'order_id' => $order_id,
1293 'token' => $log_token,
1294 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1295 ));
1296 echo wp_json_encode(array('error' => 'amount_mismatch'));
1297 exit();
1298 }
1299 if(!empty($token['cc']) && strtoupper((string)$token['cc']) !== strtoupper((string)$pu_cc))
1300 {
1301 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1302 'ppco' => 'checkout',
1303 'env_setting' => $env_setting,
1304 'event' => 'currency_mismatch',
1305 'order_id' => $order_id,
1306 'token' => $log_token,
1307 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1308 ));
1309 echo wp_json_encode(array('error' => 'currency_mismatch'));
1310 exit();
1311 }
1312 $cap_invoice_id = '';
1313 if(!empty($capture['purchase_units'][0]['invoice_id']))
1314 $cap_invoice_id = (string)$capture['purchase_units'][0]['invoice_id'];
1315 else if(!empty($capture['purchase_units'][0]['payments']['captures'][0]['invoice_id']))
1316 $cap_invoice_id = (string)$capture['purchase_units'][0]['payments']['captures'][0]['invoice_id'];
1317
1318 if($cap_invoice_id && $cap_invoice_id !== (string)$token['invoice'])
1319 {
1320 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1321 'ppco' => 'checkout',
1322 'env_setting' => $env_setting,
1323 'event' => 'invoice_mismatch',
1324 'order_id' => $order_id,
1325 'token' => $log_token,
1326 'invoice' => $cap_invoice_id,
1327 ));
1328 echo wp_json_encode(array('error' => 'invoice_mismatch'));
1329 exit();
1330 }
1331
1332 $cap_custom_id = '';
1333 if(!empty($capture['purchase_units'][0]['custom_id']))
1334 $cap_custom_id = (string)$capture['purchase_units'][0]['custom_id'];
1335 else if(!empty($capture['purchase_units'][0]['payments']['captures'][0]['custom_id']))
1336 $cap_custom_id = (string)$capture['purchase_units'][0]['payments']['captures'][0]['custom_id'];
1337
1338 if($cap_custom_id && !empty($token['custom']) && $cap_custom_id !== (string)$token['custom'])
1339 {
1340 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1341 'ppco' => 'checkout',
1342 'env_setting' => $env_setting,
1343 'event' => 'custom_mismatch',
1344 'order_id' => $order_id,
1345 'token' => $log_token,
1346 'custom' => array(
1347 'token' => !empty($token['custom']) ? $token['custom'] : '',
1348 'paypal' => $cap_custom_id,
1349 ),
1350 ));
1351 echo wp_json_encode(array('error' => 'custom_mismatch'));
1352 exit();
1353 }
1354
1355 $paypal = array(
1356 'txn_type' => 'web_accept',
1357 'payment_status' => 'Completed',
1358 'subscr_gateway' => 'paypal',
1359
1360 'txn_id' => $pu_cap_id,
1361 'subscr_id' => $pu_cap_id,
1362 'subscr_baid' => $pu_cap_id,
1363 'subscr_cid' => $pu_cap_id,
1364
1365 'mc_gross' => $pu_amount,
1366 'mc_currency' => strtoupper($pu_cc),
1367
1368 'invoice' => (string)$token['invoice'],
1369 'custom' => (string)$token['custom'],
1370 'item_name' => (string)$token['item_name'],
1371 'item_number' => (string)$token['item_number'],
1372
1373 'payer_email' => $payer_email,
1374 'first_name' => $first_name,
1375 'last_name' => $last_name,
1376
1377 // Preserve s2Member's tracking option fields.
1378 'option_name1' => (string)$token['on0'],
1379 'option_selection1' => (string)$token['os0'],
1380 'option_name2' => (string)$token['on1'],
1381 'option_selection2' => (string)$token['os1'],
1382 );
1383
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'];
1387
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']))
1404 {
1405 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1406 'ppco' => 'checkout',
1407 'env_setting' => $env_setting,
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'] : '',
1414 ));
1415 echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
1416 exit();
1417 }
1418
1419 if(!empty($notify_result['processed']))
1420 {
1421 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1422 'ppco' => 'checkout',
1423 'env_setting' => $env_setting,
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'] : '',
1430 ));
1431
1432 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
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
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
1435 }
1436
1437 // 2) Send the user through the existing Return handler via POST (sets cookies, thank-you UX, reg tokens, etc).
1438 $return_url = (string)$token['return'];
1439 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
1440
1441 $return_post = array_merge($paypal, array(
1442 's2member_paypal_proxy' => 'paypal',
1443 's2member_paypal_proxy_use' => $proxy_use,
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;
1461
1462 echo wp_json_encode(array(
1463 'rtn_url' => $return_url,
1464 'rtn_post' => $return_post,
1465 ));
1466 exit();
1467 }
1468
1469 echo wp_json_encode(array('error' => 'unknown_op'));
1470 exit();
1471 }
1472 }
1473 }
1474