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

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

890 lines 38.7 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 if($event_type === 'BILLING.SUBSCRIPTION.CREATED')
231 {
232 $invoice = !empty($resource['custom_id']) ? (string)$resource['custom_id'] : '';
233 if(!$invoice && $subscr_id)
234 {
235 $subscription_details = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details($subscr_id);
236 if(!empty($subscription_details['__error']))
237 {
238 //260902.0224 A temporary details lookup failure must not consume CREATED; ask PayPal to retry so an ambiguous browser create can still be repaired off-session.
239 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
240 'ppco' => 'webhook',
241 'env_setting'=> $env_site,
242 'env_webhook'=> $env_webhook,
243 'event' => 'subscription_created_details_failed',
244 'event_id' => $event_id,
245 'subscr_id' => $subscr_id,
246 'details' => $subscription_details,
247 ));
248 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
249 status_header(500);
250 exit();
251 }
252 if(!empty($subscription_details['custom_id']))
253 $invoice = (string)$subscription_details['custom_id'];
254 }
255
256 $status = !empty($resource['status']) ? strtoupper((string)$resource['status']) : 'APPROVAL_PENDING';
257 $recovery = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_gateway_checkout_recover($invoice, $subscr_id, $status);
258 if(!empty($recovery['handled']) && empty($recovery['ok']))
259 {
260 if(!empty($recovery['error']) && (string)$recovery['error'] === 'gateway_checkout_subscription_conflict')
261 {
262 //260902.0200 Never overwrite an already-authoritative subscription ID; a conflicting late CREATED event is diagnostic only and must not trigger fulfillment.
263 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
264 'ppco' => 'webhook',
265 'env_setting'=> $env_site,
266 'env_webhook'=> $env_webhook,
267 'event' => 'subscription_created_conflict_ignored',
268 'event_id' => $event_id,
269 'subscr_id' => $subscr_id,
270 'invoice' => $invoice,
271 'recovery' => $recovery,
272 ));
273 }
274 else
275 {
276 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
277 'ppco' => 'webhook',
278 'env_setting'=> $env_site,
279 'env_webhook'=> $env_webhook,
280 'event' => 'subscription_created_recovery_failed',
281 'event_id' => $event_id,
282 'subscr_id' => $subscr_id,
283 'invoice' => $invoice,
284 'recovery' => $recovery,
285 ));
286 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
287 status_header(500);
288 exit();
289 }
290 }
291
292 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
293 'ppco' => 'webhook',
294 'env_setting'=> $env_site,
295 'env_webhook'=> $env_webhook,
296 'event' => 'subscription_created',
297 'event_id' => $event_id,
298 'event_type' => $event_type,
299 'subscr_id' => $subscr_id,
300 'invoice' => $invoice,
301 'recovery' => $recovery,
302 ));
303
304 //260902.0200 CREATED repairs coordinator identity only; it remains unpaid/unfulfilled until PayPal activates the subscription.
305 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
306 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
307
308 status_header(200);
309 exit();
310 }
311 else if($event_type === 'BILLING.SUBSCRIPTION.ACTIVATED' || $event_type === 'BILLING.SUBSCRIPTION.RE-ACTIVATED')
312 {
313 $subscr_done_time = ($subscr_done_option) ? c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($subscr_done_option, $subscr_done_ttl) : 0;
314
315 //260401 Ignore webhook activation when checkout already handled this Subscription; otherwise allow webhook activation as a fallback.
316 if($subscr_done_option && $subscr_done_time > 0)
317 {
318 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
319 'ppco' => 'webhook',
320 'env_setting'=> $env_site,
321 'env_webhook'=> $env_webhook,
322 'event' => 'subscription_activation_ignored',
323 'note' => 'Checkout already handled this Subscription; skipping webhook fallback activation.',
324 'event_id' => $event_id,
325 'event_type' => $event_type,
326 'subscr_id' => $subscr_id,
327 'option' => $subscr_done_option,
328 ));
329
330 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
331 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
332
333 status_header(200);
334 exit();
335 }
336
337 //260818.0617 Recover the Checkout invoice from the verified PayPal event so Pro can restore prepared account state.
338 if(!empty($resource['custom_id']))
339 $paypal['invoice'] = (string)$resource['custom_id'];
340 else if($subscr_id)
341 {
342 $subscription_details = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details($subscr_id);
343 if(empty($subscription_details['__error']) && !empty($subscription_details['custom_id']))
344 $paypal['invoice'] = (string)$subscription_details['custom_id'];
345 }
346
347 //260818.0617 Do not let incomplete activation fallback bypass invoice-keyed prepared state; PayPal can retry delivery.
348 if(empty($paypal['invoice']))
349 {
350 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
351 'ppco' => 'webhook',
352 'env_setting'=> $env_site,
353 'env_webhook'=> $env_webhook,
354 'event' => 'subscription_activation_invoice_missing',
355 'event_id' => $event_id,
356 'event_type' => $event_type,
357 'subscr_id' => $subscr_id,
358 ));
359
360 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
361 status_header(500);
362 exit();
363 }
364
365 $activation_recovery = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_gateway_checkout_recover((string)$paypal['invoice'], $subscr_id, 'ACTIVE');
366 if(!empty($activation_recovery['handled']) && empty($activation_recovery['ok']))
367 {
368 if(!empty($activation_recovery['error']) && (string)$activation_recovery['error'] === 'gateway_checkout_subscription_conflict')
369 {
370 //260902.0200 A conflicting coordinator subscription must never be fulfilled as the expected checkout; leave the authoritative ID untouched for administrator diagnostics.
371 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
372 'ppco' => 'webhook',
373 'env_setting'=> $env_site,
374 'env_webhook'=> $env_webhook,
375 'event' => 'subscription_activation_conflict_ignored',
376 'event_id' => $event_id,
377 'subscr_id' => $subscr_id,
378 'invoice' => (string)$paypal['invoice'],
379 'recovery' => $activation_recovery,
380 ));
381 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
382 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
383 status_header(200);
384 exit();
385 }
386
387 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
388 status_header(500);
389 exit();
390 }
391
392 $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.
393 $paypal['payment_status'] = 'Completed';
394
395 $subscr_handled_by_webhook = true;
396 }
397 else if($event_type === 'BILLING.SUBSCRIPTION.UPDATED')
398 $paypal['txn_type'] = 'subscr_modify';
399 else if($event_type === 'BILLING.SUBSCRIPTION.CANCELLED')
400 $paypal['txn_type'] = 'subscr_cancel';
401 else if($event_type === 'BILLING.SUBSCRIPTION.SUSPENDED')
402 $paypal['txn_type'] = 'recurring_payment_suspended_due_to_max_failed_payment';
403 else if($event_type === 'BILLING.SUBSCRIPTION.EXPIRED')
404 $paypal['txn_type'] = 'subscr_eot';
405 else if($event_type === 'BILLING.SUBSCRIPTION.PAYMENT.FAILED')
406 $paypal['txn_type'] = 'subscr_failed';
407 else
408 {
409 // Ignore other BILLING.SUBSCRIPTION.* events.
410 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
411 'ppco' => 'webhook',
412 'env_setting'=> $env_site,
413 'env_webhook'=> $env_webhook,
414 'event' => 'ignored',
415 'event_id' => $event_id,
416 'event_type' => $event_type,
417 ));
418
419 //260406 Mark the webhook event done and release its lock for valid terminal events.
420 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
421 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
422
423 status_header(200);
424 exit();
425 }
426
427 $paypal['subscr_id'] = $subscr_id;
428 $paypal['txn_id'] = $event_id; // best-effort unique id
429
430 // Help legacy notify logic resolve a user when signup vars are missing (migrations, etc.).
431 $paypal['mp_id'] = $subscr_id;
432 $paypal['recurring_payment_id'] = $subscr_id;
433
434 // Best-effort payer email for logs/fallback logic.
435 if(!empty($resource['subscriber']['email_address']))
436 $paypal['payer_email'] = (string)$resource['subscriber']['email_address'];
437
438 // Enrich lifecycle events with stored signup vars so legacy notify handlers can match and set EOT properly.
439 //!!! TO-DO: Deduplicate signup-vars enrichment logic (also used in PayPal Checkout proxy confirm flow).
440 if(!empty($paypal['txn_type']) && $subscr_id
441 && 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)
442 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
443 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
444 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
445 )
446 {
447 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
448 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
449
450 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
451 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
452
453 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
454 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
455
456 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
457 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
458 }
459 }
460
461 //260824.1727 A newly opened dispute follows s2Member's established PayPal `new_case`/chargeback path.
462 else if($event_type === 'CUSTOMER.DISPUTE.CREATED')
463 {
464 //260824.1833 Use the shared extractor so documented dispute payloads are covered by direct runtime QA.
465 $seller_txn_id = self::paypal_checkout_dispute_seller_transaction_id($resource);
466
467 if(!$seller_txn_id)
468 {
469 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
470 'ppco' => 'webhook',
471 'env_setting'=> $env_site,
472 'env_webhook'=> $env_webhook,
473 'event' => 'dispute_transaction_missing',
474 'event_id' => $event_id,
475 'event_type' => $event_type,
476 'dispute_id' => !empty($resource['dispute_id']) ? (string)$resource['dispute_id'] : (!empty($resource['id']) ? (string)$resource['id'] : ''),
477 ));
478
479 // A verified but incomplete dispute should be retried; do not mark it complete.
480 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
481 status_header(500);
482 exit();
483 }
484
485 $subscr_id = $seller_txn_id;
486
487 // A first payment/one-time transaction may already identify the member directly.
488 if(($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($seller_txn_id)))
489 {
490 if(($user_subscr_id = get_user_option('s2member_subscr_id', $user_id)))
491 $subscr_id = (string)$user_subscr_id;
492 }
493 else
494 {
495 // Later Subscription payments identify the sale, not the Subscription; recover its billing agreement when available.
496 $sale = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/payments/sale/'.rawurlencode($seller_txn_id));
497
498 if(!empty($sale['code']) && (int)$sale['code'] === 200 && !empty($sale['body']) && is_string($sale['body']))
499 {
500 $sale_details = json_decode($sale['body'], true);
501
502 if(is_array($sale_details) && !empty($sale_details['billing_agreement_id']))
503 $subscr_id = (string)$sale_details['billing_agreement_id'];
504 }
505 }
506
507 $paypal['txn_type'] = 'new_case';
508 $paypal['case_type'] = 'chargeback';
509 $paypal['txn_id'] = $event_id;
510 $paypal['parent_txn_id'] = $seller_txn_id;
511 $paypal['subscr_id'] = $subscr_id;
512
513 $paypal['mp_id'] = $subscr_id;
514 $paypal['recurring_payment_id'] = $subscr_id;
515
516 if(!empty($resource['dispute_amount']['value']))
517 $paypal['mc_gross'] = (string)$resource['dispute_amount']['value'];
518 else
519 $paypal['mc_gross'] = '0';
520
521 if(!empty($resource['dispute_amount']['currency_code']))
522 $paypal['mc_currency'] = (string)$resource['dispute_amount']['currency_code'];
523 else
524 $paypal['mc_currency'] = $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_default_currency'];
525
526 if(!empty($resource['buyer']['email_address']))
527 $paypal['payer_email'] = (string)$resource['buyer']['email_address'];
528
529 // Recover the original signup context so the established chargeback handler can identify the membership.
530 if($subscr_id
531 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
532 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
533 )
534 {
535 foreach(array('item_number', 'item_name', 'period1', 'period3', 'payer_email') as $_signup_var)
536 if(empty($paypal[$_signup_var]) && !empty($ipn_signup_vars[$_signup_var]))
537 $paypal[$_signup_var] = (string)$ipn_signup_vars[$_signup_var];
538 }
539
540 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
541 'ppco' => 'webhook',
542 'env_setting' => $env_site,
543 'env_webhook' => $env_webhook,
544 'event' => 'dispute_created',
545 'event_id' => $event_id,
546 'event_type' => $event_type,
547 'dispute_id' => !empty($resource['dispute_id']) ? (string)$resource['dispute_id'] : (!empty($resource['id']) ? (string)$resource['id'] : ''),
548 'parent_txn_id'=> $seller_txn_id,
549 'subscr_id' => $subscr_id,
550 ));
551 }
552
553 // Recurring payment events (PayPal often emits PAYMENT.SALE.COMPLETED for subscription payments).
554 //260216 Add refund/reversal webhook support so refunds can trigger immediate EOT/demotion.
555 else if(in_array($event_type, array(
556 'PAYMENT.SALE.COMPLETED',
557 'PAYMENT.CAPTURE.PENDING',
558 'PAYMENT.CAPTURE.COMPLETED',
559 'PAYMENT.CAPTURE.DENIED',
560 'PAYMENT.SALE.REFUNDED',
561 'PAYMENT.CAPTURE.REFUNDED',
562 'PAYMENT.SALE.REVERSED',
563 'PAYMENT.CAPTURE.REVERSED',
564 ), true))
565 {
566 if(!empty($resource['billing_agreement_id']))
567 $subscr_id = (string)$resource['billing_agreement_id'];
568 else if(!empty($resource['parent_payment']))
569 $subscr_id = (string)$resource['parent_payment']; // fallback (not always present)
570 else if(!empty($resource['subscription_id']))
571 $subscr_id = (string)$resource['subscription_id'];
572 else if(!empty($resource['supplementary_data']['related_ids']['billing_agreement_id']))
573 $subscr_id = (string)$resource['supplementary_data']['related_ids']['billing_agreement_id'];
574
575 //260907.1820 One-time PayPal Checkout captures intentionally have no subscription reference; resolve order -> invoice -> Gateway Checkout here before the legacy no-subscription ignore path below.
576 if(!$subscr_id && in_array($event_type, array('PAYMENT.CAPTURE.PENDING', 'PAYMENT.CAPTURE.COMPLETED', 'PAYMENT.CAPTURE.DENIED'), TRUE))
577 {
578 $order_id = !empty($resource['supplementary_data']['related_ids']['order_id']) ? (string)$resource['supplementary_data']['related_ids']['order_id'] : '';
579 if($order_id)
580 {
581 $order = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_details($order_id);
582 if(!empty($order['__error']))
583 {
584 //260902.0635 Do not consume a coordinator capture webhook when its authoritative order lookup temporarily fails; PayPal can redeliver it.
585 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
586 status_header(500);
587 exit();
588 }
589
590 $invoice = !empty($order['purchase_units'][0]['invoice_id']) ? (string)$order['purchase_units'][0]['invoice_id'] : '';
591 $gateway_checkout_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
592 if($gateway_checkout_id)
593 {
594 $capture_id = !empty($resource['id']) ? (string)$resource['id'] : '';
595 $capture_status = ($event_type === 'PAYMENT.CAPTURE.COMPLETED') ? 'COMPLETED' : (($event_type === 'PAYMENT.CAPTURE.DENIED') ? 'DENIED' : 'PENDING');
596 $recovery = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $capture_id, $capture_status, 'webhook');
597 if(!empty($recovery['handled']) && empty($recovery['ok']))
598 {
599 if(!empty($recovery['error']) && in_array((string)$recovery['error'], array('gateway_checkout_order_conflict', 'gateway_checkout_capture_conflict'), TRUE))
600 {
601 //260902.0646 A conflicting late webhook is diagnostic only; never let it replace or fulfill against the checkout's authoritative provider identity.
602 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array('ppco' => 'webhook', 'event' => 'capture_recovery_conflict_ignored', 'event_id' => $event_id, 'event_type' => $event_type, 'recovery' => $recovery));
603 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
604 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
605 status_header(200);
606 exit();
607 }
608 else
609 {
610 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
611 status_header(500);
612 exit();
613 }
614 }
615
616 //260907.1820 PENDING and DENIED events only reconcile state; COMPLETED is the sole capture event allowed to cross the entitlement boundary into shared fulfillment.
617 if($capture_status === 'COMPLETED')
618 {
619 //260907.1820 Off-session fulfillment must use the encrypted server-validated purchase token; never reconstruct trusted price/access terms from the webhook payload itself.
620 $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
621 $token = is_array($private_context) && !empty($private_context['paypal_checkout']['token']) && is_array($private_context['paypal_checkout']['token']) ? $private_context['paypal_checkout']['token'] : array();
622 if(!$token || ($validation_error = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_completion_error($order, $order_id, $token)))
623 {
624 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
625 status_header(500);
626 exit();
627 }
628
629 $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($order, $token);
630 if(empty($fulfillment['ok']))
631 {
632 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
633 status_header(500);
634 exit();
635 }
636 }
637
638 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array('ppco' => 'webhook', 'event' => 'one_time_capture_recovered', 'event_id' => $event_id, 'event_type' => $event_type, 'order_id' => $order_id, 'capture_id' => $capture_id, 'invoice' => $invoice));
639 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
640 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
641 status_header(200);
642 exit();
643 }
644 }
645 }
646
647 //260228 Ignore legacy/non-coordinator one-time sale/capture webhooks that have no subscription reference.
648 if(!$subscr_id)
649 {
650 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
651 'ppco' => 'webhook',
652 'env_setting'=> $env_site,
653 'env_webhook'=> $env_webhook,
654 'event' => 'ignored_non_subscription_payment',
655 'event_id' => $event_id,
656 'event_type' => $event_type,
657 'resource' => $resource,
658 ));
659
660 //260406 Mark the webhook event done and release its lock for valid terminal events.
661 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
662 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
663
664 status_header(200);
665 exit();
666 }
667
668 $paypal['txn_type'] = 'subscr_payment';
669
670 if(strpos($event_type, '.REFUNDED') !== false)
671 $paypal['payment_status'] = 'Refunded';
672 else if(strpos($event_type, '.REVERSED') !== false)
673 $paypal['payment_status'] = 'Reversed';
674 else
675 $paypal['payment_status'] = 'Completed';
676
677 if(!empty($resource['id']))
678 $txn_id = (string)$resource['id']; // original capture/sale id
679
680 if(!empty($resource['amount']['total']))
681 $paypal['mc_gross'] = (string)$resource['amount']['total'];
682 else if(!empty($resource['amount']['value']))
683 $paypal['mc_gross'] = (string)$resource['amount']['value'];
684
685 if(!empty($resource['amount']['currency']))
686 $paypal['mc_currency'] = (string)$resource['amount']['currency'];
687 else if(!empty($resource['amount']['currency_code']))
688 $paypal['mc_currency'] = (string)$resource['amount']['currency_code'];
689
690 if(!empty($resource['payer']['payer_info']['email']))
691 $paypal['payer_email'] = (string)$resource['payer']['payer_info']['email'];
692 else if(!empty($resource['payer']['email_address']))
693 $paypal['payer_email'] = (string)$resource['payer']['email_address'];
694
695 $paypal['subscr_id'] = $subscr_id;
696
697 //260216 Emulate IPN semantics for refund/reversal: parent_txn_id=original, txn_id=event delivery.
698 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status']))
699 {
700 $paypal['parent_txn_id'] = $txn_id ? $txn_id : $event_id;
701 $paypal['txn_id'] = $event_id;
702 }
703 else
704 $paypal['txn_id'] = $txn_id ? $txn_id : $event_id;
705
706 $paypal['mp_id'] = $subscr_id;
707 $paypal['recurring_payment_id'] = $subscr_id;
708
709 //260216 Enrich refund/reversal from stored signup vars so legacy handlers can demote immediately.
710 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status'])
711 && $subscr_id
712 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
713 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
714 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
715 )
716 {
717 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
718 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
719
720 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
721 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
722
723 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
724 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
725
726 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
727 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
728
729 if(empty($paypal['payer_email']) && !empty($ipn_signup_vars['payer_email']))
730 $paypal['payer_email'] = (string)$ipn_signup_vars['payer_email'];
731 }
732 }
733 else
734 {
735 // Ignore for MVP.
736 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
737 'ppco' => 'webhook',
738 'env_setting'=> $env_site,
739 'env_webhook'=> $env_webhook,
740 'event' => 'ignored',
741 'event_id' => $event_id,
742 'event_type' => $event_type,
743 ));
744
745 //260406 Mark the webhook event done and release its lock for valid terminal events.
746 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
747 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
748
749 status_header(200);
750 exit();
751 }
752
753 //260406 Idempotency per txn prevents different webhook event IDs from double-processing the same payment.
754 if(!empty($paypal['txn_type']))
755 {
756 $txn_key = (string)$event_id;
757
758 //260216 For refund/reversal, prefer idempotency on original payment id.
759 if(!empty($paypal['parent_txn_id']))
760 $txn_key = (string)$paypal['parent_txn_id'];
761 else if(!empty($paypal['txn_id']))
762 $txn_key = (string)$paypal['txn_id'];
763
764 //260824.1727 Refunds, reversals, and disputes can share the original payment ID; keep each later state independently idempotent.
765 $txn_dedupe_key = $txn_key;
766 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status']))
767 $txn_dedupe_key = strtolower((string)$paypal['payment_status']).'|'.$txn_key;
768 else if(!empty($paypal['txn_type']) && $paypal['txn_type'] === 'new_case' && !empty($paypal['case_type']) && $paypal['case_type'] === 'chargeback')
769 $txn_dedupe_key = 'chargeback|'.$txn_key;
770
771 $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_dedupe_key);
772
773 if($txn_key)
774 {
775 $txn_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($txn_done_option, $txn_done_ttl);
776
777 if($txn_done_time > 0)
778 {
779 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
780 'ppco' => 'webhook',
781 'env_setting'=> $env_site,
782 'env_webhook'=> $env_webhook,
783 'event' => 'duplicate_txn',
784 'action' => 'ignored',
785 'note' => 'Duplicate webhook delivery (txn_id already processed).',
786 'event_id' => $event_id,
787 'event_type' => $event_type,
788 'subscr_id' => $subscr_id,
789 'txn_id' => !empty($paypal['txn_id']) ? (string)$paypal['txn_id'] : '',
790 'option' => $txn_done_option,
791 ));
792
793 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
794 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
795
796 status_header(200);
797 exit();
798 }
799 }
800 }
801
802 // Proxy into existing s2Member PayPal notify handler to reuse all provisioning/eot logic.
803 $url = add_query_arg('s2member_paypal_notify', '1', home_url('/'));
804 $notify_duplicate = false;
805
806 if($subscr_handled_by_webhook && !empty($subscr_done_option))
807 {
808 //260818.0603 Share the subscription Notify lock/done marker with browser confirmation so activation fallback cannot race it.
809 $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $subscr_done_option, 'paypal_checkout_webhook');
810 $notify_ok = !empty($notify_result['ok']);
811 $notify_duplicate = !empty($notify_result['duplicate']);
812 $code = !empty($notify_result['code']) ? (int)$notify_result['code'] : 0;
813 $message = !empty($notify_result['message']) ? (string)$notify_result['message'] : (!empty($notify_result['error']) ? (string)$notify_result['error'] : '');
814 }
815 else
816 {
817 $post = array_merge($paypal, array(
818 's2member_paypal_proxy' => 'paypal',
819 's2member_paypal_proxy_use' => 'paypal_checkout_webhook',
820 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
821 ));
822
823 $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
824 'timeout' => 20,
825 ), true);
826
827 if(!is_array($r))
828 $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
829
830 $code = !empty($r['code']) ? (int)$r['code'] : 0;
831 $message = !empty($r['message']) ? (string)$r['message'] : '';
832 $notify_ok = ($code >= 200 && $code <= 299);
833 }
834
835 if($notify_ok)
836 {
837 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
838 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
839
840 if(!empty($txn_done_option))
841 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($txn_done_option);
842
843 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
844 'ppco' => 'webhook',
845 'env_setting'=> $env_site,
846 'env_webhook'=> $env_webhook,
847 'event' => 'notify_proxy_response',
848 'event_id' => $event_id,
849 'event_type' => $event_type,
850 'subscr_id' => $subscr_id,
851 'txn_id' => $txn_id ? $txn_id : $event_id,
852 'url' => $url,
853 'code' => $code,
854 'message' => $message,
855 'duplicate' => $notify_duplicate,
856 ));
857 }
858 else
859 {
860 //260406 Release the in-flight webhook lock on failure so PayPal retries can proceed.
861 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
862
863 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
864 'ppco' => 'webhook',
865 'env_setting'=> $env_site,
866 'env_webhook'=> $env_webhook,
867 'event' => 'notify_proxy_failed',
868 'event_id' => $event_id,
869 'event_type' => $event_type,
870 'subscr_id' => $subscr_id,
871 'txn_id' => $txn_id ? $txn_id : $event_id,
872 'url' => $url,
873 'code' => $code,
874 'message' => $message,
875 ));
876
877 //260818.0603 Activation fallback must remain retryable when shared fulfillment fails or is still in progress.
878 if($subscr_handled_by_webhook)
879 {
880 status_header(500);
881 exit();
882 }
883 }
884
885 status_header(200);
886 exit();
887 }
888 }
889 }
890