PluginProbe
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions / 260927
s2Member – Excellent for All Kinds of Memberships, Content Restriction Paywalls & Member Access Subscriptions v260927
260927 260917 260913 260909 260829 260814 260805 110710 110731 110812 110815 110912 110913 110915 110926 110927 111002 111003 111011 111017 111029 111105 111206 111216 111220 All 190 releases
← All changes | src/includes/classes/paypal-webhook-in.inc.php +347 -24 260805 → 260927 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,63 @@
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 +
261 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.
262 393 $paypal['payment_status'] = 'Completed';
263 394
264 395 $subscr_handled_by_webhook = true;
@@ -326,14 +457,107 @@
326 457 $paypal['period3'] = (string)$ipn_signup_vars['period3'];
327 458 }
328 459 }
329 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 +
330 553 // Recurring payment events (PayPal often emits PAYMENT.SALE.COMPLETED for subscription payments).
331 554 //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 555 else if(in_array($event_type, array(
334 556 'PAYMENT.SALE.COMPLETED',
557 + 'PAYMENT.CAPTURE.PENDING',
335 558 'PAYMENT.CAPTURE.COMPLETED',
559 + 'PAYMENT.CAPTURE.DENIED',
336 560 'PAYMENT.SALE.REFUNDED',
337 561 'PAYMENT.CAPTURE.REFUNDED',
338 562 'PAYMENT.SALE.REVERSED',
339 563 'PAYMENT.CAPTURE.REVERSED',
@@ -347,9 +571,81 @@
347 571 $subscr_id = (string)$resource['subscription_id'];
348 572 else if(!empty($resource['supplementary_data']['related_ids']['billing_agreement_id']))
349 573 $subscr_id = (string)$resource['supplementary_data']['related_ids']['billing_agreement_id'];
350 574
351 - //260228 Ignore one-time sale/capture webhooks that have no subscription reference.
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.
352 648 if(!$subscr_id)
353 649 {
354 650 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
355 651 'ppco' => 'webhook',
@@ -464,10 +760,17 @@
464 760 $txn_key = (string)$paypal['parent_txn_id'];
465 761 else if(!empty($paypal['txn_id']))
466 762 $txn_key = (string)$paypal['txn_id'];
467 763
468 - $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_key);
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;
469 770
771 + $txn_done_option = 's2m_ppco_txn_done_'.md5($paypal['txn_type'].'|'.$subscr_id.'|'.$txn_dedupe_key);
772 +
470 773 if($txn_key)
471 774 {
472 775 $txn_done_time = c_ws_plugin__s2member_paypal_utilities::dedupe_done_time_get($txn_done_option, $txn_done_ttl);
473 776
@@ -496,25 +799,41 @@
496 799 }
497 800 }
498 801
499 802 // 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 - ));
803 + $url = add_query_arg('s2member_paypal_notify', '1', home_url('/'));
804 + $notify_duplicate = false;
506 805
507 - $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
508 - 'timeout' => 20,
509 - ), true);
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 + ));
510 822
511 - if(!is_array($r))
512 - $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
823 + $r = c_ws_plugin__s2member_utils_urls::remote($url, $post, array(
824 + 'timeout' => 20,
825 + ), true);
513 826
514 - $code = !empty($r['code']) ? (int)$r['code'] : 0;
827 + if(!is_array($r))
828 + $r = array('code' => 0, 'message' => 'request_failed', 'body' => '');
515 829
516 - if($code >= 200 && $code <= 299)
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)
517 836 {
518 837 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
519 838 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
520 839
@@ -520,12 +839,8 @@
520 839
521 840 if(!empty($txn_done_option))
522 841 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($txn_done_option);
523 842
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 843 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
529 844 'ppco' => 'webhook',
530 845 'env_setting'=> $env_site,
531 846 'env_webhook'=> $env_webhook,
@@ -535,9 +850,10 @@
535 850 'subscr_id' => $subscr_id,
536 851 'txn_id' => $txn_id ? $txn_id : $event_id,
537 852 'url' => $url,
538 853 'code' => $code,
539 - 'message' => !empty($r['message']) ? (string)$r['message'] : '',
854 + 'message' => $message,
855 + 'duplicate' => $notify_duplicate,
540 856 ));
541 857 }
542 858 else
543 859 {
@@ -554,10 +870,17 @@
554 870 'subscr_id' => $subscr_id,
555 871 'txn_id' => $txn_id ? $txn_id : $event_id,
556 872 'url' => $url,
557 873 'code' => $code,
558 - 'message' => !empty($r['message']) ? (string)$r['message'] : '',
874 + 'message' => $message,
559 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 + }
560 883 }
561 884
562 885 status_header(200);
563 886 exit();