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
← All changes | src/includes/classes/paypal-webhook-in.inc.php +370 -24 260805 → 261001 View file →
@@ -23,8 +23,23 @@
23 23 if(!class_exists('c_ws_plugin__s2member_paypal_webhook_in'))
24 24 {
25 25 class c_ws_plugin__s2member_paypal_webhook_in
26 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 +
27 42 public static function paypal_webhook()
28 43 {
29 44 if(empty($_REQUEST['s2member_paypal_webhook']))
30 45 return;
@@ -211,11 +226,70 @@
211 226
212 227 if($subscr_id)
213 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.
214 229
215 - //260401 Treat CREATED as informational only, and let ACTIVATED/RE-ACTIVATED act only as a fallback when checkout has not already handled this Subscription.
216 230 if($event_type === 'BILLING.SUBSCRIPTION.CREATED')
217 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 +
218 292 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
219 293 'ppco' => 'webhook',
220 294 'env_setting'=> $env_site,
221 295 'env_webhook'=> $env_webhook,
@@ -222,11 +296,13 @@
222 296 'event' => 'subscription_created',
223 297 'event_id' => $event_id,
224 298 'event_type' => $event_type,
225 299 'subscr_id' => $subscr_id,
300 + 'invoice' => $invoice,
301 + 'recovery' => $recovery,
226 302 ));
227 303
228 - //260406 Mark the webhook event done and release its lock for valid terminal events.
304 + //260902.0200 CREATED repairs coordinator identity only; it remains unpaid/unfulfilled until PayPal activates the subscription.
229 305 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
230 306 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
231 307
232 308 status_header(200);
@@ -257,8 +333,86 @@
257 333 status_header(200);
258 334 exit();
259 335 }
260 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 + }
261 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.
262 416 $paypal['payment_status'] = 'Completed';
263 417
264 418 $subscr_handled_by_webhook = true;
@@ -326,14 +480,107 @@
326 480 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
327 481 }
328 482 }
329 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 +
330 576 // Recurring payment events (PayPal often emits PAYMENT.SALE.COMPLETED for subscription payments).
331 577 //260216 Add refund/reversal webhook support so refunds can trigger immediate EOT/demotion.
332 - //260226 !!! TO-DO: Consider handling PayPal dispute/chargeback webhooks (e.g., CUSTOMER.DISPUTE.*), since not all chargebacks map to SALE/CAPTURE reversal events.
333 578 else if(in_array($event_type, array(
334 579 'PAYMENT.SALE.COMPLETED',
580 + 'PAYMENT.CAPTURE.PENDING',
335 581 'PAYMENT.CAPTURE.COMPLETED',
582 + 'PAYMENT.CAPTURE.DENIED',
336 583 'PAYMENT.SALE.REFUNDED',
337 584 'PAYMENT.CAPTURE.REFUNDED',
338 585 'PAYMENT.SALE.REVERSED',
339 586 'PAYMENT.CAPTURE.REVERSED',
@@ -347,9 +594,81 @@
347 594 $subscr_id = (string)$resource['subscription_id'];
348 595 else if(!empty($resource['supplementary_data']['related_ids']['billing_agreement_id']))
349 596 $subscr_id = (string)$resource['supplementary_data']['related_ids']['billing_agreement_id'];
350 597
351 - //260228 Ignore one-time sale/capture webhooks that have no subscription reference.
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.
352 671 if(!$subscr_id)
353 672 {
354 673 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
355 674 'ppco' => 'webhook',
@@ -464,10 +783,17 @@
464 783 $txn_key = (string)$paypal['parent_txn_id'];
465 784 else if(!empty($paypal['txn_id']))
466 785 $txn_key = (string)$paypal['txn_id'];
467 786
468 - $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_key);
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;
469 793
794 + $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_dedupe_key);
795 +
470 796 if($txn_key)
471 797 {
472 798 $txn_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($txn_done_option, $txn_done_ttl);
473 799
@@ -496,25 +822,41 @@
496 822 }
497 823 }
498 824
499 825 // Proxy into existing s2Member PayPal notify handler to reuse all provisioning/eot logic.
500 - $url = add_query_arg('s2member_paypal_notify', '1', home_url('/'));
501 - $post = array_merge($paypal, array(
502 - 's2member_paypal_proxy' => 'paypal',
503 - 's2member_paypal_proxy_use' => 'paypal_checkout_webhook',
504 - 's2member_paypal_proxy_verification' => c_ws_plugin__s2member_paypal_utilities::paypal_proxy_key_gen(),
505 - ));
826 + $url = add_query_arg('s2member_paypal_notify', '1', home_url('/'));
827 + $notify_duplicate = false;
506 828
507 - $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
508 - 'timeout' => 20,
509 - ), true);
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 + ));
510 845
511 - if(!is_array($r))
512 - $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
846 + $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
847 + 'timeout' => 20,
848 + ), true);
513 849
514 - $code = !empty($r['code']) ? (int)$r['code'] : 0;
850 + if(!is_array($r))
851 + $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
515 852
516 - if($code >= 200 && $code <= 299)
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)
517 859 {
518 860 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
519 861 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
520 862
@@ -520,12 +862,8 @@
520 862
521 863 if(!empty($txn_done_option))
522 864 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($txn_done_option);
523 865
524 - //260401 If webhook activation had to rescue this Subscription, mark it done so later activation webhooks are ignored.
525 - if($subscr_handled_by_webhook && !empty($subscr_done_option))
526 - c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($subscr_done_option);
527 -
528 866 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
529 867 'ppco' => 'webhook',
530 868 'env_setting'=> $env_site,
531 869 'env_webhook'=> $env_webhook,
@@ -535,9 +873,10 @@
535 873 'subscr_id' => $subscr_id,
536 874 'txn_id' => $txn_id ? $txn_id : $event_id,
537 875 'url' => $url,
538 876 'code' => $code,
539 - 'message' => !empty($r['message']) ? (string)$r['message'] : '',
877 + 'message' => $message,
878 + 'duplicate' => $notify_duplicate,
540 879 ));
541 880 }
542 881 else
543 882 {
@@ -554,10 +893,17 @@
554 893 'subscr_id' => $subscr_id,
555 894 'txn_id' => $txn_id ? $txn_id : $event_id,
556 895 'url' => $url,
557 896 'code' => $code,
558 - 'message' => !empty($r['message']) ? (string)$r['message'] : '',
897 + 'message' => $message,
559 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 + }
560 906 }
561 907
562 908 status_header(200);
563 909 exit();