PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260917
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260917
260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 120213 All 189 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 260917, at src/includes/classes/paypal-checkout-in.inc.php

1,358 lines 60.8 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 if(!empty($token['exp']) && is_numeric($token['exp']) && time() > (int)$token['exp'])
81 {
82 echo wp_json_encode(array('error' => 'token_expired'));
83 exit();
84 }
85 if(empty($token['invoice']) || empty($token['ip']) || empty($token['item_number']) || empty($token['checksum']))
86 {
87 echo wp_json_encode(array('error' => 'token_incomplete'));
88 exit();
89 }
90 if($token['checksum'] !== md5($token['invoice'].$token['ip'].$token['item_number']))
91 {
92 echo wp_json_encode(array('error' => 'token_checksum_mismatch'));
93 exit();
94 }
95
96 if($token['ip'] !== c_ws_plugin__s2member_utils_ip::current())
97 {
98 //260414 PayPal Checkout browser returns can legitimately arrive with a different client IP; log it, but do not fail the token.
99 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
100 'ppco' => 'checkout',
101 'env_setting' => $env_setting,
102 'event' => 'token_ip_mismatch',
103 'token' => $token,
104 'ip' => c_ws_plugin__s2member_utils_ip::current(),
105 ));
106 }
107
108 $old__subscr_gateway = !empty($token['old__subscr_gateway']) ? (string)$token['old__subscr_gateway'] : '';
109 $old__subscr_id = !empty($token['old__subscr_id']) ? (string)$token['old__subscr_id'] : '';
110 $old__subscr_baid = !empty($token['old__subscr_baid']) ? (string)$token['old__subscr_baid'] : '';
111 $old__subscr_cid = !empty($token['old__subscr_cid']) ? (string)$token['old__subscr_cid'] : '';
112 $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.
113
114 // output="anchor|url" support: redirect-mode endpoints (GET).
115 if($op === 'redirect' || $op === 'return' || $op === 'cancel')
116 {
117 // NOTE: These endpoints are intended for output="anchor|url" shortcode formats.
118 // They redirect to PayPal approval URLs, then auto-POST into s2Member's existing PayPal notify + return handlers.
119
120 if($op === 'cancel')
121 {
122 $cancel = !empty($token['cancel']) ? (string)$token['cancel'] : home_url('/');
123 $cancel = wp_validate_redirect($cancel, home_url('/'));
124 wp_redirect($cancel);
125 exit();
126 }
127
128 $endpoint = home_url('/?s2member_paypal_checkout=1');
129 $return_url = $endpoint.'&s2member_paypal_checkout_op=return&s2member_paypal_checkout_t='.rawurlencode($t);
130 $cancel_url = $endpoint.'&s2member_paypal_checkout_op=cancel&s2member_paypal_checkout_t='.rawurlencode($t);
131
132 if($op === 'redirect')
133 {
134 $pp_token = $token;
135 $pp_token['return'] = $return_url;
136 $pp_token['cancel'] = $cancel_url;
137
138 if((!isset($pp_token['rr']) || (string)$pp_token['rr'] === '') || strtoupper((string)$pp_token['rr']) === 'BN')
139 {
140 $order = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_create($pp_token);
141
142 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
143 'ppco' => 'checkout',
144 'env_setting' => $env_setting,
145 'event' => 'redirect_order_create_response',
146 'order' => $order,
147 'token' => $token,
148 ));
149
150 $approve_url = '';
151 if(!empty($order['links']) && is_array($order['links']))
152 foreach($order['links'] as $link)
153 if(!empty($link['rel']) && !empty($link['href']))
154 {
155 $rel = strtolower((string)$link['rel']);
156 if($rel === 'approve' || $rel === 'payer-action' || $rel === 'approval_url')
157 $approve_url = (string)$link['href'];
158 }
159
160 if(!$approve_url)
161 {
162 echo 'order_approval_url_missing';
163 exit();
164 }
165
166 wp_redirect($approve_url);
167 exit();
168 }
169 else
170 {
171 $subscription = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_create($pp_token);
172
173 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
174 'ppco' => 'checkout',
175 'env_setting' => $env_setting,
176 'event' => 'redirect_subscription_create_response',
177 'subscription' => $subscription,
178 'token' => $token,
179 ));
180
181 $approve_url = '';
182 if(!empty($subscription['links']) && is_array($subscription['links']))
183 foreach($subscription['links'] as $link)
184 if(!empty($link['rel']) && !empty($link['href']) && strtolower((string)$link['rel']) === 'approve')
185 $approve_url = (string)$link['href'];
186
187 if(!$approve_url)
188 {
189 echo 'subscription_approval_url_missing';
190 exit();
191 }
192
193 wp_redirect($approve_url);
194 exit();
195 }
196 }
197
198 // Return URL: PayPal redirects here after approval.
199 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
200 {
201 $order_id = !empty($_GET['token']) ? trim(stripslashes((string)$_GET['token'])) : '';
202 if(!$order_id)
203 {
204 echo 'missing_order_id';
205 exit();
206 }
207
208 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
209
210 $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();
211 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
212 'ppco' => 'checkout',
213 'env_setting' => $env_setting,
214 'event' => 'capture_response',
215 'order_id' => $order_id,
216 'status' => !empty($capture['status']) ? (string)$capture['status'] : '',
217 'capture_id' => !empty($cap0['id']) ? (string)$cap0['id'] : '',
218 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
219 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
220 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
221 'capture' => $capture,
222 'token' => $token,
223 ));
224
225 if(!empty($capture['__error']))
226 {
227 echo (string)$capture['__error'];
228 exit();
229 }
230
231 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
232 {
233 echo 'order_capture_failed';
234 exit();
235 }
236
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'] : '');
242
243 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
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'] : '';
245 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
246
247 if(!$payer_email || !$pu_amount || !$pu_cc || !$pu_cap_id)
248 {
249 echo 'capture_missing_fields';
250 exit();
251 }
252
253 //260228 Normalize amount strings before comparison (e.g. 20 vs 20.00).
254 if(!empty($token['amount']) && number_format((float)$token['amount'], 2, '.', '') !== number_format((float)$pu_amount, 2, '.', ''))
255 {
256 echo 'amount_mismatch';
257 exit();
258 }
259 if(!empty($token['cc']) && strtoupper((string)$token['cc']) !== strtoupper((string)$pu_cc))
260 {
261 echo 'currency_mismatch';
262 exit();
263 }
264
265 $paypal = array(
266 'txn_type' => 'web_accept',
267 'payment_status' => 'Completed',
268 'txn_id' => $pu_cap_id,
269 'mc_gross' => $pu_amount,
270 'mc_currency' => $pu_cc,
271 'invoice' => (string)$token['invoice'],
272 'custom' => (string)$token['custom'],
273 'item_name' => (string)$token['item_name'],
274 'item_number' => (string)$token['item_number'],
275 'option_name1' => (string)$token['on0'],
276 'option_selection1' => (string)$token['os0'],
277 'option_name2' => (string)$token['on1'],
278 'option_selection2' => (string)$token['os1'],
279 'payer_email' => $payer_email,
280 'first_name' => $first_name,
281 'last_name' => $last_name,
282 );
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
288 $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
289 $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
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.
291
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();
295
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']))
305 {
306 if($is_redirect_mode)
307 echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
308 else
309 {
310 if(!headers_sent())
311 status_header(500);
312
313 echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
314 }
315 exit();
316 }
317
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
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
321
322 $return_url = (string)$token['return'];
323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
324
325 $return_post = array_merge($paypal, array(
326 's2member_paypal_proxy' => 'paypal',
327 's2member_paypal_proxy_use' => $proxy_use,
328 ));
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
343 // Auto-POST into s2Member's existing PayPal return handler.
344 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
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.
346 foreach($return_post as $k => $v)
347 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
348 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
349 exit();
350 }
351 else
352 {
353 $subscription_id = !empty($_GET['subscription_id']) ? trim(stripslashes((string)$_GET['subscription_id'])) : '';
354 if(!$subscription_id)
355 {
356 echo 'missing_subscription_id';
357 exit();
358 }
359
360 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
361
362 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
363 'ppco' => 'checkout',
364 'env_setting' => $env_setting,
365 'event' => 'subscription_get_response',
366 'subscription_id' => $subscription_id,
367 'code' => !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0,
368 'body' => !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '',
369 'token' => $token,
370 ));
371
372 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
373 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
374
375 $subscription = array();
376 if($subscription_body)
377 $subscription = json_decode($subscription_body, true);
378
379 if(!is_array($subscription))
380 $subscription = array();
381
382 if($subscription_code < 200 || $subscription_code > 299 || empty($subscription['id']))
383 {
384 echo 'subscription_get_failed';
385 exit();
386 }
387
388 $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
389 if($custom_id && (string)$token['invoice'] && $custom_id !== (string)$token['invoice'])
390 {
391 echo 'subscription_custom_id_mismatch';
392 exit();
393 }
394
395 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
396 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
397 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
398
399 $paypal = array(
400 'txn_type' => 'subscr_signup',
401 'payment_status' => 'Completed',
402 'subscr_gateway' => 'paypal',
403
404 'txn_id' => $subscription_id,
405 'subscr_id' => $subscription_id,
406 'subscr_baid' => $subscription_id,
407 'subscr_cid' => $subscription_id,
408
409 'mc_gross' => (string)$token['amount'],
410 'mc_currency' => strtoupper((string)$token['cc']),
411
412 'period1' => (!empty($token['tp']) && !empty($token['tt'])) ? ((string)$token['tp'].' '.strtoupper((string)$token['tt'])) : '0 D',
413 'mc_amount1' => (!empty($token['tp']) && !empty($token['tt'])) ? (string)$token['ta'] : '0.00',
414
415 'period3' => ((string)$token['rp'].' '.strtoupper((string)$token['rt'])),
416 'mc_amount3' => (string)$token['amount'],
417 'recurring' => ((isset($token['rr']) && (string)$token['rr'] === '1') ? '1' : '0'),
418
419 'invoice' => (string)$token['invoice'],
420 'custom' => (string)$token['custom'],
421 'item_name' => (string)$token['item_name'],
422 'item_number' => (string)$token['item_number'],
423
424 'payer_email' => $subscriber_email,
425 'first_name' => $first_name,
426 'last_name' => $last_name,
427
428 'option_name1' => (string)$token['on0'],
429 'option_selection1' => (string)$token['os0'],
430 'option_name2' => (string)$token['on1'],
431 'option_selection2' => (string)$token['os1'],
432 );
433
434 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
435
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']))
440 {
441 echo !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed';
442 exit();
443 }
444
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);
448
449 $return_url2 = (string)$token['return'];
450 $return_url2 = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url2);
451
452 $return_post2 = array_merge($paypal, array(
453 's2member_paypal_proxy' => 'paypal',
454 's2member_paypal_proxy_use' => 'paypal_checkout',
455 ));
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
466 echo '<!DOCTYPE html><html><head><meta charset="utf-8" /><meta name="robots" content="noindex,nofollow" /></head><body>';
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.
468 foreach($return_post2 as $k => $v)
469 echo '<input type="hidden" name="'.esc_attr($k).'" value="'.esc_attr((string)$v).'" />';
470 echo '</form><script type="text/javascript">document.getElementById("s2m_ppco_rtn").submit();</script></body></html>';
471 exit();
472 }
473 }
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::get($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
533 if($op === 'get_plan_id')
534 {
535 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
536 {
537 echo wp_json_encode(array('error' => 'not_subscription'));
538 exit();
539 }
540
541 $plan_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_plan_get_id($token);
542
543 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
544 'ppco' => 'checkout',
545 'env_setting' => $env_setting,
546 'event' => 'get_plan_id_response',
547 'plan_id' => $plan_id,
548 'token' => $token,
549 ));
550
551 if(!$plan_id)
552 {
553 echo wp_json_encode(array('error' => 'plan_create_failed'));
554 exit();
555 }
556
557 echo wp_json_encode(array('plan_id' => $plan_id));
558 exit();
559 }
560
561 if($op === 'confirm_subscription')
562 {
563 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
564 {
565 echo wp_json_encode(array('error' => 'not_subscription'));
566 exit();
567 }
568 $subscription_id = !empty($_POST['subscription_id']) ? trim(stripslashes((string)$_POST['subscription_id'])) : '';
569
570 if(!$subscription_id)
571 {
572 echo wp_json_encode(array('error' => 'missing_subscription_id'));
573 exit();
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::get($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
589 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
590
591 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
592 'ppco' => 'checkout',
593 'env_setting' => $env_setting,
594 'event' => 'subscription_get_response',
595 'subscription_id' => $subscription_id,
596 'subscription' => $subscription_r,
597 'token' => $token,
598 ));
599
600 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
601 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
602
603 $subscription = array();
604 if($subscription_body)
605 $subscription = json_decode($subscription_body, true);
606
607 if(!is_array($subscription))
608 $subscription = array();
609
610 if($subscription_code < 200 || $subscription_code > 299 || empty($subscription['id']))
611 {
612 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
613 'ppco' => 'checkout',
614 'env_setting' => $env_setting,
615 'event' => 'subscription_get_failed',
616 'subscription_id' => $subscription_id,
617 'code' => $subscription_code,
618 'body' => $subscription_body,
619 ));
620 echo wp_json_encode(array('error' => 'subscription_get_failed'));
621 exit();
622 }
623 $status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
624
625 $is_single_cycle = (isset($token['rr']) && (string)$token['rr'] === '0');
626 $allow_expired_single_cycle = false;
627
628 // PayPal can complete a single-cycle subscription immediately, returning status=EXPIRED after payment.
629 if($is_single_cycle && $status === 'EXPIRED')
630 {
631 $lpv = '';
632 $lpc = '';
633
634 if(!empty($subscription['billing_info']['last_payment']['amount']['value']))
635 $lpv = (string)$subscription['billing_info']['last_payment']['amount']['value'];
636
637 if(!empty($subscription['billing_info']['last_payment']['amount']['currency_code']))
638 $lpc = strtoupper((string)$subscription['billing_info']['last_payment']['amount']['currency_code']);
639
640 if($lpv !== '' && $lpc !== '')
641 $allow_expired_single_cycle = true;
642 }
643
644 if(!$status)
645 {
646 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
647 'ppco' => 'checkout',
648 'env_setting' => $env_setting,
649 'event' => 'subscription_status_invalid',
650 'subscription_id' => $subscription_id,
651 'status' => $status,
652 ));
653
654 echo wp_json_encode(array('error' => 'subscription_status_invalid'));
655 exit();
656 }
657 $expected_plan_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_plan_get_id($token);
658 if($expected_plan_id && !empty($subscription['plan_id']) && (string)$subscription['plan_id'] !== (string)$expected_plan_id)
659 {
660 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
661 'ppco' => 'checkout',
662 'env_setting' => $env_setting,
663 'event' => 'plan_mismatch',
664 'subscription_id' => $subscription_id,
665 'expected' => $expected_plan_id,
666 'actual' => (string)$subscription['plan_id'],
667 ));
668 echo wp_json_encode(array('error' => 'plan_mismatch'));
669 exit();
670 }
671 $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
672 if($custom_id && $custom_id !== (string)$token['invoice'])
673 {
674 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
675 'ppco' => 'checkout',
676 'env_setting' => $env_setting,
677 'event' => 'subscription_custom_id_mismatch',
678 'subscription_id' => $subscription_id,
679 'expected' => (string)$token['invoice'],
680 'actual' => $custom_id,
681 ));
682 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
683 exit();
684 }
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
726 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
727 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
728 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
729
730 $paypal = array(
731 'txn_type' => 'subscr_signup',
732 'payment_status' => 'Completed',
733 'subscr_gateway' => 'paypal',
734
735 'txn_id' => $subscription_id,
736 'subscr_id' => $subscription_id,
737 'subscr_baid' => $subscription_id,
738 'subscr_cid' => $subscription_id,
739
740 'mc_gross' => (string)$token['amount'],
741 'mc_currency' => strtoupper((string)$token['cc']),
742
743 'period1' => (!empty($token['tp']) && !empty($token['tt'])) ? ((string)$token['tp'].' '.strtoupper((string)$token['tt'])) : '0 D',
744 'mc_amount1' => (!empty($token['tp']) && !empty($token['tt'])) ? (string)$token['ta'] : '0.00',
745
746 'period3' => ((string)$token['rp'].' '.strtoupper((string)$token['rt'])),
747 'mc_amount3' => (string)$token['amount'],
748 'recurring' => ((isset($token['rr']) && (string)$token['rr'] === '1') ? '1' : '0'),
749
750 'invoice' => (string)$token['invoice'],
751 'custom' => (string)$token['custom'],
752 'item_name' => (string)$token['item_name'],
753 'item_number' => (string)$token['item_number'],
754
755 'payer_email' => $subscriber_email,
756 'first_name' => $first_name,
757 'last_name' => $last_name,
758
759 'option_name1' => (string)$token['on0'],
760 'option_selection1' => (string)$token['os0'],
761 'option_name2' => (string)$token['on1'],
762 'option_selection2' => (string)$token['os1'],
763 );
764
765 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
766
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']))
774 {
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();
788 }
789
790 if(!empty($notify_result['duplicate']))
791 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
792 'ppco' => 'checkout',
793 'env_setting' => $env_setting,
794 'event' => 'duplicate_subscription_ignored',
795 'subscription_id' => $subscription_id,
796 'option' => $option_ppco_subscr,
797 ));
798 else
799 {
800 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
801 'ppco' => 'checkout',
802 'env_setting' => $env_setting,
803 'event' => 'notify_proxy_response',
804 'subscription_id' => $subscription_id,
805 'code' => $notify_code,
806 'message' => $notify_msg,
807 'body' => $notify_body,
808 ));
809
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);
813 }
814
815 $return_url = (string)$token['return'];
816 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
817
818 $return_post = array_merge($paypal, array(
819 's2member_paypal_proxy' => 'paypal',
820 's2member_paypal_proxy_use' => 'paypal_checkout',
821 ));
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
835 echo wp_json_encode(array(
836 'rtn_url' => $return_url,
837 'rtn_post' => $return_post,
838 ));
839 exit();
840 }
841
842 if($op === 'cancel_subscription')
843 {
844 if(!is_user_logged_in())
845 {
846 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
847 'ppco' => 'checkout',
848 'env_setting' => $env_setting,
849 'event' => 'cancel_subscription_not_logged_in',
850 'token' => $token,
851 ));
852
853 echo wp_json_encode(array('error' => 'not_logged_in'));
854 exit();
855 }
856 $user_id = (int)get_current_user_id();
857
858 $nonce = !empty($_POST['s2member_paypal_checkout_nonce']) ? trim(stripslashes((string)$_POST['s2member_paypal_checkout_nonce'])) : '';
859 if(!$nonce || !wp_verify_nonce($nonce, 's2m_ppco_cancel_'.$user_id))
860 {
861 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
862 'ppco' => 'checkout',
863 'env_setting' => $env_setting,
864 'event' => 'cancel_subscription_bad_nonce',
865 'user_id'=> $user_id,
866 ));
867
868 echo wp_json_encode(array('error' => 'bad_nonce'));
869 exit();
870 }
871
872 $token_user_id = !empty($token['user_id']) ? (int)$token['user_id'] : 0;
873 $token_subscr_id = !empty($token['subscr_id']) ? (string)$token['subscr_id'] : '';
874
875 if(!$token_user_id || $token_user_id !== $user_id || !$token_subscr_id)
876 {
877 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
878 'ppco' => 'checkout',
879 'env_setting' => $env_setting,
880 'event' => 'cancel_subscription_token_mismatch',
881 'user_id' => $user_id,
882 'token' => $token,
883 ));
884
885 echo wp_json_encode(array('error' => 'token_mismatch'));
886 exit();
887 }
888
889 $subscr_id = (string)get_user_option('s2member_subscr_id', $user_id);
890 if(!$subscr_id || $subscr_id !== $token_subscr_id)
891 {
892 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
893 'ppco' => 'checkout',
894 'env_setting' => $env_setting,
895 'event' => 'cancel_subscription_user_mismatch',
896 'user_id' => $user_id,
897 'user_subscr'=> $subscr_id,
898 'token_subscr'=> $token_subscr_id,
899 ));
900
901 echo wp_json_encode(array('error' => 'user_mismatch'));
902 exit();
903 }
904
905 $reason = !empty($_POST['reason']) ? trim(stripslashes((string)$_POST['reason'])) : 'Cancelled by subscriber.';
906 $reason = sanitize_text_field($reason);
907 if(!$reason)
908 $reason = 'Cancelled by subscriber.';
909
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();
913
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']);
918
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 );
928
929 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
930 'ppco' => 'checkout',
931 'env_setting' => $env_setting,
932 'event' => 'cancel_subscription_response',
933 'user_id' => $user_id,
934 'subscr_id' => $subscr_id,
935 'accepted' => $cancelled ? 1 : 0,
936 ));
937
938 if($cancelled)
939 {
940 // Immediately feed s2Member's existing cancel handler (webhooks may be missing in MVP sites).
941 $paypal = array(
942 'txn_type' => 'subscr_cancel',
943 'payment_status' => 'Completed',
944 'subscr_gateway' => 'paypal',
945
946 'txn_id' => $subscr_id,
947 'subscr_id' => $subscr_id,
948 'custom' => (string)get_user_option('s2member_custom', $user_id),
949
950 // Help legacy notify logic resolve user in some fallback cases.
951 'mp_id' => $subscr_id,
952 'recurring_payment_id' => $subscr_id,
953
954 //260517 Provide safe defaults when signup vars are missing.
955 'item_number' => (string)c_ws_plugin__s2member_user_access::user_access_level(wp_get_current_user()),
956 'item_name' => 'PayPal Checkout Subscription',
957
958 // Best-effort payer email for logs/fallback logic.
959 'payer_email' => (string)wp_get_current_user()->user_email,
960 );
961
962 //260517 Enrich with stored signup vars so legacy cancel handler can match and compute EOT.
963 if($ipn_signup_vars)
964 {
965 if(!empty($ipn_signup_vars['item_number']))
966 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
967
968 if(!empty($ipn_signup_vars['item_name']))
969 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
970
971 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
972 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
973
974 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
975 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
976 }
977
978 $notify_url = home_url('/?s2member_paypal_notify=1');
979 $notify_post = array_merge($paypal, array(
980 'proxy_user_id' => $user_id, //260517
981 'proxy_next_billing_time' => $next_billing_time, //260517
982 's2member_paypal_proxy' => 'paypal',
983 's2member_paypal_proxy_use' => 'paypal_checkout',
984 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
985 ));
986
987 $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
988
989 if(!is_array($notify_r))
990 $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
991
992 $notify_code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
993 if(!($notify_code >= 200 && $notify_code <= 299))
994 {
995 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
996 'ppco' => 'checkout',
997 'env_setting' => $env_setting,
998 'event' => 'cancel_subscription_notify_failed',
999 'user_id' => $user_id,
1000 'subscr_id' => $subscr_id,
1001 'notify_code' => $notify_code,
1002 'notify_msg' => !empty($notify_r['message']) ? (string)$notify_r['message'] : '',
1003 ));
1004 }
1005
1006 echo wp_json_encode(array('ok' => 1));
1007 exit();
1008 }
1009
1010 echo wp_json_encode(array('error' => 'cancel_failed'));
1011 exit();
1012 }
1013
1014 if($op === 'create_order')
1015 {
1016 $order = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_create($token);
1017
1018 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1019 'ppco' => 'checkout',
1020 'env_setting' => $env_setting,
1021 'event' => 'create_order_response',
1022 'order' => $order,
1023 'token' => $token,
1024 ));
1025
1026 if(empty($order['id']))
1027 {
1028 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1029 'ppco' => 'checkout',
1030 'env_setting' => $env_setting,
1031 'event' => 'order_create_failed',
1032 'order' => $order,
1033 'token' => $token,
1034 ));
1035
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')));
1040 exit();
1041 }
1042 echo wp_json_encode(array('order_id' => $order['id']));
1043 exit();
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::get($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 }
1068 else if($op === 'capture_order')
1069 {
1070 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
1071
1072 if(!$order_id)
1073 {
1074 echo wp_json_encode(array('error' => 'missing_order_id'));
1075 exit();
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::get($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
1092 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
1093
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();
1095 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1096 'ppco' => 'checkout',
1097 'env_setting' => $env_setting,
1098 'event' => 'capture_response',
1099 'order_id' => $order_id,
1100 'status' => !empty($capture['status']) ? (string)$capture['status'] : '',
1101 'capture_id' => !empty($cap0['id']) ? (string)$cap0['id'] : '',
1102 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
1103 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
1104 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
1105 'capture' => $capture,
1106 'token' => $token,
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 }
1129
1130 if(!empty($capture['__error']))
1131 {
1132 echo wp_json_encode(array('error' => (string)$capture['__error']));
1133 exit();
1134 }
1135
1136 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
1137 {
1138 echo wp_json_encode(array('error' => 'order_capture_failed'));
1139 exit();
1140 }
1141
1142 /*
1143 * Build PayPal-like variables to feed s2Member's existing IPN + Return handlers.
1144 */
1145 $payer_email = !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '';
1146 $first_name = !empty($capture['payer']['name']['given_name']) ? (string)$capture['payer']['name']['given_name'] : '';
1147 $last_name = !empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '';
1148
1149 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
1150 $pu_cc = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code'] : '';
1151 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
1152
1153 if(!$payer_email || !$pu_amount || !$pu_cc || !$pu_cap_id)
1154 {
1155 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1156 'ppco' => 'checkout',
1157 'env_setting' => $env_setting,
1158 'event' => 'capture_missing_fields',
1159 'order_id' => $order_id,
1160 'capture' => $capture,
1161 'token' => $token,
1162 ));
1163
1164 echo wp_json_encode(array('error' => 'capture_missing_fields'));
1165 exit();
1166 }
1167
1168 // Extra safety: enforce token matches amount/currency/invoice/custom if provided.
1169 //260228 Normalize amount strings before comparison (e.g. 20 vs 20.00).
1170 if(!empty($token['amount']) && number_format((float)$token['amount'], 2, '.', '') !== number_format((float)$pu_amount, 2, '.', ''))
1171 {
1172 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1173 'ppco' => 'checkout',
1174 'env_setting' => $env_setting,
1175 'event' => 'amount_mismatch',
1176 'order_id' => $order_id,
1177 'token' => $token,
1178 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1179 ));
1180 echo wp_json_encode(array('error' => 'amount_mismatch'));
1181 exit();
1182 }
1183 if(!empty($token['cc']) && strtoupper((string)$token['cc']) !== strtoupper((string)$pu_cc))
1184 {
1185 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1186 'ppco' => 'checkout',
1187 'env_setting' => $env_setting,
1188 'event' => 'currency_mismatch',
1189 'order_id' => $order_id,
1190 'token' => $token,
1191 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1192 ));
1193 echo wp_json_encode(array('error' => 'currency_mismatch'));
1194 exit();
1195 }
1196 $cap_invoice_id = '';
1197 if(!empty($capture['purchase_units'][0]['invoice_id']))
1198 $cap_invoice_id = (string)$capture['purchase_units'][0]['invoice_id'];
1199 else if(!empty($capture['purchase_units'][0]['payments']['captures'][0]['invoice_id']))
1200 $cap_invoice_id = (string)$capture['purchase_units'][0]['payments']['captures'][0]['invoice_id'];
1201
1202 if($cap_invoice_id && $cap_invoice_id !== (string)$token['invoice'])
1203 {
1204 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1205 'ppco' => 'checkout',
1206 'env_setting' => $env_setting,
1207 'event' => 'invoice_mismatch',
1208 'order_id' => $order_id,
1209 'token' => $token,
1210 'invoice' => $cap_invoice_id,
1211 ));
1212 echo wp_json_encode(array('error' => 'invoice_mismatch'));
1213 exit();
1214 }
1215
1216 $cap_custom_id = '';
1217 if(!empty($capture['purchase_units'][0]['custom_id']))
1218 $cap_custom_id = (string)$capture['purchase_units'][0]['custom_id'];
1219 else if(!empty($capture['purchase_units'][0]['payments']['captures'][0]['custom_id']))
1220 $cap_custom_id = (string)$capture['purchase_units'][0]['payments']['captures'][0]['custom_id'];
1221
1222 if($cap_custom_id && !empty($token['custom']) && $cap_custom_id !== (string)$token['custom'])
1223 {
1224 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1225 'ppco' => 'checkout',
1226 'env_setting' => $env_setting,
1227 'event' => 'custom_mismatch',
1228 'order_id' => $order_id,
1229 'token' => $token,
1230 'custom' => array(
1231 'token' => !empty($token['custom']) ? $token['custom'] : '',
1232 'paypal' => $cap_custom_id,
1233 ),
1234 ));
1235 echo wp_json_encode(array('error' => 'custom_mismatch'));
1236 exit();
1237 }
1238
1239 $paypal = array(
1240 'txn_type' => 'web_accept',
1241 'payment_status' => 'Completed',
1242 'subscr_gateway' => 'paypal',
1243
1244 'txn_id' => $pu_cap_id,
1245 'subscr_id' => $pu_cap_id,
1246 'subscr_baid' => $pu_cap_id,
1247 'subscr_cid' => $pu_cap_id,
1248
1249 'mc_gross' => $pu_amount,
1250 'mc_currency' => strtoupper($pu_cc),
1251
1252 'invoice' => (string)$token['invoice'],
1253 'custom' => (string)$token['custom'],
1254 'item_name' => (string)$token['item_name'],
1255 'item_number' => (string)$token['item_number'],
1256
1257 'payer_email' => $payer_email,
1258 'first_name' => $first_name,
1259 'last_name' => $last_name,
1260
1261 // Preserve s2Member's tracking option fields.
1262 'option_name1' => (string)$token['on0'],
1263 'option_selection1' => (string)$token['os0'],
1264 'option_name2' => (string)$token['on1'],
1265 'option_selection2' => (string)$token['os1'],
1266 );
1267
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'];
1271
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']))
1288 {
1289 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1290 'ppco' => 'checkout',
1291 'env_setting' => $env_setting,
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'] : '',
1298 ));
1299 echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
1300 exit();
1301 }
1302
1303 if(!empty($notify_result['processed']))
1304 {
1305 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1306 'ppco' => 'checkout',
1307 'env_setting' => $env_setting,
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'] : '',
1314 ));
1315
1316 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
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
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
1319 }
1320
1321 // 2) Send the user through the existing Return handler via POST (sets cookies, thank-you UX, reg tokens, etc).
1322 $return_url = (string)$token['return'];
1323 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
1324
1325 $return_post = array_merge($paypal, array(
1326 's2member_paypal_proxy' => 'paypal',
1327 's2member_paypal_proxy_use' => $proxy_use,
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;
1345
1346 echo wp_json_encode(array(
1347 'rtn_url' => $return_url,
1348 'rtn_post' => $return_post,
1349 ));
1350 exit();
1351 }
1352
1353 echo wp_json_encode(array('error' => 'unknown_op'));
1354 exit();
1355 }
1356 }
1357 }
1358