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

913 lines 40.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 // @codingStandardsIgnoreFile
3 /**
4 * s2Member's PayPal Checkout 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
393 //260928.1538 Standalone Framework buttons are coordinator-backed; a verified activation now recovers all original purchase terms and completes membership even if the browser never calls confirm_subscription.
394 if(strpos((string)$paypal['invoice'], 's2mb-') === 0)
395 {
396 $button_checkout_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_gateway_checkout_id_from_invoice((string)$paypal['invoice']);
397 $button_private = $button_checkout_id ? c_ws_plugin__s2member_gateway_checkouts::private_context_get($button_checkout_id) : FALSE;
398 $button_token = is_array($button_private) && !empty($button_private['paypal_checkout']['token']) && is_array($button_private['paypal_checkout']['token']) ? $button_private['paypal_checkout']['token'] : array();
399 $subscription_details = $subscr_id ? c_ws_plugin__s2member_paypal_utilities::paypal_checkout_subscription_details($subscr_id) : array();
400 $button_fulfillment = (!$button_token || !empty($subscription_details['__error']))
401 ? array('ok' => FALSE, 'error' => 'gateway_checkout_purchase_context_or_provider_missing')
402 : c_ws_plugin__s2member_paypal_utilities::paypal_checkout_button_subscription_fulfill($subscription_details, $button_token, 'webhook');
403 if(empty($button_fulfillment['ok']))
404 {
405 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array('ppco' => 'webhook', 'event' => 'button_subscription_fulfillment_retry', 'subscription_id' => $subscr_id, 'invoice' => (string)$paypal['invoice'], 'error' => !empty($button_fulfillment['error']) ? (string)$button_fulfillment['error'] : 'unknown'));
406 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
407 status_header(500); //260928.1538 Leave the activation event unconsumed so PayPal retries on temporary recovery/Notify failure.
408 exit();
409 }
410 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
411 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
412 status_header(200);
413 exit();
414 }
415 $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.
416 $paypal['payment_status'] = 'Completed';
417
418 $subscr_handled_by_webhook = true;
419 }
420 else if($event_type === 'BILLING.SUBSCRIPTION.UPDATED')
421 $paypal['txn_type'] = 'subscr_modify';
422 else if($event_type === 'BILLING.SUBSCRIPTION.CANCELLED')
423 $paypal['txn_type'] = 'subscr_cancel';
424 else if($event_type === 'BILLING.SUBSCRIPTION.SUSPENDED')
425 $paypal['txn_type'] = 'recurring_payment_suspended_due_to_max_failed_payment';
426 else if($event_type === 'BILLING.SUBSCRIPTION.EXPIRED')
427 $paypal['txn_type'] = 'subscr_eot';
428 else if($event_type === 'BILLING.SUBSCRIPTION.PAYMENT.FAILED')
429 $paypal['txn_type'] = 'subscr_failed';
430 else
431 {
432 // Ignore other BILLING.SUBSCRIPTION.* events.
433 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
434 'ppco' => 'webhook',
435 'env_setting'=> $env_site,
436 'env_webhook'=> $env_webhook,
437 'event' => 'ignored',
438 'event_id' => $event_id,
439 'event_type' => $event_type,
440 ));
441
442 //260406 Mark the webhook event done and release its lock for valid terminal events.
443 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
444 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
445
446 status_header(200);
447 exit();
448 }
449
450 $paypal['subscr_id'] = $subscr_id;
451 $paypal['txn_id'] = $event_id; // best-effort unique id
452
453 // Help legacy notify logic resolve a user when signup vars are missing (migrations, etc.).
454 $paypal['mp_id'] = $subscr_id;
455 $paypal['recurring_payment_id'] = $subscr_id;
456
457 // Best-effort payer email for logs/fallback logic.
458 if(!empty($resource['subscriber']['email_address']))
459 $paypal['payer_email'] = (string)$resource['subscriber']['email_address'];
460
461 // Enrich lifecycle events with stored signup vars so legacy notify handlers can match and set EOT properly.
462 //!!! TO-DO: Deduplicate signup-vars enrichment logic (also used in PayPal Checkout proxy confirm flow).
463 if(!empty($paypal['txn_type']) && $subscr_id
464 && 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)
465 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
466 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
467 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
468 )
469 {
470 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
471 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
472
473 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
474 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
475
476 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
477 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
478
479 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
480 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
481 }
482 }
483
484 //260824.1727 A newly opened dispute follows s2Member's established PayPal `new_case`/chargeback path.
485 else if($event_type === 'CUSTOMER.DISPUTE.CREATED')
486 {
487 //260824.1833 Use the shared extractor so documented dispute payloads are covered by direct runtime QA.
488 $seller_txn_id = self::paypal_checkout_dispute_seller_transaction_id($resource);
489
490 if(!$seller_txn_id)
491 {
492 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
493 'ppco' => 'webhook',
494 'env_setting'=> $env_site,
495 'env_webhook'=> $env_webhook,
496 'event' => 'dispute_transaction_missing',
497 'event_id' => $event_id,
498 'event_type' => $event_type,
499 'dispute_id' => !empty($resource['dispute_id']) ? (string)$resource['dispute_id'] : (!empty($resource['id']) ? (string)$resource['id'] : ''),
500 ));
501
502 // A verified but incomplete dispute should be retried; do not mark it complete.
503 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
504 status_header(500);
505 exit();
506 }
507
508 $subscr_id = $seller_txn_id;
509
510 // A first payment/one-time transaction may already identify the member directly.
511 if(($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($seller_txn_id)))
512 {
513 if(($user_subscr_id = get_user_option('s2member_subscr_id', $user_id)))
514 $subscr_id = (string)$user_subscr_id;
515 }
516 else
517 {
518 // Later Subscription payments identify the sale, not the Subscription; recover its billing agreement when available.
519 $sale = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_api_request('GET', '/v1/payments/sale/'.rawurlencode($seller_txn_id));
520
521 if(!empty($sale['code']) && (int)$sale['code'] === 200 && !empty($sale['body']) && is_string($sale['body']))
522 {
523 $sale_details = json_decode($sale['body'], true);
524
525 if(is_array($sale_details) && !empty($sale_details['billing_agreement_id']))
526 $subscr_id = (string)$sale_details['billing_agreement_id'];
527 }
528 }
529
530 $paypal['txn_type'] = 'new_case';
531 $paypal['case_type'] = 'chargeback';
532 $paypal['txn_id'] = $event_id;
533 $paypal['parent_txn_id'] = $seller_txn_id;
534 $paypal['subscr_id'] = $subscr_id;
535
536 $paypal['mp_id'] = $subscr_id;
537 $paypal['recurring_payment_id'] = $subscr_id;
538
539 if(!empty($resource['dispute_amount']['value']))
540 $paypal['mc_gross'] = (string)$resource['dispute_amount']['value'];
541 else
542 $paypal['mc_gross'] = '0';
543
544 if(!empty($resource['dispute_amount']['currency_code']))
545 $paypal['mc_currency'] = (string)$resource['dispute_amount']['currency_code'];
546 else
547 $paypal['mc_currency'] = $GLOBALS['WS_PLUGIN__']['s2member']['o']['paypal_default_currency'];
548
549 if(!empty($resource['buyer']['email_address']))
550 $paypal['payer_email'] = (string)$resource['buyer']['email_address'];
551
552 // Recover the original signup context so the established chargeback handler can identify the membership.
553 if($subscr_id
554 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
555 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
556 )
557 {
558 foreach(array('item_number', 'item_name', 'period1', 'period3', 'payer_email') as $_signup_var)
559 if(empty($paypal[$_signup_var]) && !empty($ipn_signup_vars[$_signup_var]))
560 $paypal[$_signup_var] = (string)$ipn_signup_vars[$_signup_var];
561 }
562
563 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
564 'ppco' => 'webhook',
565 'env_setting' => $env_site,
566 'env_webhook' => $env_webhook,
567 'event' => 'dispute_created',
568 'event_id' => $event_id,
569 'event_type' => $event_type,
570 'dispute_id' => !empty($resource['dispute_id']) ? (string)$resource['dispute_id'] : (!empty($resource['id']) ? (string)$resource['id'] : ''),
571 'parent_txn_id'=> $seller_txn_id,
572 'subscr_id' => $subscr_id,
573 ));
574 }
575
576 // Recurring payment events (PayPal often emits PAYMENT.SALE.COMPLETED for subscription payments).
577 //260216 Add refund/reversal webhook support so refunds can trigger immediate EOT/demotion.
578 else if(in_array($event_type, array(
579 'PAYMENT.SALE.COMPLETED',
580 'PAYMENT.CAPTURE.PENDING',
581 'PAYMENT.CAPTURE.COMPLETED',
582 'PAYMENT.CAPTURE.DENIED',
583 'PAYMENT.SALE.REFUNDED',
584 'PAYMENT.CAPTURE.REFUNDED',
585 'PAYMENT.SALE.REVERSED',
586 'PAYMENT.CAPTURE.REVERSED',
587 ), true))
588 {
589 if(!empty($resource['billing_agreement_id']))
590 $subscr_id = (string)$resource['billing_agreement_id'];
591 else if(!empty($resource['parent_payment']))
592 $subscr_id = (string)$resource['parent_payment']; // fallback (not always present)
593 else if(!empty($resource['subscription_id']))
594 $subscr_id = (string)$resource['subscription_id'];
595 else if(!empty($resource['supplementary_data']['related_ids']['billing_agreement_id']))
596 $subscr_id = (string)$resource['supplementary_data']['related_ids']['billing_agreement_id'];
597
598 //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.
599 if(!$subscr_id && in_array($event_type, array('PAYMENT.CAPTURE.PENDING', 'PAYMENT.CAPTURE.COMPLETED', 'PAYMENT.CAPTURE.DENIED'), TRUE))
600 {
601 $order_id = !empty($resource['supplementary_data']['related_ids']['order_id']) ? (string)$resource['supplementary_data']['related_ids']['order_id'] : '';
602 if($order_id)
603 {
604 $order = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_details($order_id);
605 if(!empty($order['__error']))
606 {
607 //260902.0635 Do not consume a coordinator capture webhook when its authoritative order lookup temporarily fails; PayPal can redeliver it.
608 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
609 status_header(500);
610 exit();
611 }
612
613 $invoice = !empty($order['purchase_units'][0]['invoice_id']) ? (string)$order['purchase_units'][0]['invoice_id'] : '';
614 $gateway_checkout_id = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_gateway_checkout_id_from_invoice($invoice);
615 if($gateway_checkout_id)
616 {
617 $capture_id = !empty($resource['id']) ? (string)$resource['id'] : '';
618 $capture_status = ($event_type === 'PAYMENT.CAPTURE.COMPLETED') ? 'COMPLETED' : (($event_type === 'PAYMENT.CAPTURE.DENIED') ? 'DENIED' : 'PENDING');
619 $recovery = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_gateway_checkout_recover($invoice, $order_id, $capture_id, $capture_status, 'webhook');
620 if(!empty($recovery['handled']) && empty($recovery['ok']))
621 {
622 if(!empty($recovery['error']) && in_array((string)$recovery['error'], array('gateway_checkout_order_conflict', 'gateway_checkout_capture_conflict'), TRUE))
623 {
624 //260902.0646 A conflicting late webhook is diagnostic only; never let it replace or fulfill against the checkout's authoritative provider identity.
625 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));
626 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
627 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
628 status_header(200);
629 exit();
630 }
631 else
632 {
633 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
634 status_header(500);
635 exit();
636 }
637 }
638
639 //260907.1820 PENDING and DENIED events only reconcile state; COMPLETED is the sole capture event allowed to cross the entitlement boundary into shared fulfillment.
640 if($capture_status === 'COMPLETED')
641 {
642 //260907.1820 Off-session fulfillment must use the encrypted server-validated purchase token; never reconstruct trusted price/access terms from the webhook payload itself.
643 $private_context = c_ws_plugin__s2member_gateway_checkouts::private_context_get($gateway_checkout_id);
644 $token = is_array($private_context) && !empty($private_context['paypal_checkout']['token']) && is_array($private_context['paypal_checkout']['token']) ? $private_context['paypal_checkout']['token'] : array();
645 if(!$token || ($validation_error = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_completion_error($order, $order_id, $token)))
646 {
647 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
648 status_header(500);
649 exit();
650 }
651
652 $fulfillment = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_order_fulfill($order, $token);
653 if(empty($fulfillment['ok']))
654 {
655 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
656 status_header(500);
657 exit();
658 }
659 }
660
661 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));
662 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
663 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
664 status_header(200);
665 exit();
666 }
667 }
668 }
669
670 //260228 Ignore legacy/non-coordinator one-time sale/capture webhooks that have no subscription reference.
671 if(!$subscr_id)
672 {
673 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
674 'ppco' => 'webhook',
675 'env_setting'=> $env_site,
676 'env_webhook'=> $env_webhook,
677 'event' => 'ignored_non_subscription_payment',
678 'event_id' => $event_id,
679 'event_type' => $event_type,
680 'resource' => $resource,
681 ));
682
683 //260406 Mark the webhook event done and release its lock for valid terminal events.
684 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
685 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
686
687 status_header(200);
688 exit();
689 }
690
691 $paypal['txn_type'] = 'subscr_payment';
692
693 if(strpos($event_type, '.REFUNDED') !== false)
694 $paypal['payment_status'] = 'Refunded';
695 else if(strpos($event_type, '.REVERSED') !== false)
696 $paypal['payment_status'] = 'Reversed';
697 else
698 $paypal['payment_status'] = 'Completed';
699
700 if(!empty($resource['id']))
701 $txn_id = (string)$resource['id']; // original capture/sale id
702
703 if(!empty($resource['amount']['total']))
704 $paypal['mc_gross'] = (string)$resource['amount']['total'];
705 else if(!empty($resource['amount']['value']))
706 $paypal['mc_gross'] = (string)$resource['amount']['value'];
707
708 if(!empty($resource['amount']['currency']))
709 $paypal['mc_currency'] = (string)$resource['amount']['currency'];
710 else if(!empty($resource['amount']['currency_code']))
711 $paypal['mc_currency'] = (string)$resource['amount']['currency_code'];
712
713 if(!empty($resource['payer']['payer_info']['email']))
714 $paypal['payer_email'] = (string)$resource['payer']['payer_info']['email'];
715 else if(!empty($resource['payer']['email_address']))
716 $paypal['payer_email'] = (string)$resource['payer']['email_address'];
717
718 $paypal['subscr_id'] = $subscr_id;
719
720 //260216 Emulate IPN semantics for refund/reversal: parent_txn_id=original, txn_id=event delivery.
721 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status']))
722 {
723 $paypal['parent_txn_id'] = $txn_id ? $txn_id : $event_id;
724 $paypal['txn_id'] = $event_id;
725 }
726 else
727 $paypal['txn_id'] = $txn_id ? $txn_id : $event_id;
728
729 $paypal['mp_id'] = $subscr_id;
730 $paypal['recurring_payment_id'] = $subscr_id;
731
732 //260216 Enrich refund/reversal from stored signup vars so legacy handlers can demote immediately.
733 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status'])
734 && $subscr_id
735 && ($user_id = c_ws_plugin__s2member_utils_users::get_user_id_with($subscr_id))
736 && is_array($ipn_signup_vars = get_user_option('s2member_ipn_signup_vars', $user_id))
737 && !empty($ipn_signup_vars['subscr_id']) && (string)$ipn_signup_vars['subscr_id'] === (string)$subscr_id
738 )
739 {
740 if(empty($paypal['item_number']) && !empty($ipn_signup_vars['item_number']))
741 $paypal['item_number'] = (string)$ipn_signup_vars['item_number'];
742
743 if(empty($paypal['item_name']) && !empty($ipn_signup_vars['item_name']))
744 $paypal['item_name'] = (string)$ipn_signup_vars['item_name'];
745
746 if(empty($paypal['period1']) && !empty($ipn_signup_vars['period1']))
747 $paypal['period1'] = (string)$ipn_signup_vars['period1'];
748
749 if(empty($paypal['period3']) && !empty($ipn_signup_vars['period3']))
750 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
751
752 if(empty($paypal['payer_email']) && !empty($ipn_signup_vars['payer_email']))
753 $paypal['payer_email'] = (string)$ipn_signup_vars['payer_email'];
754 }
755 }
756 else
757 {
758 // Ignore for MVP.
759 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
760 'ppco' => 'webhook',
761 'env_setting'=> $env_site,
762 'env_webhook'=> $env_webhook,
763 'event' => 'ignored',
764 'event_id' => $event_id,
765 'event_type' => $event_type,
766 ));
767
768 //260406 Mark the webhook event done and release its lock for valid terminal events.
769 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
770 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
771
772 status_header(200);
773 exit();
774 }
775
776 //260406 Idempotency per txn prevents different webhook event IDs from double-processing the same payment.
777 if(!empty($paypal['txn_type']))
778 {
779 $txn_key = (string)$event_id;
780
781 //260216 For refund/reversal, prefer idempotency on original payment id.
782 if(!empty($paypal['parent_txn_id']))
783 $txn_key = (string)$paypal['parent_txn_id'];
784 else if(!empty($paypal['txn_id']))
785 $txn_key = (string)$paypal['txn_id'];
786
787 //260824.1727 Refunds, reversals, and disputes can share the original payment ID; keep each later state independently idempotent.
788 $txn_dedupe_key = $txn_key;
789 if(!empty($paypal['payment_status']) && preg_match('/^(refunded|reversed|reversal)$/i', $paypal['payment_status']))
790 $txn_dedupe_key = strtolower((string)$paypal['payment_status']).'|'.$txn_key;
791 else if(!empty($paypal['txn_type']) && $paypal['txn_type'] === 'new_case' && !empty($paypal['case_type']) && $paypal['case_type'] === 'chargeback')
792 $txn_dedupe_key = 'chargeback|'.$txn_key;
793
794 $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_dedupe_key);
795
796 if($txn_key)
797 {
798 $txn_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($txn_done_option, $txn_done_ttl);
799
800 if($txn_done_time > 0)
801 {
802 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
803 'ppco' => 'webhook',
804 'env_setting'=> $env_site,
805 'env_webhook'=> $env_webhook,
806 'event' => 'duplicate_txn',
807 'action' => 'ignored',
808 'note' => 'Duplicate webhook delivery (txn_id already processed).',
809 'event_id' => $event_id,
810 'event_type' => $event_type,
811 'subscr_id' => $subscr_id,
812 'txn_id' => !empty($paypal['txn_id']) ? (string)$paypal['txn_id'] : '',
813 'option' => $txn_done_option,
814 ));
815
816 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
817 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
818
819 status_header(200);
820 exit();
821 }
822 }
823 }
824
825 // Proxy into existing s2Member PayPal notify handler to reuse all provisioning/eot logic.
826 $url = add_query_arg('s2member_paypal_notify', '1', home_url('/'));
827 $notify_duplicate = false;
828
829 if($subscr_handled_by_webhook && !empty($subscr_done_option))
830 {
831 //260818.0603 Share the subscription Notify lock/done marker with browser confirmation so activation fallback cannot race it.
832 $notify_result = c_ws_plugin__s2member_paypal_utilities::paypal_checkout_notify_once($paypal, $subscr_done_option, 'paypal_checkout_webhook');
833 $notify_ok = !empty($notify_result['ok']);
834 $notify_duplicate = !empty($notify_result['duplicate']);
835 $code = !empty($notify_result['code']) ? (int)$notify_result['code'] : 0;
836 $message = !empty($notify_result['message']) ? (string)$notify_result['message'] : (!empty($notify_result['error']) ? (string)$notify_result['error'] : '');
837 }
838 else
839 {
840 $post = array_merge($paypal, array(
841 's2member_paypal_proxy' => 'paypal',
842 's2member_paypal_proxy_use' => 'paypal_checkout_webhook',
843 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
844 ));
845
846 $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
847 'timeout' => 20,
848 ), true);
849
850 if(!is_array($r))
851 $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
852
853 $code = !empty($r['code']) ? (int)$r['code'] : 0;
854 $message = !empty($r['message']) ? (string)$r['message'] : '';
855 $notify_ok = ($code >= 200 && $code <= 299);
856 }
857
858 if($notify_ok)
859 {
860 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
861 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
862
863 if(!empty($txn_done_option))
864 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($txn_done_option);
865
866 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
867 'ppco' => 'webhook',
868 'env_setting'=> $env_site,
869 'env_webhook'=> $env_webhook,
870 'event' => 'notify_proxy_response',
871 'event_id' => $event_id,
872 'event_type' => $event_type,
873 'subscr_id' => $subscr_id,
874 'txn_id' => $txn_id ? $txn_id : $event_id,
875 'url' => $url,
876 'code' => $code,
877 'message' => $message,
878 'duplicate' => $notify_duplicate,
879 ));
880 }
881 else
882 {
883 //260406 Release the in-flight webhook lock on failure so PayPal retries can proceed.
884 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
885
886 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
887 'ppco' => 'webhook',
888 'env_setting'=> $env_site,
889 'env_webhook'=> $env_webhook,
890 'event' => 'notify_proxy_failed',
891 'event_id' => $event_id,
892 'event_type' => $event_type,
893 'subscr_id' => $subscr_id,
894 'txn_id' => $txn_id ? $txn_id : $event_id,
895 'url' => $url,
896 'code' => $code,
897 'message' => $message,
898 ));
899
900 //260818.0603 Activation fallback must remain retryable when shared fulfillment fails or is still in progress.
901 if($subscr_handled_by_webhook)
902 {
903 status_header(500);
904 exit();
905 }
906 }
907
908 status_header(200);
909 exit();
910 }
911 }
912 }
913