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-webhook-in.inc.php

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

728 lines 30.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 //260824.1833 Keep dispute transaction extraction directly testable while accepting PayPal's documented nested payload and a tolerated direct fallback.
28 public static function paypal_checkout_dispute_seller_transaction_id($resource = array())
29 {
30 if(empty($resource['disputed_transactions']) || !is_array($resource['disputed_transactions']))
31 return '';
32
33 foreach($resource['disputed_transactions'] as $_disputed_transaction)
34 if(is_array($_disputed_transaction) && !empty($_disputed_transaction['transaction_info']['seller_transaction_id']))
35 return (string)$_disputed_transaction['transaction_info']['seller_transaction_id'];
36 else if(is_array($_disputed_transaction) && !empty($_disputed_transaction['seller_transaction_id']))
37 return (string)$_disputed_transaction['seller_transaction_id'];
38
39 return '';
40 }
41
42 public static function paypal_webhook()
43 {
44 if(empty($_REQUEST['s2member_paypal_webhook']))
45 return;
46
47 //260218 Allow webhook processing even when Checkout buttons are disabled (if creds+webhook id exist).
48 if(!c_ws_plugin__s2member_paypal_utilities::paypal_checkout_webhook_processing_is_enabled())
49 {
50 status_header(404);
51 exit();
52 }
53 // Admin-only reachability test endpoint (does not validate signatures).
54 if(!empty($_GET['s2member_paypal_webhook_test']) && current_user_can('manage_options')
55 && !empty($_GET['_wpnonce']) && wp_verify_nonce((string)$_GET['_wpnonce'], 's2member_ppco_webhook_test'))
56 {
57 $env_site = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
58 $env_webhook = (!empty($_GET['ppco_webhook_env']) && $_GET['ppco_webhook_env'] === 'sandbox') ? 'sandbox' : 'live';
59
60 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
61 'ppco' => 'webhook',
62 'env_setting' => $env_site,
63 'env_webhook' => $env_webhook,
64 'event' => 'endpoint_test_ok',
65 'host' => !empty($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : '',
66 'uri' => !empty($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : '',
67 'ssl' => is_ssl() ? '1' : '0',
68 ));
69
70 status_header(200);
71 header('Content-Type: text/plain; charset=UTF-8');
72
73 $lines = array(
74 'SUCCESS',
75 '',
76 's2Member PayPal Webhook Endpoint (reachability test)',
77 'Environment setting: '.$env_site,
78 'Environment webhook: '.$env_webhook,
79 'SSL: '.(is_ssl() ? 'yes' : 'no'),
80 'Host: '.(!empty($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : ''),
81 'URI: '.(!empty($_SERVER['REQUEST_URI']) ? (string)$_SERVER['REQUEST_URI'] : ''),
82 'Timestamp (UTC): '.gmdate('Y-m-d H:i:s'),
83 '',
84 'Note: This is a reachability-only test. Real PayPal webhooks are POST requests and require signature verification.',
85 );
86
87 echo implode("\n", $lines);
88 exit();
89 }
90
91 if(strtoupper((string)$_SERVER['REQUEST_METHOD']) !== 'POST')
92 {
93 status_header(405);
94 exit();
95 }
96
97 $raw_body = file_get_contents('php://input');
98 $event = json_decode((string)$raw_body, true);
99
100 $headers = array();
101 if(function_exists('getallheaders'))
102 foreach((array)getallheaders() as $_k => $_v)
103 $headers[strtolower((string)$_k)] = (string)$_v;
104
105 // Fallback for hosts without getallheaders().
106 foreach(array(
107 'HTTP_PAYPAL_TRANSMISSION_ID' => 'paypal-transmission-id',
108 'HTTP_PAYPAL_TRANSMISSION_TIME' => 'paypal-transmission-time',
109 'HTTP_PAYPAL_TRANSMISSION_SIG' => 'paypal-transmission-sig',
110 'HTTP_PAYPAL_CERT_URL' => 'paypal-cert-url',
111 'HTTP_PAYPAL_AUTH_ALGO' => 'paypal-auth-algo',
112 ) as $_server => $_key)
113 if(empty($headers[$_key]) && !empty($_SERVER[$_server]))
114 $headers[$_key] = (string)$_SERVER[$_server];
115
116 //260206 Detect environment from inbound PayPal cert URL.
117 $cert_url = !empty($headers['paypal-cert-url']) ? (string)$headers['paypal-cert-url'] : '';
118 $env_site = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_is_sandbox() ? 'sandbox' : 'live';
119
120 $cert_host = $cert_url ? (string)parse_url($cert_url, PHP_URL_HOST) : '';
121 $env_webhook = 'unknown';
122
123 if($cert_host && preg_match('/(^|\.)paypal\.com$/i', $cert_host))
124 $env_webhook = (stripos($cert_host, 'sandbox') !== false || strpos($cert_url, 'sandbox') !== false) ? 'sandbox' : 'live';
125
126 if(!is_array($event) || empty($event['id']) || empty($event['event_type']))
127 {
128 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
129 'ppco' => 'webhook',
130 'env_setting' => $env_site,
131 'env_webhook' => $env_webhook,
132 'event' => 'invalid_payload',
133 ));
134 status_header(400);
135 exit();
136 }
137
138 $verified = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_verify_webhook_signature($event, $raw_body, $headers);
139 if(!$verified)
140 {
141 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
142 'ppco' => 'webhook',
143 'env_setting'=> $env_site,
144 'env_webhook'=> $env_webhook,
145 'event' => 'signature_failed',
146 'event_id' => (string)$event['id'],
147 'event_type' => (string)$event['event_type'],
148 'tx_id' => !empty($headers['paypal-transmission-id']) ? (string)$headers['paypal-transmission-id'] : '',
149 'tx_time' => !empty($headers['paypal-transmission-time']) ? (string)$headers['paypal-transmission-time'] : '',
150 'auth_algo' => !empty($headers['paypal-auth-algo']) ? (string)$headers['paypal-auth-algo'] : '',
151 'cert_url' => !empty($headers['paypal-cert-url']) ? (string)$headers['paypal-cert-url'] : '',
152 ));
153 status_header(400);
154 exit();
155 }
156
157 $event_id = (string)$event['id'];
158 $event_type = (string)$event['event_type'];
159
160 //260406 Use option-based dedupe/lock markers for PayPal Checkout because transients were not reliable enough on some sites.
161 $event_lock_option = 's2m_ppco_wh_lock_'.md5($event_id);
162 $event_done_option = 's2m_ppco_wh_done_'.md5($event_id);
163 $event_lock_ttl = 900;
164 $event_done_ttl = 6 * HOUR_IN_SECONDS;
165 $txn_done_ttl = DAY_IN_SECONDS;
166 $subscr_done_ttl = DAY_IN_SECONDS;
167
168 //260406 Occasionally clean up expired PayPal Checkout dedupe markers; the transient only throttles cleanup frequency.
169 c_ws_plugin__s2member_paypal_utilities::dedupe_markers_cleanup('s2m_ppco_dedupe_cleanup_throttle', array(
170 array('prefix' => 's2m_ppco_wh_done_', 'ttl' => $event_done_ttl),
171 array('prefix' => 's2m_ppco_txn_done_', 'ttl' => $txn_done_ttl),
172 array('prefix' => 's2m_ppco_subscr_done_', 'ttl' => $subscr_done_ttl),
173 ), 6 * HOUR_IN_SECONDS);
174
175 $event_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($event_done_option, $event_done_ttl);
176 if($event_done_time > 0)
177 {
178 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
179 'ppco' => 'webhook',
180 'env_setting'=> $env_site,
181 'env_webhook'=> $env_webhook,
182 'event' => 'duplicate_event',
183 'action' => 'ignored',
184 'note' => 'Duplicate webhook delivery (event_id already processed).',
185 'event_id' => $event_id,
186 'event_type' => $event_type,
187 ));
188 status_header(200);
189 exit();
190 }
191
192 if(!c_ws_plugin__s2member_paypal_utilities::dedupe_lock_acquire($event_lock_option, $event_lock_ttl))
193 {
194 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
195 'ppco' => 'webhook',
196 'env_setting'=> $env_site,
197 'env_webhook'=> $env_webhook,
198 'event' => 'duplicate_event',
199 'action' => 'ignored',
200 'note' => 'Duplicate webhook delivery (event_id already processing).',
201 'event_id' => $event_id,
202 'event_type' => $event_type,
203 ));
204 status_header(200);
205 exit();
206 }
207
208 $resource = !empty($event['resource']) && is_array($event['resource']) ? $event['resource'] : array();
209
210 $paypal = array();
211 $paypal['charset'] = 'utf-8';
212 $paypal['custom'] = !empty($_SERVER['HTTP_HOST']) ? (string)$_SERVER['HTTP_HOST'] : (string)parse_url(home_url('/'), PHP_URL_HOST);
213
214 $subscr_id = '';
215 $txn_id = '';
216
217 $txn_done_option = '';
218 $subscr_done_option = '';
219 $subscr_handled_by_webhook = false;
220
221 // Subscription lifecycle events.
222 if(strpos($event_type, 'BILLING.SUBSCRIPTION.') === 0)
223 {
224 if(!empty($resource['id']))
225 $subscr_id = (string)$resource['id'];
226
227 if($subscr_id)
228 $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.
229
230 //260401 Treat CREATED as informational only, and let ACTIVATED/RE-ACTIVATED act only as a fallback when checkout has not already handled this Subscription.
231 if($event_type === 'BILLING.SUBSCRIPTION.CREATED')
232 {
233 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
234 'ppco' => 'webhook',
235 'env_setting'=> $env_site,
236 'env_webhook'=> $env_webhook,
237 'event' => 'subscription_created',
238 'event_id' => $event_id,
239 'event_type' => $event_type,
240 'subscr_id' => $subscr_id,
241 ));
242
243 //260406 Mark the webhook event done and release its lock for valid terminal events.
244 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
245 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
246
247 status_header(200);
248 exit();
249 }
250 else if($event_type === 'BILLING.SUBSCRIPTION.ACTIVATED' || $event_type === 'BILLING.SUBSCRIPTION.RE-ACTIVATED')
251 {
252 $subscr_done_time = ($subscr_done_option) ? c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($subscr_done_option, $subscr_done_ttl) : 0;
253
254 //260401 Ignore webhook activation when checkout already handled this Subscription; otherwise allow webhook activation as a fallback.
255 if($subscr_done_option && $subscr_done_time > 0)
256 {
257 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
258 'ppco' => 'webhook',
259 'env_setting'=> $env_site,
260 'env_webhook'=> $env_webhook,
261 'event' => 'subscription_activation_ignored',
262 'note' => 'Checkout already handled this Subscription; skipping webhook fallback activation.',
263 'event_id' => $event_id,
264 'event_type' => $event_type,
265 'subscr_id' => $subscr_id,
266 'option' => $subscr_done_option,
267 ));
268
269 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
270 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
271
272 status_header(200);
273 exit();
274 }
275
276 //260818.0617 Recover the Checkout invoice from the verified PayPal event so Pro can restore prepared account state.
277 if(!empty($resource['custom_id']))
278 $paypal['invoice'] = (string)$resource['custom_id'];
279 else if($subscr_id)
280 {
281 $subscription_details = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details($subscr_id);
282 if(empty($subscription_details['__error']) && !empty($subscription_details['custom_id']))
283 $paypal['invoice'] = (string)$subscription_details['custom_id'];
284 }
285
286 //260818.0617 Do not let incomplete activation fallback bypass invoice-keyed prepared state; PayPal can retry delivery.
287 if(empty($paypal['invoice']))
288 {
289 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
290 'ppco' => 'webhook',
291 'env_setting'=> $env_site,
292 'env_webhook'=> $env_webhook,
293 'event' => 'subscription_activation_invoice_missing',
294 'event_id' => $event_id,
295 'event_type' => $event_type,
296 'subscr_id' => $subscr_id,
297 ));
298
299 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
300 status_header(500);
301 exit();
302 }
303
304 $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.
305 $paypal['payment_status'] = 'Completed';
306
307 $subscr_handled_by_webhook = true;
308 }
309 else if($event_type === 'BILLING.SUBSCRIPTION.UPDATED')
310 $paypal['txn_type'] = 'subscr_modify';
311 else if($event_type === 'BILLING.SUBSCRIPTION.CANCELLED')
312 $paypal['txn_type'] = 'subscr_cancel';
313 else if($event_type === 'BILLING.SUBSCRIPTION.SUSPENDED')
314 $paypal['txn_type'] = 'recurring_payment_suspended_due_to_max_failed_payment';
315 else if($event_type === 'BILLING.SUBSCRIPTION.EXPIRED')
316 $paypal['txn_type'] = 'subscr_eot';
317 else if($event_type === 'BILLING.SUBSCRIPTION.PAYMENT.FAILED')
318 $paypal['txn_type'] = 'subscr_failed';
319 else
320 {
321 // Ignore other BILLING.SUBSCRIPTION.* events.
322 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
323 'ppco' => 'webhook',
324 'env_setting'=> $env_site,
325 'env_webhook'=> $env_webhook,
326 'event' => 'ignored',
327 'event_id' => $event_id,
328 'event_type' => $event_type,
329 ));
330
331 //260406 Mark the webhook event done and release its lock for valid terminal events.
332 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
333 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
334
335 status_header(200);
336 exit();
337 }
338
339 $paypal['subscr_id'] = $subscr_id;
340 $paypal['txn_id'] = $event_id; // best-effort unique id
341
342 // Help legacy notify logic resolve a user when signup vars are missing (migrations, etc.).
343 $paypal['mp_id'] = $subscr_id;
344 $paypal['recurring_payment_id'] = $subscr_id;
345
346 // Best-effort payer email for logs/fallback logic.
347 if(!empty($resource['subscriber']['email_address']))
348 $paypal['payer_email'] = (string)$resource['subscriber']['email_address'];
349
350 // Enrich lifecycle events with stored signup vars so legacy notify handlers can match and set EOT properly.
351 //!!! TO-DO: Deduplicate signup-vars enrichment logic (also used in PayPal Checkout proxy confirm flow).
352 if(!empty($paypal['txn_type']) && $subscr_id
353 && 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)
354 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
355 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
356 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
357 )
358 {
359 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
360 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
361
362 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
363 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
364
365 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
366 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
367
368 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
369 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
370 }
371 }
372
373 //260824.1727 A newly opened dispute follows s2Member's established PayPal `new_case`/chargeback path.
374 else if($event_type === 'CUSTOMER.DISPUTE.CREATED')
375 {
376 //260824.1833 Use the shared extractor so documented dispute payloads are covered by direct runtime QA.
377 $seller_txn_id = self::paypal_checkout_dispute_seller_transaction_id($resource);
378
379 if(!$seller_txn_id)
380 {
381 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
382 'ppco' => 'webhook',
383 'env_setting'=> $env_site,
384 'env_webhook'=> $env_webhook,
385 'event' => 'dispute_transaction_missing',
386 'event_id' => $event_id,
387 'event_type' => $event_type,
388 'dispute_id' => !empty($resource['dispute_id']) ? (string)$resource['dispute_id'] : (!empty($resource['id']) ? (string)$resource['id'] : ''),
389 ));
390
391 // A verified but incomplete dispute should be retried; do not mark it complete.
392 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
393 status_header(500);
394 exit();
395 }
396
397 $subscr_id = $seller_txn_id;
398
399 // A first payment/one-time transaction may already identify the member directly.
400 if(($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($seller_txn_id)))
401 {
402 if(($user_subscr_id = get_user_option('s2member_subscr_id', $user_id)))
403 $subscr_id = (string)$user_subscr_id;
404 }
405 else
406 {
407 // Later Subscription payments identify the sale, not the Subscription; recover its billing agreement when available.
408 $sale = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/payments/sale/'.rawurlencode($seller_txn_id));
409
410 if(!empty($sale['code']) && (int)$sale['code'] === 200 && !empty($sale['body']) && is_string($sale['body']))
411 {
412 $sale_details = json_decode($sale['body'], true);
413
414 if(is_array($sale_details) && !empty($sale_details['billing_agreement_id']))
415 $subscr_id = (string)$sale_details['billing_agreement_id'];
416 }
417 }
418
419 $paypal['txn_type'] = 'new_case';
420 $paypal['case_type'] = 'chargeback';
421 $paypal['txn_id'] = $event_id;
422 $paypal['parent_txn_id'] = $seller_txn_id;
423 $paypal['subscr_id'] = $subscr_id;
424
425 $paypal['mp_id'] = $subscr_id;
426 $paypal['recurring_payment_id'] = $subscr_id;
427
428 if(!empty($resource['dispute_amount']['value']))
429 $paypal['mc_gross'] = (string)$resource['dispute_amount']['value'];
430 else
431 $paypal['mc_gross'] = '0';
432
433 if(!empty($resource['dispute_amount']['currency_code']))
434 $paypal['mc_currency'] = (string)$resource['dispute_amount']['currency_code'];
435 else
436 $paypal['mc_currency'] = $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_default_currency'];
437
438 if(!empty($resource['buyer']['email_address']))
439 $paypal['payer_email'] = (string)$resource['buyer']['email_address'];
440
441 // Recover the original signup context so the established chargeback handler can identify the membership.
442 if($subscr_id
443 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
444 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
445 )
446 {
447 foreach(array('item_number', 'item_name', 'period1', 'period3', 'payer_email') as $_signup_var)
448 if(empty($paypal[$_signup_var]) && !empty($ipn_signup_vars[$_signup_var]))
449 $paypal[$_signup_var] = (string)$ipn_signup_vars[$_signup_var];
450 }
451
452 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
453 'ppco' => 'webhook',
454 'env_setting' => $env_site,
455 'env_webhook' => $env_webhook,
456 'event' => 'dispute_created',
457 'event_id' => $event_id,
458 'event_type' => $event_type,
459 'dispute_id' => !empty($resource['dispute_id']) ? (string)$resource['dispute_id'] : (!empty($resource['id']) ? (string)$resource['id'] : ''),
460 'parent_txn_id'=> $seller_txn_id,
461 'subscr_id' => $subscr_id,
462 ));
463 }
464
465 // Recurring payment events (PayPal often emits PAYMENT.SALE.COMPLETED for subscription payments).
466 //260216 Add refund/reversal webhook support so refunds can trigger immediate EOT/demotion.
467 else if(in_array($event_type, array(
468 'PAYMENT.SALE.COMPLETED',
469 'PAYMENT.CAPTURE.COMPLETED',
470 'PAYMENT.SALE.REFUNDED',
471 'PAYMENT.CAPTURE.REFUNDED',
472 'PAYMENT.SALE.REVERSED',
473 'PAYMENT.CAPTURE.REVERSED',
474 ), true))
475 {
476 if(!empty($resource['billing_agreement_id']))
477 $subscr_id = (string)$resource['billing_agreement_id'];
478 else if(!empty($resource['parent_payment']))
479 $subscr_id = (string)$resource['parent_payment']; // fallback (not always present)
480 else if(!empty($resource['subscription_id']))
481 $subscr_id = (string)$resource['subscription_id'];
482 else if(!empty($resource['supplementary_data']['related_ids']['billing_agreement_id']))
483 $subscr_id = (string)$resource['supplementary_data']['related_ids']['billing_agreement_id'];
484
485 //260228 Ignore one-time sale/capture webhooks that have no subscription reference.
486 if(!$subscr_id)
487 {
488 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
489 'ppco' => 'webhook',
490 'env_setting'=> $env_site,
491 'env_webhook'=> $env_webhook,
492 'event' => 'ignored_non_subscription_payment',
493 'event_id' => $event_id,
494 'event_type' => $event_type,
495 'resource' => $resource,
496 ));
497
498 //260406 Mark the webhook event done and release its lock for valid terminal events.
499 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
500 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
501
502 status_header(200);
503 exit();
504 }
505
506 $paypal['txn_type'] = 'subscr_payment';
507
508 if(strpos($event_type, '.REFUNDED') !== false)
509 $paypal['payment_status'] = 'Refunded';
510 else if(strpos($event_type, '.REVERSED') !== false)
511 $paypal['payment_status'] = 'Reversed';
512 else
513 $paypal['payment_status'] = 'Completed';
514
515 if(!empty($resource['id']))
516 $txn_id = (string)$resource['id']; // original capture/sale id
517
518 if(!empty($resource['amount']['total']))
519 $paypal['mc_gross'] = (string)$resource['amount']['total'];
520 else if(!empty($resource['amount']['value']))
521 $paypal['mc_gross'] = (string)$resource['amount']['value'];
522
523 if(!empty($resource['amount']['currency']))
524 $paypal['mc_currency'] = (string)$resource['amount']['currency'];
525 else if(!empty($resource['amount']['currency_code']))
526 $paypal['mc_currency'] = (string)$resource['amount']['currency_code'];
527
528 if(!empty($resource['payer']['payer_info']['email']))
529 $paypal['payer_email'] = (string)$resource['payer']['payer_info']['email'];
530 else if(!empty($resource['payer']['email_address']))
531 $paypal['payer_email'] = (string)$resource['payer']['email_address'];
532
533 $paypal['subscr_id'] = $subscr_id;
534
535 //260216 Emulate IPN semantics for refund/reversal: parent_txn_id=original, txn_id=event delivery.
536 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status']))
537 {
538 $paypal['parent_txn_id'] = $txn_id ? $txn_id : $event_id;
539 $paypal['txn_id'] = $event_id;
540 }
541 else
542 $paypal['txn_id'] = $txn_id ? $txn_id : $event_id;
543
544 $paypal['mp_id'] = $subscr_id;
545 $paypal['recurring_payment_id'] = $subscr_id;
546
547 //260216 Enrich refund/reversal from stored signup vars so legacy handlers can demote immediately.
548 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status'])
549 && $subscr_id
550 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
551 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
552 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
553 )
554 {
555 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
556 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
557
558 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
559 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
560
561 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
562 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
563
564 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
565 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
566
567 if(empty($paypal['payer_email']) && !empty($ipn_signup_vars['payer_email']))
568 $paypal['payer_email'] = (string)$ipn_signup_vars['payer_email'];
569 }
570 }
571 else
572 {
573 // Ignore for MVP.
574 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
575 'ppco' => 'webhook',
576 'env_setting'=> $env_site,
577 'env_webhook'=> $env_webhook,
578 'event' => 'ignored',
579 'event_id' => $event_id,
580 'event_type' => $event_type,
581 ));
582
583 //260406 Mark the webhook event done and release its lock for valid terminal events.
584 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
585 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
586
587 status_header(200);
588 exit();
589 }
590
591 //260406 Idempotency per txn prevents different webhook event IDs from double-processing the same payment.
592 if(!empty($paypal['txn_type']))
593 {
594 $txn_key = (string)$event_id;
595
596 //260216 For refund/reversal, prefer idempotency on original payment id.
597 if(!empty($paypal['parent_txn_id']))
598 $txn_key = (string)$paypal['parent_txn_id'];
599 else if(!empty($paypal['txn_id']))
600 $txn_key = (string)$paypal['txn_id'];
601
602 //260824.1727 Refunds, reversals, and disputes can share the original payment ID; keep each later state independently idempotent.
603 $txn_dedupe_key = $txn_key;
604 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status']))
605 $txn_dedupe_key = strtolower((string)$paypal['payment_status']).'|'.$txn_key;
606 else if(!empty($paypal['txn_type']) && $paypal['txn_type'] === 'new_case' && !empty($paypal['case_type']) && $paypal['case_type'] === 'chargeback')
607 $txn_dedupe_key = 'chargeback|'.$txn_key;
608
609 $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_dedupe_key);
610
611 if($txn_key)
612 {
613 $txn_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($txn_done_option, $txn_done_ttl);
614
615 if($txn_done_time > 0)
616 {
617 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
618 'ppco' => 'webhook',
619 'env_setting'=> $env_site,
620 'env_webhook'=> $env_webhook,
621 'event' => 'duplicate_txn',
622 'action' => 'ignored',
623 'note' => 'Duplicate webhook delivery (txn_id already processed).',
624 'event_id' => $event_id,
625 'event_type' => $event_type,
626 'subscr_id' => $subscr_id,
627 'txn_id' => !empty($paypal['txn_id']) ? (string)$paypal['txn_id'] : '',
628 'option' => $txn_done_option,
629 ));
630
631 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
632 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
633
634 status_header(200);
635 exit();
636 }
637 }
638 }
639
640 // Proxy into existing s2Member PayPal notify handler to reuse all provisioning/eot logic.
641 $url = add_query_arg('s2member_paypal_notify', '1', home_url('/'));
642 $notify_duplicate = false;
643
644 if($subscr_handled_by_webhook && !empty($subscr_done_option))
645 {
646 //260818.0603 Share the subscription Notify lock/done marker with browser confirmation so activation fallback cannot race it.
647 $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $subscr_done_option, 'paypal_checkout_webhook');
648 $notify_ok = !empty($notify_result['ok']);
649 $notify_duplicate = !empty($notify_result['duplicate']);
650 $code = !empty($notify_result['code']) ? (int)$notify_result['code'] : 0;
651 $message = !empty($notify_result['message']) ? (string)$notify_result['message'] : (!empty($notify_result['error']) ? (string)$notify_result['error'] : '');
652 }
653 else
654 {
655 $post = array_merge($paypal, array(
656 's2member_paypal_proxy' => 'paypal',
657 's2member_paypal_proxy_use' => 'paypal_checkout_webhook',
658 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
659 ));
660
661 $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
662 'timeout' => 20,
663 ), true);
664
665 if(!is_array($r))
666 $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
667
668 $code = !empty($r['code']) ? (int)$r['code'] : 0;
669 $message = !empty($r['message']) ? (string)$r['message'] : '';
670 $notify_ok = ($code >= 200 && $code <= 299);
671 }
672
673 if($notify_ok)
674 {
675 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
676 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
677
678 if(!empty($txn_done_option))
679 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($txn_done_option);
680
681 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
682 'ppco' => 'webhook',
683 'env_setting'=> $env_site,
684 'env_webhook'=> $env_webhook,
685 'event' => 'notify_proxy_response',
686 'event_id' => $event_id,
687 'event_type' => $event_type,
688 'subscr_id' => $subscr_id,
689 'txn_id' => $txn_id ? $txn_id : $event_id,
690 'url' => $url,
691 'code' => $code,
692 'message' => $message,
693 'duplicate' => $notify_duplicate,
694 ));
695 }
696 else
697 {
698 //260406 Release the in-flight webhook lock on failure so PayPal retries can proceed.
699 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
700
701 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
702 'ppco' => 'webhook',
703 'env_setting'=> $env_site,
704 'env_webhook'=> $env_webhook,
705 'event' => 'notify_proxy_failed',
706 'event_id' => $event_id,
707 'event_type' => $event_type,
708 'subscr_id' => $subscr_id,
709 'txn_id' => $txn_id ? $txn_id : $event_id,
710 'url' => $url,
711 'code' => $code,
712 'message' => $message,
713 ));
714
715 //260818.0603 Activation fallback must remain retryable when shared fulfillment fails or is still in progress.
716 if($subscr_handled_by_webhook)
717 {
718 status_header(500);
719 exit();
720 }
721 }
722
723 status_header(200);
724 exit();
725 }
726 }
727 }
728