PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260829
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260829
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 260829, at src/includes/classes/paypal-checkout-in.inc.php

1,184 lines 50.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): 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 === 'get_plan_id')
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 $plan_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_plan_get_id($token);
484
485 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
486 'ppco' => 'checkout',
487 'env_setting' => $env_setting,
488 'event' => 'get_plan_id_response',
489 'plan_id' => $plan_id,
490 'token' => $token,
491 ));
492
493 if(!$plan_id)
494 {
495 echo wp_json_encode(array('error' => 'plan_create_failed'));
496 exit();
497 }
498
499 echo wp_json_encode(array('plan_id' => $plan_id));
500 exit();
501 }
502
503 if($op === 'confirm_subscription')
504 {
505 if((!isset($token['rr']) || (string)$token['rr'] === '') || strtoupper((string)$token['rr']) === 'BN')
506 {
507 echo wp_json_encode(array('error' => 'not_subscription'));
508 exit();
509 }
510 $subscription_id = !empty($_POST['subscription_id']) ? trim(stripslashes((string)$_POST['subscription_id'])) : '';
511
512 if(!$subscription_id)
513 {
514 echo wp_json_encode(array('error' => 'missing_subscription_id'));
515 exit();
516 }
517 $subscription_r = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/billing/subscriptions/'.rawurlencode($subscription_id));
518
519 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
520 'ppco' => 'checkout',
521 'env_setting' => $env_setting,
522 'event' => 'subscription_get_response',
523 'subscription_id' => $subscription_id,
524 'subscription' => $subscription_r,
525 'token' => $token,
526 ));
527
528 $subscription_code = !empty($subscription_r['code']) ? (int)$subscription_r['code'] : 0;
529 $subscription_body = !empty($subscription_r['body']) ? (string)$subscription_r['body'] : '';
530
531 $subscription = array();
532 if($subscription_body)
533 $subscription = json_decode($subscription_body, true);
534
535 if(!is_array($subscription))
536 $subscription = array();
537
538 if($subscription_code < 200 || $subscription_code > 299 || empty($subscription['id']))
539 {
540 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
541 'ppco' => 'checkout',
542 'env_setting' => $env_setting,
543 'event' => 'subscription_get_failed',
544 'subscription_id' => $subscription_id,
545 'code' => $subscription_code,
546 'body' => $subscription_body,
547 ));
548 echo wp_json_encode(array('error' => 'subscription_get_failed'));
549 exit();
550 }
551 $status = !empty($subscription['status']) ? strtoupper((string)$subscription['status']) : '';
552
553 $is_single_cycle = (isset($token['rr']) && (string)$token['rr'] === '0');
554 $allow_expired_single_cycle = false;
555
556 // PayPal can complete a single-cycle subscription immediately, returning status=EXPIRED after payment.
557 if($is_single_cycle && $status === 'EXPIRED')
558 {
559 $lpv = '';
560 $lpc = '';
561
562 if(!empty($subscription['billing_info']['last_payment']['amount']['value']))
563 $lpv = (string)$subscription['billing_info']['last_payment']['amount']['value'];
564
565 if(!empty($subscription['billing_info']['last_payment']['amount']['currency_code']))
566 $lpc = strtoupper((string)$subscription['billing_info']['last_payment']['amount']['currency_code']);
567
568 if($lpv !== '' && $lpc !== '')
569 $allow_expired_single_cycle = true;
570 }
571
572 if($status && !in_array($status, array('ACTIVE', 'APPROVED', 'APPROVAL_PENDING'), true) && !$allow_expired_single_cycle)
573 {
574 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
575 'ppco' => 'checkout',
576 'env_setting' => $env_setting,
577 'event' => 'subscription_status_invalid',
578 'subscription_id' => $subscription_id,
579 'status' => $status,
580 ));
581
582 echo wp_json_encode(array('error' => 'subscription_status_invalid'));
583 exit();
584 }
585 $expected_plan_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_plan_get_id($token);
586 if($expected_plan_id && !empty($subscription['plan_id']) && (string)$subscription['plan_id'] !== (string)$expected_plan_id)
587 {
588 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
589 'ppco' => 'checkout',
590 'env_setting' => $env_setting,
591 'event' => 'plan_mismatch',
592 'subscription_id' => $subscription_id,
593 'expected' => $expected_plan_id,
594 'actual' => (string)$subscription['plan_id'],
595 ));
596 echo wp_json_encode(array('error' => 'plan_mismatch'));
597 exit();
598 }
599 $custom_id = !empty($subscription['custom_id']) ? (string)$subscription['custom_id'] : '';
600 if($custom_id && $custom_id !== (string)$token['invoice'])
601 {
602 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
603 'ppco' => 'checkout',
604 'env_setting' => $env_setting,
605 'event' => 'subscription_custom_id_mismatch',
606 'subscription_id' => $subscription_id,
607 'expected' => (string)$token['invoice'],
608 'actual' => $custom_id,
609 ));
610 echo wp_json_encode(array('error' => 'subscription_custom_id_mismatch'));
611 exit();
612 }
613
614 $subscriber_email = !empty($subscription['subscriber']['email_address']) ? (string)$subscription['subscriber']['email_address'] : '';
615 $first_name = !empty($subscription['subscriber']['name']['given_name']) ? (string)$subscription['subscriber']['name']['given_name'] : '';
616 $last_name = !empty($subscription['subscriber']['name']['surname']) ? (string)$subscription['subscriber']['name']['surname'] : '';
617
618 $paypal = array(
619 'txn_type' => 'subscr_signup',
620 'payment_status' => 'Completed',
621 'subscr_gateway' => 'paypal',
622
623 'txn_id' => $subscription_id,
624 'subscr_id' => $subscription_id,
625 'subscr_baid' => $subscription_id,
626 'subscr_cid' => $subscription_id,
627
628 'mc_gross' => (string)$token['amount'],
629 'mc_currency' => strtoupper((string)$token['cc']),
630
631 'period1' => (!empty($token['tp']) && !empty($token['tt'])) ? ((string)$token['tp'].' '.strtoupper((string)$token['tt'])) : '0 D',
632 'mc_amount1' => (!empty($token['tp']) && !empty($token['tt'])) ? (string)$token['ta'] : '0.00',
633
634 'period3' => ((string)$token['rp'].' '.strtoupper((string)$token['rt'])),
635 'mc_amount3' => (string)$token['amount'],
636 'recurring' => ((isset($token['rr']) && (string)$token['rr'] === '1') ? '1' : '0'),
637
638 'invoice' => (string)$token['invoice'],
639 'custom' => (string)$token['custom'],
640 'item_name' => (string)$token['item_name'],
641 'item_number' => (string)$token['item_number'],
642
643 'payer_email' => $subscriber_email,
644 'first_name' => $first_name,
645 'last_name' => $last_name,
646
647 'option_name1' => (string)$token['on0'],
648 'option_selection1' => (string)$token['os0'],
649 'option_name2' => (string)$token['on1'],
650 'option_selection2' => (string)$token['os1'],
651 );
652
653 $option_ppco_subscr = 's2m_ppco_subscr_done_'.md5($subscription_id);
654
655 //260818.0603 Mark the Subscription done only after Notify succeeds, using the same lock as webhook activation fallback.
656 $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $option_ppco_subscr);
657 $notify_code = !empty($notify_result['code']) ? (int)$notify_result['code'] : 0;
658 $notify_msg = !empty($notify_result['message']) ? (string)$notify_result['message'] : '';
659 $notify_body = !empty($notify_result['body']) ? (string)$notify_result['body'] : '';
660
661 if(empty($notify_result['ok']))
662 {
663 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
664 'ppco' => 'checkout',
665 'env_setting' => $env_setting,
666 'event' => 'notify_proxy_failed',
667 'subscription_id' => $subscription_id,
668 'code' => $notify_code,
669 'message' => $notify_msg,
670 'body' => $notify_body,
671 'error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed',
672 ));
673
674 echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
675 exit();
676 }
677
678 if(!empty($notify_result['duplicate']))
679 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
680 'ppco' => 'checkout',
681 'env_setting' => $env_setting,
682 'event' => 'duplicate_subscription_ignored',
683 'subscription_id' => $subscription_id,
684 'option' => $option_ppco_subscr,
685 ));
686 else
687 {
688 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
689 'ppco' => 'checkout',
690 'env_setting' => $env_setting,
691 'event' => 'notify_proxy_response',
692 'subscription_id' => $subscription_id,
693 'code' => $notify_code,
694 'message' => $notify_msg,
695 'body' => $notify_body,
696 ));
697
698 //260818.0603 Only successful first-pass fulfillment should trigger replacement-subscription cancellation.
699 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()))
700 c_ws_plugin__s2member_utilities::cancel_gateway_subscription($old__subscr_gateway, $old__subscr_id, $old__subscr_baid, $old__subscr_cid, $old__ipn_signup_vars);
701 }
702
703 $return_url = (string)$token['return'];
704 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
705
706 $return_post = array_merge($paypal, array(
707 's2member_paypal_proxy' => 'paypal',
708 's2member_paypal_proxy_use' => 'paypal_checkout',
709 ));
710
711 //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
712 $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
713 if(!$return_handoff)
714 {
715 if(!headers_sent())
716 status_header(500);
717
718 echo wp_json_encode(array('error' => 'return_handoff_failed'));
719 exit();
720 }
721 $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
722
723 echo wp_json_encode(array(
724 'rtn_url' => $return_url,
725 'rtn_post' => $return_post,
726 ));
727 exit();
728 }
729
730 if($op === 'cancel_subscription')
731 {
732 if(!is_user_logged_in())
733 {
734 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
735 'ppco' => 'checkout',
736 'env_setting' => $env_setting,
737 'event' => 'cancel_subscription_not_logged_in',
738 'token' => $token,
739 ));
740
741 echo wp_json_encode(array('error' => 'not_logged_in'));
742 exit();
743 }
744 $user_id = (int)get_current_user_id();
745
746 $nonce = !empty($_POST['s2member_paypal_checkout_nonce']) ? trim(stripslashes((string)$_POST['s2member_paypal_checkout_nonce'])) : '';
747 if(!$nonce || !wp_verify_nonce($nonce, 's2m_ppco_cancel_'.$user_id))
748 {
749 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
750 'ppco' => 'checkout',
751 'env_setting' => $env_setting,
752 'event' => 'cancel_subscription_bad_nonce',
753 'user_id'=> $user_id,
754 ));
755
756 echo wp_json_encode(array('error' => 'bad_nonce'));
757 exit();
758 }
759
760 $token_user_id = !empty($token['user_id']) ? (int)$token['user_id'] : 0;
761 $token_subscr_id = !empty($token['subscr_id']) ? (string)$token['subscr_id'] : '';
762
763 if(!$token_user_id || $token_user_id !== $user_id || !$token_subscr_id)
764 {
765 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
766 'ppco' => 'checkout',
767 'env_setting' => $env_setting,
768 'event' => 'cancel_subscription_token_mismatch',
769 'user_id' => $user_id,
770 'token' => $token,
771 ));
772
773 echo wp_json_encode(array('error' => 'token_mismatch'));
774 exit();
775 }
776
777 $subscr_id = (string)get_user_option('s2member_subscr_id', $user_id);
778 if(!$subscr_id || $subscr_id !== $token_subscr_id)
779 {
780 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
781 'ppco' => 'checkout',
782 'env_setting' => $env_setting,
783 'event' => 'cancel_subscription_user_mismatch',
784 'user_id' => $user_id,
785 'user_subscr'=> $subscr_id,
786 'token_subscr'=> $token_subscr_id,
787 ));
788
789 echo wp_json_encode(array('error' => 'user_mismatch'));
790 exit();
791 }
792
793 $reason = !empty($_POST['reason']) ? trim(stripslashes((string)$_POST['reason'])) : 'Cancelled by subscriber.';
794 $reason = sanitize_text_field($reason);
795 if(!$reason)
796 $reason = 'Cancelled by subscriber.';
797
798 //260819.0417 Resolve the active subscription through whichever configured PayPal API family owns it.
799 $ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id);
800 $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();
801
802 $next_billing_time = '';
803 $eot = c_ws_plugin__s2member_utils_users::get_user_eot($user_id, TRUE, 'next');
804 if(is_array($eot) && !empty($eot['type']) && $eot['type'] === 'next' && !empty($eot['time']) && (int)$eot['time'] > time())
805 $next_billing_time = gmdate('Y-m-d\TH:i:s\Z', (int)$eot['time']);
806
807 $cancelled = c_ws_plugin__s2member_utilities::cancel_gateway_subscription(
808 'paypal',
809 $subscr_id,
810 (string)get_user_option('s2member_subscr_baid', $user_id),
811 (string)get_user_option('s2member_subscr_cid', $user_id),
812 $ipn_signup_vars,
813 TRUE,
814 $reason
815 );
816
817 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
818 'ppco' => 'checkout',
819 'env_setting' => $env_setting,
820 'event' => 'cancel_subscription_response',
821 'user_id' => $user_id,
822 'subscr_id' => $subscr_id,
823 'accepted' => $cancelled ? 1 : 0,
824 ));
825
826 if($cancelled)
827 {
828 // Immediately feed s2Member's existing cancel handler (webhooks may be missing in MVP sites).
829 $paypal = array(
830 'txn_type' => 'subscr_cancel',
831 'payment_status' => 'Completed',
832 'subscr_gateway' => 'paypal',
833
834 'txn_id' => $subscr_id,
835 'subscr_id' => $subscr_id,
836 'custom' => (string)get_user_option('s2member_custom', $user_id),
837
838 // Help legacy notify logic resolve user in some fallback cases.
839 'mp_id' => $subscr_id,
840 'recurring_payment_id' => $subscr_id,
841
842 //260517 Provide safe defaults when signup vars are missing.
843 'item_number' => (string)c_ws_plugin__s2member_user_access::user_access_level(wp_get_current_user()),
844 'item_name' => 'PayPal Checkout Subscription',
845
846 // Best-effort payer email for logs/fallback logic.
847 'payer_email' => (string)wp_get_current_user()->user_email,
848 );
849
850 //260517 Enrich with stored signup vars so legacy cancel handler can match and compute EOT.
851 if($ipn_signup_vars)
852 {
853 if(!empty($ipn_signup_vars['item_number']))
854 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
855
856 if(!empty($ipn_signup_vars['item_name']))
857 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
858
859 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
860 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
861
862 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
863 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
864 }
865
866 $notify_url = home_url('/?s2member_paypal_notify=1');
867 $notify_post = array_merge($paypal, array(
868 'proxy_user_id' => $user_id, //260517
869 'proxy_next_billing_time' => $next_billing_time, //260517
870 's2member_paypal_proxy' => 'paypal',
871 's2member_paypal_proxy_use' => 'paypal_checkout',
872 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
873 ));
874
875 $notify_r = c_ws_plugin__s2member_utils_urls::remote($notify_url, $notify_post, array('timeout' => 20), true);
876
877 if(!is_array($notify_r))
878 $notify_r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
879
880 $notify_code = !empty($notify_r['code']) ? (int)$notify_r['code'] : 0;
881 if(!($notify_code >= 200 && $notify_code <= 299))
882 {
883 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
884 'ppco' => 'checkout',
885 'env_setting' => $env_setting,
886 'event' => 'cancel_subscription_notify_failed',
887 'user_id' => $user_id,
888 'subscr_id' => $subscr_id,
889 'notify_code' => $notify_code,
890 'notify_msg' => !empty($notify_r['message']) ? (string)$notify_r['message'] : '',
891 ));
892 }
893
894 echo wp_json_encode(array('ok' => 1));
895 exit();
896 }
897
898 echo wp_json_encode(array('error' => 'cancel_failed'));
899 exit();
900 }
901
902 if($op === 'create_order')
903 {
904 $order = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_create($token);
905
906 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
907 'ppco' => 'checkout',
908 'env_setting' => $env_setting,
909 'event' => 'create_order_response',
910 'order' => $order,
911 'token' => $token,
912 ));
913
914 if(empty($order['id']))
915 {
916 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
917 'ppco' => 'checkout',
918 'env_setting' => $env_setting,
919 'event' => 'order_create_failed',
920 'order' => $order,
921 'token' => $token,
922 ));
923
924 echo wp_json_encode(array('error' => 'order_create_failed'));
925 exit();
926 }
927 echo wp_json_encode(array('order_id' => $order['id']));
928 exit();
929 }
930 else if($op === 'capture_order')
931 {
932 $order_id = !empty($_POST['order_id']) ? trim(stripslashes((string)$_POST['order_id'])) : '';
933
934 if(!$order_id)
935 {
936 echo wp_json_encode(array('error' => 'missing_order_id'));
937 exit();
938 }
939 $capture = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_capture($order_id, $token);
940
941 $cap0 = (!empty($capture['purchase_units'][0]['payments']['captures'][0]) && is_array($capture['purchase_units'][0]['payments']['captures'][0])) ? $capture['purchase_units'][0]['payments']['captures'][0] : array();
942 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
943 'ppco' => 'checkout',
944 'env_setting' => $env_setting,
945 'event' => 'capture_response',
946 'order_id' => $order_id,
947 'status' => !empty($capture['status']) ? (string)$capture['status'] : '',
948 'capture_id' => !empty($cap0['id']) ? (string)$cap0['id'] : '',
949 'amount' => !empty($cap0['amount']['value']) ? (string)$cap0['amount']['value'] : '',
950 'cc' => !empty($cap0['amount']['currency_code']) ? (string)$cap0['amount']['currency_code'] : '',
951 'payer' => !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '',
952 'capture' => $capture,
953 'token' => $token,
954 ));
955
956 if(!empty($capture['__error']))
957 {
958 echo wp_json_encode(array('error' => (string)$capture['__error']));
959 exit();
960 }
961
962 if(empty($capture['status']) || strtoupper($capture['status']) !== 'COMPLETED')
963 {
964 echo wp_json_encode(array('error' => 'order_capture_failed'));
965 exit();
966 }
967
968 /*
969 * Build PayPal-like variables to feed s2Member's existing IPN + Return handlers.
970 */
971 $payer_email = !empty($capture['payer']['email_address']) ? (string)$capture['payer']['email_address'] : '';
972 $first_name = !empty($capture['payer']['name']['given_name']) ? (string)$capture['payer']['name']['given_name'] : '';
973 $last_name = !empty($capture['payer']['name']['surname']) ? (string)$capture['payer']['name']['surname'] : '';
974
975 $pu_amount = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['value']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['value'] : '';
976 $pu_cc = !empty($capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['amount']['currency_code'] : '';
977 $pu_cap_id = !empty($capture['purchase_units'][0]['payments']['captures'][0]['id']) ? (string)$capture['purchase_units'][0]['payments']['captures'][0]['id'] : '';
978
979 if(!$payer_email || !$pu_amount || !$pu_cc || !$pu_cap_id)
980 {
981 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
982 'ppco' => 'checkout',
983 'env_setting' => $env_setting,
984 'event' => 'capture_missing_fields',
985 'order_id' => $order_id,
986 'capture' => $capture,
987 'token' => $token,
988 ));
989
990 echo wp_json_encode(array('error' => 'capture_missing_fields'));
991 exit();
992 }
993
994 // Extra safety: enforce token matches amount/currency/invoice/custom if provided.
995 //260228 Normalize amount strings before comparison (e.g. 20 vs 20.00).
996 if(!empty($token['amount']) && number_format((float)$token['amount'], 2, '.', '') !== number_format((float)$pu_amount, 2, '.', ''))
997 {
998 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
999 'ppco' => 'checkout',
1000 'env_setting' => $env_setting,
1001 'event' => 'amount_mismatch',
1002 'order_id' => $order_id,
1003 'token' => $token,
1004 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1005 ));
1006 echo wp_json_encode(array('error' => 'amount_mismatch'));
1007 exit();
1008 }
1009 if(!empty($token['cc']) && strtoupper((string)$token['cc']) !== strtoupper((string)$pu_cc))
1010 {
1011 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1012 'ppco' => 'checkout',
1013 'env_setting' => $env_setting,
1014 'event' => 'currency_mismatch',
1015 'order_id' => $order_id,
1016 'token' => $token,
1017 'pu' => array('amount' => $pu_amount, 'cc' => $pu_cc),
1018 ));
1019 echo wp_json_encode(array('error' => 'currency_mismatch'));
1020 exit();
1021 }
1022 $cap_invoice_id = '';
1023 if(!empty($capture['purchase_units'][0]['invoice_id']))
1024 $cap_invoice_id = (string)$capture['purchase_units'][0]['invoice_id'];
1025 else if(!empty($capture['purchase_units'][0]['payments']['captures'][0]['invoice_id']))
1026 $cap_invoice_id = (string)$capture['purchase_units'][0]['payments']['captures'][0]['invoice_id'];
1027
1028 if($cap_invoice_id && $cap_invoice_id !== (string)$token['invoice'])
1029 {
1030 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1031 'ppco' => 'checkout',
1032 'env_setting' => $env_setting,
1033 'event' => 'invoice_mismatch',
1034 'order_id' => $order_id,
1035 'token' => $token,
1036 'invoice' => $cap_invoice_id,
1037 ));
1038 echo wp_json_encode(array('error' => 'invoice_mismatch'));
1039 exit();
1040 }
1041
1042 $cap_custom_id = '';
1043 if(!empty($capture['purchase_units'][0]['custom_id']))
1044 $cap_custom_id = (string)$capture['purchase_units'][0]['custom_id'];
1045 else if(!empty($capture['purchase_units'][0]['payments']['captures'][0]['custom_id']))
1046 $cap_custom_id = (string)$capture['purchase_units'][0]['payments']['captures'][0]['custom_id'];
1047
1048 if($cap_custom_id && !empty($token['custom']) && $cap_custom_id !== (string)$token['custom'])
1049 {
1050 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1051 'ppco' => 'checkout',
1052 'env_setting' => $env_setting,
1053 'event' => 'custom_mismatch',
1054 'order_id' => $order_id,
1055 'token' => $token,
1056 'custom' => array(
1057 'token' => !empty($token['custom']) ? $token['custom'] : '',
1058 'paypal' => $cap_custom_id,
1059 ),
1060 ));
1061 echo wp_json_encode(array('error' => 'custom_mismatch'));
1062 exit();
1063 }
1064
1065 $paypal = array(
1066 'txn_type' => 'web_accept',
1067 'payment_status' => 'Completed',
1068 'subscr_gateway' => 'paypal',
1069
1070 'txn_id' => $pu_cap_id,
1071 'subscr_id' => $pu_cap_id,
1072 'subscr_baid' => $pu_cap_id,
1073 'subscr_cid' => $pu_cap_id,
1074
1075 'mc_gross' => $pu_amount,
1076 'mc_currency' => strtoupper($pu_cc),
1077
1078 'invoice' => (string)$token['invoice'],
1079 'custom' => (string)$token['custom'],
1080 'item_name' => (string)$token['item_name'],
1081 'item_number' => (string)$token['item_number'],
1082
1083 'payer_email' => $payer_email,
1084 'first_name' => $first_name,
1085 'last_name' => $last_name,
1086
1087 // Preserve s2Member's tracking option fields.
1088 'option_name1' => (string)$token['on0'],
1089 'option_selection1' => (string)$token['os0'],
1090 'option_name2' => (string)$token['on1'],
1091 'option_selection2' => (string)$token['os1'],
1092 );
1093
1094 //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.
1095 if(isset($token['tax']))
1096 $paypal['tax'] = (string)$token['tax'];
1097
1098 $is_independent_ccaps_sale = (strpos((string)$token['item_number'], '*:') === 0);
1099 $is_specific_post_page_sale = (strpos((string)$token['item_number'], 'sp:') === 0);
1100 $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.
1101
1102 $proxy_use = !empty($token['s2member_paypal_proxy_use']) ? (string)$token['s2member_paypal_proxy_use'] : 'paypal_checkout';
1103 $notify_extra = array();
1104
1105 if(!empty($token['s2member_paypal_proxy_coupon']) && is_array($token['s2member_paypal_proxy_coupon']))
1106 $notify_extra['s2member_paypal_proxy_coupon'] = $token['s2member_paypal_proxy_coupon'];
1107 if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1108 $notify_extra['s2member_paypal_proxy_return_url'] = (string)$token['s2member_paypal_proxy_return_url'];
1109
1110 $notify_done_option = 's2m_ppco_capture_done_'.md5($pu_cap_id);
1111 $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $notify_done_option, $proxy_use, $notify_extra);
1112
1113 if(empty($notify_result['ok']))
1114 {
1115 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1116 'ppco' => 'checkout',
1117 'env_setting' => $env_setting,
1118 'event' => 'notify_proxy_failed',
1119 'order_id' => $order_id,
1120 'txn_id' => $pu_cap_id,
1121 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1122 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1123 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1124 ));
1125 echo wp_json_encode(array('error' => !empty($notify_result['error']) ? (string)$notify_result['error'] : 'notify_proxy_failed'));
1126 exit();
1127 }
1128
1129 if(!empty($notify_result['processed']))
1130 {
1131 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
1132 'ppco' => 'checkout',
1133 'env_setting' => $env_setting,
1134 'event' => 'notify_proxy_response',
1135 'order_id' => $order_id,
1136 'txn_id' => $pu_cap_id,
1137 'code' => !empty($notify_result['code']) ? (int)$notify_result['code'] : 0,
1138 'message' => !empty($notify_result['message']) ? (string)$notify_result['message'] : '',
1139 'body' => !empty($notify_result['body']) ? (string)$notify_result['body'] : '',
1140 ));
1141
1142 //260407 Framework PPCO AJAX replacements can also replace subscriptions created by other gateways without affecting independent CCAPS or specific post/page purchases.
1143 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
1144 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
1145 }
1146
1147 // 2) Send the user through the existing Return handler via POST (sets cookies, thank-you UX, reg tokens, etc).
1148 $return_url = (string)$token['return'];
1149 $return_url = add_query_arg('s2member_paypal_proxy', 'paypal', $return_url);
1150
1151 $return_post = array_merge($paypal, array(
1152 's2member_paypal_proxy' => 'paypal',
1153 's2member_paypal_proxy_use' => $proxy_use,
1154 ));
1155
1156 //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.
1157 if(array_key_exists('s2member_paypal_proxy_return_url', $token))
1158 $return_post['s2member_paypal_proxy_return_url'] = !empty($notify_result['body']) ? trim((string)$notify_result['body']) : '';
1159
1160 //260817 Sign the exact browser-return payload without exposing the reusable internal PayPal proxy key.
1161 $return_handoff = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_return_handoff_create($return_post);
1162 if(!$return_handoff)
1163 {
1164 if(!headers_sent())
1165 status_header(500);
1166
1167 echo wp_json_encode(array('error' => 'return_handoff_failed'));
1168 exit();
1169 }
1170 $return_post['s2member_paypal_checkout_handoff'] = $return_handoff;
1171
1172 echo wp_json_encode(array(
1173 'rtn_url' => $return_url,
1174 'rtn_post' => $return_post,
1175 ));
1176 exit();
1177 }
1178
1179 echo wp_json_encode(array('error' => 'unknown_op'));
1180 exit();
1181 }
1182 }
1183 }
1184