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 +165 -3 260829 → 260927 View file →
@@ -226,11 +226,70 @@
226 226
227 227 if($subscr_id)
228 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 229
230 - //260401 Treat CREATED as informational only, and let ACTIVATED/RE-ACTIVATED act only as a fallback when checkout has not already handled this Subscription.
231 230 if($event_type === 'BILLING.SUBSCRIPTION.CREATED')
232 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 +
233 292 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
234 293 'ppco' => 'webhook',
235 294 'env_setting'=> $env_site,
236 295 'env_webhook'=> $env_webhook,
@@ -237,11 +296,13 @@
237 296 'event' => 'subscription_created',
238 297 'event_id' => $event_id,
239 298 'event_type' => $event_type,
240 299 'subscr_id' => $subscr_id,
300 + 'invoice' => $invoice,
301 + 'recovery' => $recovery,
241 302 ));
242 303
243 - //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.
244 305 c_ws_plugin__s2member_paypal_utilities::dedupe_done_mark($event_done_option);
245 306 c_ws_plugin__s2member_paypal_utilities::dedupe_lock_release($event_lock_option);
246 307
247 308 status_header(200);
@@ -300,8 +361,35 @@
300 361 status_header(500);
301 362 exit();
302 363 }
303 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 +
304 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.
305 393 $paypal['payment_status'] = 'Completed';
306 394
307 395 $subscr_handled_by_webhook = true;
@@ -465,9 +553,11 @@
465 553 // Recurring payment events (PayPal often emits PAYMENT.SALE.COMPLETED for subscription payments).
466 554 //260216 Add refund/reversal webhook support so refunds can trigger immediate EOT/demotion.
467 555 else if(in_array($event_type, array(
468 556 'PAYMENT.SALE.COMPLETED',
557 + 'PAYMENT.CAPTURE.PENDING',
469 558 'PAYMENT.CAPTURE.COMPLETED',
559 + 'PAYMENT.CAPTURE.DENIED',
470 560 'PAYMENT.SALE.REFUNDED',
471 561 'PAYMENT.CAPTURE.REFUNDED',
472 562 'PAYMENT.SALE.REVERSED',
473 563 'PAYMENT.CAPTURE.REVERSED',
@@ -481,9 +571,81 @@
481 571 $subscr_id = (string)$resource['subscription_id'];
482 572 else if(!empty($resource['supplementary_data']['related_ids']['billing_agreement_id']))
483 573 $subscr_id = (string)$resource['supplementary_data']['related_ids']['billing_agreement_id'];
484 574
485 - //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.
486 648 if(!$subscr_id)
487 649 {
488 650 c_ws_plugin__s2member_utils_logs::log_entry('paypal-checkout', array(
489 651 'ppco' => 'webhook',