PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260814
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260814
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-webhook-in.inc.php

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

567 lines 23.2 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 Webhook handler (REST).
5 *
6 * Receives PayPal webhooks, verifies authenticity, translates events into legacy
7 * PayPal-IPN-like vars/txn_type equivalents, and proxies into s2Member's existing
8 * PayPal notify handler (via a proxy key) to preserve provisioning behavior.
9 *
10 * - Signature verification: verify-webhook-signature.
11 * - Idempotent processing: duplicate deliveries are safely ignored (and logged).
12 * - Admin reachability test: optional GET-based "OK" response for diagnostics.
13 *
14 * Note: PayPal's Webhooks Simulator is best treated as connectivity-only; real sandbox
15 * transactions are the reliable end-to-end verification path.
16 *
17 * @package s2Member\PayPal
18 * @since 260112
19 */
20 if(!defined('WPINC')) // MUST have WordPress.
21 exit('Do not access this file directly.');
22
23 if(!class_exists('c_ws_plugin__s2member_paypal_webhook_in'))
24 {
25 class c_ws_plugin__s2member_paypal_webhook_in
26 {
27 public static function paypal_webhook()
28 {
29 if(empty($_REQUEST['s2member_paypal_webhook']))
30 return;
31
32 //260218 Allow webhook processing even when Checkout buttons are disabled (if creds+webhook id exist).
33 if(!c_ws_plugin__s2member_paypal_utilities::paypal_checkout_webhook_processing_is_enabled())
34 {
35 status_header(404);
36 exit();
37 }
38 // Admin-only reachability test endpoint (does not validate signatures).
39 if(!empty($_GET['s2member_paypal_webhook_test']) && current_user_can('manage_options')
40 && !empty($_GET['_wpnonce']) && wp_verify_nonce((string)$_GET['_wpnonce'], 's2member_ppco_webhook_test'))
41 {
42 $env_site = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
43 $env_webhook = (!empty($_GET['ppco_webhook_env']) && $_GET['ppco_webhook_env'] === 'sandbox') ? 'sandbox' : 'live';
44
45 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
46 'ppco' => 'webhook',
47 'env_setting' => $env_site,
48 'env_webhook' => $env_webhook,
49 'event' => 'endpoint_test_ok',
50 'host' => !empty($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : '',
51 'uri' => !empty($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '',
52 'ssl' => is_ssl() ? '1' : '0',
53 ));
54
55 status_header(200);
56 header('Content-Type: text/plain; charset=UTF-8');
57
58 $lines = array(
59 'SUCCESS',
60 '',
61 's2Member PayPal Webhook Endpoint (reachability test)',
62 'Environment setting: '.$env_site,
63 'Environment webhook: '.$env_webhook,
64 'SSL: '.(is_ssl() ? 'yes' : 'no'),
65 'Host: '.(!empty($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : ''),
66 'URI: '.(!empty($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : ''),
67 'Timestamp (UTC): '.gmdate('Y-m-d H:i:s'),
68 '',
69 'Note: This is a reachability-only test. Real PayPal webhooks are POST requests and require signature verification.',
70 );
71
72 echo implode("\n", $lines);
73 exit();
74 }
75
76 if(strtoupper((string)$_SERVER['REQUEST_METHOD']) !== 'POST')
77 {
78 status_header(405);
79 exit();
80 }
81
82 $raw_body = file_get_contents('php://input');
83 $event = json_decode((string)$raw_body, true);
84
85 $headers = array();
86 if(function_exists('getallheaders'))
87 foreach((array)getallheaders() as $_k => $_v)
88 $headers[strtolower((string)$_k)] = (string)$_v;
89
90 // Fallback for hosts without getallheaders().
91 foreach(array(
92 'HTTP_PAYPAL_TRANSMISSION_ID' => 'paypal-transmission-id',
93 'HTTP_PAYPAL_TRANSMISSION_TIME' => 'paypal-transmission-time',
94 'HTTP_PAYPAL_TRANSMISSION_SIG' => 'paypal-transmission-sig',
95 'HTTP_PAYPAL_CERT_URL' => 'paypal-cert-url',
96 'HTTP_PAYPAL_AUTH_ALGO' => 'paypal-auth-algo',
97 ) as $_server => $_key)
98 if(empty($headers[$_key]) && !empty($_SERVER[$_server]))
99 $headers[$_key] = (string)$_SERVER[$_server];
100
101 //260206 Detect environment from inbound PayPal cert URL.
102 $cert_url = !empty($headers['paypal-cert-url']) ? (string)$headers['paypal-cert-url'] : '';
103 $env_site = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
104
105 $cert_host = $cert_url ? (string)parse_url($cert_url, PHP_URL_HOST) : '';
106 $env_webhook = 'unknown';
107
108 if($cert_host && preg_match('/(^|\.)paypal\.com$/i', $cert_host))
109 $env_webhook = (stripos($cert_host, 'sandbox') !== false || strpos($cert_url, 'sandbox') !== false) ? 'sandbox' : 'live';
110
111 if(!is_array($event) || empty($event['id']) || empty($event['event_type']))
112 {
113 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
114 'ppco' => 'webhook',
115 'env_setting' => $env_site,
116 'env_webhook' => $env_webhook,
117 'event' => 'invalid_payload',
118 ));
119 status_header(400);
120 exit();
121 }
122
123 $verified = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_verify_webhook_signature($event, $raw_body, $headers);
124 if(!$verified)
125 {
126 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
127 'ppco' => 'webhook',
128 'env_setting'=> $env_site,
129 'env_webhook'=> $env_webhook,
130 'event' => 'signature_failed',
131 'event_id' => (string)$event['id'],
132 'event_type' => (string)$event['event_type'],
133 'tx_id' => !empty($headers['paypal-transmission-id']) ? (string)$headers['paypal-transmission-id'] : '',
134 'tx_time' => !empty($headers['paypal-transmission-time']) ? (string)$headers['paypal-transmission-time'] : '',
135 'auth_algo' => !empty($headers['paypal-auth-algo']) ? (string)$headers['paypal-auth-algo'] : '',
136 'cert_url' => !empty($headers['paypal-cert-url']) ? (string)$headers['paypal-cert-url'] : '',
137 ));
138 status_header(400);
139 exit();
140 }
141
142 $event_id = (string)$event['id'];
143 $event_type = (string)$event['event_type'];
144
145 //260406 Use option-based dedupe/lock markers for PayPal Checkout because transients were not reliable enough on some sites.
146 $event_lock_option = 's2m_ppco_wh_lock_'.md5($event_id);
147 $event_done_option = 's2m_ppco_wh_done_'.md5($event_id);
148 $event_lock_ttl = 900;
149 $event_done_ttl = 6 * HOUR_IN_SECONDS;
150 $txn_done_ttl = DAY_IN_SECONDS;
151 $subscr_done_ttl = DAY_IN_SECONDS;
152
153 //260406 Occasionally clean up expired PayPal Checkout dedupe markers; the transient only throttles cleanup frequency.
154 c_ws_plugin__s2member_paypal_utilities::dedupe_markers_cleanup('s2m_ppco_dedupe_cleanup_throttle', array(
155 array('prefix' => 's2m_ppco_wh_done_', 'ttl' => $event_done_ttl),
156 array('prefix' => 's2m_ppco_txn_done_', 'ttl' => $txn_done_ttl),
157 array('prefix' => 's2m_ppco_subscr_done_', 'ttl' => $subscr_done_ttl),
158 ), 6 * HOUR_IN_SECONDS);
159
160 $event_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($event_done_option, $event_done_ttl);
161 if($event_done_time > 0)
162 {
163 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
164 'ppco' => 'webhook',
165 'env_setting'=> $env_site,
166 'env_webhook'=> $env_webhook,
167 'event' => 'duplicate_event',
168 'action' => 'ignored',
169 'note' => 'Duplicate webhook delivery (event_id already processed).',
170 'event_id' => $event_id,
171 'event_type' => $event_type,
172 ));
173 status_header(200);
174 exit();
175 }
176
177 if(!c_ws_plugin__s2member_paypal_utilities::dedupe_lock_acquire($event_lock_option, $event_lock_ttl))
178 {
179 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
180 'ppco' => 'webhook',
181 'env_setting'=> $env_site,
182 'env_webhook'=> $env_webhook,
183 'event' => 'duplicate_event',
184 'action' => 'ignored',
185 'note' => 'Duplicate webhook delivery (event_id already processing).',
186 'event_id' => $event_id,
187 'event_type' => $event_type,
188 ));
189 status_header(200);
190 exit();
191 }
192
193 $resource = !empty($event['resource']) && is_array($event['resource']) ? $event['resource'] : array();
194
195 $paypal = array();
196 $paypal['charset'] = 'utf-8';
197 $paypal['custom'] = !empty($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : (string)parse_url(home_url('/'), PHP_URL_HOST);
198
199 $subscr_id = '';
200 $txn_id = '';
201
202 $txn_done_option = '';
203 $subscr_done_option = '';
204 $subscr_handled_by_webhook = false;
205
206 // Subscription lifecycle events.
207 if(strpos($event_type, 'BILLING.SUBSCRIPTION.') === 0)
208 {
209 if(!empty($resource['id']))
210 $subscr_id = (string)$resource['id'];
211
212 if($subscr_id)
213 $subscr_done_option = 's2m_ppco_subscr_done_'.md5($subscr_id); //260406 Match the checkout subscription-done option so webhook ACTIVATED/RE-ACTIVATED stays fallback-only.
214
215 //260401 Treat CREATED as informational only, and let ACTIVATED/RE-ACTIVATED act only as a fallback when checkout has not already handled this Subscription.
216 if($event_type === 'BILLING.SUBSCRIPTION.CREATED')
217 {
218 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
219 'ppco' => 'webhook',
220 'env_setting'=> $env_site,
221 'env_webhook'=> $env_webhook,
222 'event' => 'subscription_created',
223 'event_id' => $event_id,
224 'event_type' => $event_type,
225 'subscr_id' => $subscr_id,
226 ));
227
228 //260406 Mark the webhook event done and release its lock for valid terminal events.
229 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
230 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
231
232 status_header(200);
233 exit();
234 }
235 else if($event_type === 'BILLING.SUBSCRIPTION.ACTIVATED' || $event_type === 'BILLING.SUBSCRIPTION.RE-ACTIVATED')
236 {
237 $subscr_done_time = ($subscr_done_option) ? c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($subscr_done_option, $subscr_done_ttl) : 0;
238
239 //260401 Ignore webhook activation when checkout already handled this Subscription; otherwise allow webhook activation as a fallback.
240 if($subscr_done_option && $subscr_done_time > 0)
241 {
242 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
243 'ppco' => 'webhook',
244 'env_setting'=> $env_site,
245 'env_webhook'=> $env_webhook,
246 'event' => 'subscription_activation_ignored',
247 'note' => 'Checkout already handled this Subscription; skipping webhook fallback activation.',
248 'event_id' => $event_id,
249 'event_type' => $event_type,
250 'subscr_id' => $subscr_id,
251 'option' => $subscr_done_option,
252 ));
253
254 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
255 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
256
257 status_header(200);
258 exit();
259 }
260
261 $paypal['txn_type'] = 'subscr_signup'; //260401 Keep webhook activation as a fallback to the legacy signup handler only when checkout did not already handle this Subscription.
262 $paypal['payment_status'] = 'Completed';
263
264 $subscr_handled_by_webhook = true;
265 }
266 else if($event_type === 'BILLING.SUBSCRIPTION.UPDATED')
267 $paypal['txn_type'] = 'subscr_modify';
268 else if($event_type === 'BILLING.SUBSCRIPTION.CANCELLED')
269 $paypal['txn_type'] = 'subscr_cancel';
270 else if($event_type === 'BILLING.SUBSCRIPTION.SUSPENDED')
271 $paypal['txn_type'] = 'recurring_payment_suspended_due_to_max_failed_payment';
272 else if($event_type === 'BILLING.SUBSCRIPTION.EXPIRED')
273 $paypal['txn_type'] = 'subscr_eot';
274 else if($event_type === 'BILLING.SUBSCRIPTION.PAYMENT.FAILED')
275 $paypal['txn_type'] = 'subscr_failed';
276 else
277 {
278 // Ignore other BILLING.SUBSCRIPTION.* events.
279 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
280 'ppco' => 'webhook',
281 'env_setting'=> $env_site,
282 'env_webhook'=> $env_webhook,
283 'event' => 'ignored',
284 'event_id' => $event_id,
285 'event_type' => $event_type,
286 ));
287
288 //260406 Mark the webhook event done and release its lock for valid terminal events.
289 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
290 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
291
292 status_header(200);
293 exit();
294 }
295
296 $paypal['subscr_id'] = $subscr_id;
297 $paypal['txn_id'] = $event_id; // best-effort unique id
298
299 // Help legacy notify logic resolve a user when signup vars are missing (migrations, etc.).
300 $paypal['mp_id'] = $subscr_id;
301 $paypal['recurring_payment_id'] = $subscr_id;
302
303 // Best-effort payer email for logs/fallback logic.
304 if(!empty($resource['subscriber']['email_address']))
305 $paypal['payer_email'] = (string)$resource['subscriber']['email_address'];
306
307 // Enrich lifecycle events with stored signup vars so legacy notify handlers can match and set EOT properly.
308 //!!! TO-DO: Deduplicate signup-vars enrichment logic (also used in PayPal Checkout proxy confirm flow).
309 if(!empty($paypal['txn_type']) && $subscr_id
310 && in_array($paypal['txn_type'], array('subscr_signup', 'subscr_modify', 'subscr_cancel', 'subscr_eot', 'subscr_failed', 'recurring_payment_suspended_due_to_max_failed_payment'), true)
311 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
312 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
313 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
314 )
315 {
316 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
317 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
318
319 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
320 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
321
322 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
323 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
324
325 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
326 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
327 }
328 }
329
330 // Recurring payment events (PayPal often emits PAYMENT.SALE.COMPLETED for subscription payments).
331 //260216 Add refund/reversal webhook support so refunds can trigger immediate EOT/demotion.
332 //260226 !!! TO-DO: Consider handling PayPal dispute/chargeback webhooks (e.g., CUSTOMER.DISPUTE.*), since not all chargebacks map to SALE/CAPTURE reversal events.
333 else if(in_array($event_type, array(
334 'PAYMENT.SALE.COMPLETED',
335 'PAYMENT.CAPTURE.COMPLETED',
336 'PAYMENT.SALE.REFUNDED',
337 'PAYMENT.CAPTURE.REFUNDED',
338 'PAYMENT.SALE.REVERSED',
339 'PAYMENT.CAPTURE.REVERSED',
340 ), true))
341 {
342 if(!empty($resource['billing_agreement_id']))
343 $subscr_id = (string)$resource['billing_agreement_id'];
344 else if(!empty($resource['parent_payment']))
345 $subscr_id = (string)$resource['parent_payment']; // fallback (not always present)
346 else if(!empty($resource['subscription_id']))
347 $subscr_id = (string)$resource['subscription_id'];
348 else if(!empty($resource['supplementary_data']['related_ids']['billing_agreement_id']))
349 $subscr_id = (string)$resource['supplementary_data']['related_ids']['billing_agreement_id'];
350
351 //260228 Ignore one-time sale/capture webhooks that have no subscription reference.
352 if(!$subscr_id)
353 {
354 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
355 'ppco' => 'webhook',
356 'env_setting'=> $env_site,
357 'env_webhook'=> $env_webhook,
358 'event' => 'ignored_non_subscription_payment',
359 'event_id' => $event_id,
360 'event_type' => $event_type,
361 'resource' => $resource,
362 ));
363
364 //260406 Mark the webhook event done and release its lock for valid terminal events.
365 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
366 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
367
368 status_header(200);
369 exit();
370 }
371
372 $paypal['txn_type'] = 'subscr_payment';
373
374 if(strpos($event_type, '.REFUNDED') !== false)
375 $paypal['payment_status'] = 'Refunded';
376 else if(strpos($event_type, '.REVERSED') !== false)
377 $paypal['payment_status'] = 'Reversed';
378 else
379 $paypal['payment_status'] = 'Completed';
380
381 if(!empty($resource['id']))
382 $txn_id = (string)$resource['id']; // original capture/sale id
383
384 if(!empty($resource['amount']['total']))
385 $paypal['mc_gross'] = (string)$resource['amount']['total'];
386 else if(!empty($resource['amount']['value']))
387 $paypal['mc_gross'] = (string)$resource['amount']['value'];
388
389 if(!empty($resource['amount']['currency']))
390 $paypal['mc_currency'] = (string)$resource['amount']['currency'];
391 else if(!empty($resource['amount']['currency_code']))
392 $paypal['mc_currency'] = (string)$resource['amount']['currency_code'];
393
394 if(!empty($resource['payer']['payer_info']['email']))
395 $paypal['payer_email'] = (string)$resource['payer']['payer_info']['email'];
396 else if(!empty($resource['payer']['email_address']))
397 $paypal['payer_email'] = (string)$resource['payer']['email_address'];
398
399 $paypal['subscr_id'] = $subscr_id;
400
401 //260216 Emulate IPN semantics for refund/reversal: parent_txn_id=original, txn_id=event delivery.
402 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status']))
403 {
404 $paypal['parent_txn_id'] = $txn_id ? $txn_id : $event_id;
405 $paypal['txn_id'] = $event_id;
406 }
407 else
408 $paypal['txn_id'] = $txn_id ? $txn_id : $event_id;
409
410 $paypal['mp_id'] = $subscr_id;
411 $paypal['recurring_payment_id'] = $subscr_id;
412
413 //260216 Enrich refund/reversal from stored signup vars so legacy handlers can demote immediately.
414 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status'])
415 && $subscr_id
416 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
417 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
418 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
419 )
420 {
421 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
422 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
423
424 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
425 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
426
427 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
428 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
429
430 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
431 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
432
433 if(empty($paypal['payer_email']) && !empty($ipn_signup_vars['payer_email']))
434 $paypal['payer_email'] = (string)$ipn_signup_vars['payer_email'];
435 }
436 }
437 else
438 {
439 // Ignore for MVP.
440 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
441 'ppco' => 'webhook',
442 'env_setting'=> $env_site,
443 'env_webhook'=> $env_webhook,
444 'event' => 'ignored',
445 'event_id' => $event_id,
446 'event_type' => $event_type,
447 ));
448
449 //260406 Mark the webhook event done and release its lock for valid terminal events.
450 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
451 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
452
453 status_header(200);
454 exit();
455 }
456
457 //260406 Idempotency per txn prevents different webhook event IDs from double-processing the same payment.
458 if(!empty($paypal['txn_type']))
459 {
460 $txn_key = (string)$event_id;
461
462 //260216 For refund/reversal, prefer idempotency on original payment id.
463 if(!empty($paypal['parent_txn_id']))
464 $txn_key = (string)$paypal['parent_txn_id'];
465 else if(!empty($paypal['txn_id']))
466 $txn_key = (string)$paypal['txn_id'];
467
468 $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_key);
469
470 if($txn_key)
471 {
472 $txn_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($txn_done_option, $txn_done_ttl);
473
474 if($txn_done_time > 0)
475 {
476 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
477 'ppco' => 'webhook',
478 'env_setting'=> $env_site,
479 'env_webhook'=> $env_webhook,
480 'event' => 'duplicate_txn',
481 'action' => 'ignored',
482 'note' => 'Duplicate webhook delivery (txn_id already processed).',
483 'event_id' => $event_id,
484 'event_type' => $event_type,
485 'subscr_id' => $subscr_id,
486 'txn_id' => !empty($paypal['txn_id']) ? (string)$paypal['txn_id'] : '',
487 'option' => $txn_done_option,
488 ));
489
490 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
491 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
492
493 status_header(200);
494 exit();
495 }
496 }
497 }
498
499 // Proxy into existing s2Member PayPal notify handler to reuse all provisioning/eot logic.
500 $url = add_query_arg('s2member_paypal_notify', '1', home_url('/'));
501 $post = array_merge($paypal, array(
502 's2member_paypal_proxy' => 'paypal',
503 's2member_paypal_proxy_use' => 'paypal_checkout_webhook',
504 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
505 ));
506
507 $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
508 'timeout' => 20,
509 ), true);
510
511 if(!is_array($r))
512 $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
513
514 $code = !empty($r['code']) ? (int)$r['code'] : 0;
515
516 if($code >= 200 && $code <= 299)
517 {
518 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
519 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
520
521 if(!empty($txn_done_option))
522 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($txn_done_option);
523
524 //260401 If webhook activation had to rescue this Subscription, mark it done so later activation webhooks are ignored.
525 if($subscr_handled_by_webhook && !empty($subscr_done_option))
526 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($subscr_done_option);
527
528 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
529 'ppco' => 'webhook',
530 'env_setting'=> $env_site,
531 'env_webhook'=> $env_webhook,
532 'event' => 'notify_proxy_response',
533 'event_id' => $event_id,
534 'event_type' => $event_type,
535 'subscr_id' => $subscr_id,
536 'txn_id' => $txn_id ? $txn_id : $event_id,
537 'url' => $url,
538 'code' => $code,
539 'message' => !empty($r['message']) ? (string)$r['message'] : '',
540 ));
541 }
542 else
543 {
544 //260406 Release the in-flight webhook lock on failure so PayPal retries can proceed.
545 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
546
547 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
548 'ppco' => 'webhook',
549 'env_setting'=> $env_site,
550 'env_webhook'=> $env_webhook,
551 'event' => 'notify_proxy_failed',
552 'event_id' => $event_id,
553 'event_type' => $event_type,
554 'subscr_id' => $subscr_id,
555 'txn_id' => $txn_id ? $txn_id : $event_id,
556 'url' => $url,
557 'code' => $code,
558 'message' => !empty($r['message']) ? (string)$r['message'] : '',
559 ));
560 }
561
562 status_header(200);
563 exit();
564 }
565 }
566 }
567