PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
← All changes | app/Modules/PaymentMethods/StripeGateway/Webhook/IPN.php +112 -5 1.3.21 → 1.6.6 View file →
@@ -2,8 +2,9 @@
2 2
3 3 namespace FluentCart\App\Modules\PaymentMethods\StripeGateway\Webhook;
4 4
5 5 use FluentCart\App\Events\Order\OrderRefund;
6 +use FluentCart\App\Events\Subscription\SubscriptionRenewalFailed;
6 7 use FluentCart\App\Helpers\CurrenciesHelper;
7 8 use FluentCart\App\Events\Order\OrderStatusUpdated;
8 9 use FluentCart\App\Helpers\Status;
9 10 use FluentCart\App\Helpers\StatusHelper;
@@ -33,8 +34,10 @@
33 34
34 35 // For Subscriptions
35 36 add_action('fluent_cart/payments/stripe/webhook_customer_subscription_updated', [$this, 'handleSubscriptionUpdated'], 10, 1);
36 37 add_action('fluent_cart/payments/stripe/webhook_customer_subscription_deleted', [$this, 'handleSubscriptionUpdated'], 10, 1); // canceled event
38 +
39 + add_action('fluent_cart/payments/stripe/webhook_invoice_payment_failed', [$this, 'handleInvoicePaymentFailed'], 10, 1);
37 40 }
38 41
39 42
40 43 public function handleChargeRefunded($data)
@@ -47,8 +50,18 @@
47 50 $charge = Arr::get($eventArray, 'data.object', []);
48 51
49 52 $refunds = Arr::get($charge, 'refunds.data', []);
50 53
54 + if (empty($refunds)) {
55 + $chargeId = Arr::get($charge, 'id', '');
56 + if ($chargeId) {
57 + $refundsResponse = (new API())->getStripeObject('charges/' . $chargeId . '/refunds', [], StripeHelper::modeFromLivemode(isset($event->livemode) ? (bool)$event->livemode : null));
58 + if (!is_wp_error($refundsResponse)) {
59 + $refunds = Arr::get($refundsResponse, 'data', []);
60 + }
61 + }
62 + }
63 +
51 64 if (!$refunds) {
52 65 return false;
53 66 }
54 67
@@ -387,8 +400,80 @@
387 400
388 401 return $currentSubscription->reSyncFromRemote();
389 402 }
390 403
404 + public function handleInvoicePaymentFailed($data)
405 + {
406 + $event = Arr::get($data, 'event');
407 + $order = Arr::get($data, 'order');
408 + $invoice = $event->data->object;
409 +
410 + $invoice = (new API())->getStripeObject('invoices/' . $invoice->id, [], StripeHelper::modeFromLivemode(isset($event->livemode) ? (bool)$event->livemode : null));
411 +
412 + $vendorSubscriptionId = Arr::get($invoice, 'subscription', null)
413 + ?: Arr::get($invoice, 'parent.subscription_details.subscription', null);
414 +
415 + $subscription = null;
416 + if ($vendorSubscriptionId) {
417 + $subscription = Subscription::query()
418 + ->where('vendor_subscription_id', $vendorSubscriptionId)
419 + ->where('parent_order_id', $order->id)
420 + ->where('current_payment_method', 'stripe')
421 + ->first();
422 + }
423 +
424 + if (!$subscription) {
425 + return false;
426 + }
427 +
428 + $invoiceId = Arr::get($invoice, 'id');
429 +
430 + if (!$invoiceId || !preg_match('/^in_[a-zA-Z0-9_]+$/', $invoiceId)) {
431 + return false;
432 + }
433 +
434 + $claimKey = 'fct_sub_renewal_failed_' . $subscription->id . '_' . $invoiceId;
435 +
436 + // One notification per failed renewal cycle. invoice.payment_failed fires once
437 + // per Stripe retry attempt against the same invoice, and verifyAndProcess()
438 + // authenticates by re-fetching the event rather than by signature, so a
439 + // resubmitted event id re-runs the handler. The invoice id is stable across
440 + // retries within a cycle and distinct for the next one. The stored value must
441 + // stay constant — add_option()'s pre-check is not atomic, so the claim leans on
442 + // MySQL reporting zero affected rows for an unchanged ON DUPLICATE KEY UPDATE.
443 + if (!add_option($claimKey, '1', '', false)) {
444 + return true; // already notified for this renewal cycle
445 + }
446 +
447 + $paymentIntentId = Arr::get($invoice, 'payment_intent', null);
448 + if (is_array($paymentIntentId)) {
449 + $paymentIntentId = Arr::get($paymentIntentId, 'id', null);
450 + }
451 +
452 + $error = '';
453 + if ($paymentIntentId && preg_match('/^[a-zA-Z0-9_-]+$/', $paymentIntentId)) {
454 + $paymentIntent = (new API())->getStripeObject('payment_intents/' . $paymentIntentId, [], $order->mode);
455 + if (!is_wp_error($paymentIntent)) {
456 + $error = (string)Arr::get($paymentIntent, 'last_payment_error.message', '');
457 + }
458 + }
459 +
460 + if (!$error) {
461 + $error = __('Stripe reported a failed invoice payment attempt.', 'fluent-cart');
462 + }
463 +
464 + try {
465 + (new SubscriptionRenewalFailed($subscription, $order, $order->customer, $error))->dispatch();
466 + } catch (\Throwable $e) {
467 + // The claim is permanent, so a half-finished dispatch would suppress this
468 + // renewal forever. Release it so the redelivery reruns the dispatch.
469 + delete_option($claimKey);
470 + throw $e;
471 + }
472 +
473 + return true;
474 + }
475 +
391 476 public function verifyAndProcess()
392 477 {
393 478 $data = (new API())->verifyIPN();
394 479 if (is_wp_error($data)) {
@@ -403,8 +488,10 @@
403 488 'charge.dispute.closed',
404 489 'checkout.session.completed',
405 490 'customer.subscription.deleted',
406 491 'customer.subscription.updated',
492 + 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
493 + 'invoice.payment_failed',
407 494 ];
408 495
409 496 $eventType = $data->type;
410 497 if (!in_array($eventType, $acceptedEvents)) {
@@ -411,20 +498,40 @@
411 498 $this->sendResponse(200, 'Event type not accepted.');
412 499 }
413 500
414 501 $eventId = $data->id;
415 - $event = (new API())->getEvent($eventId);
502 + $livemode = isset($data->livemode) ? (bool)$data->livemode : null;
503 + $event = (new API())->getEvent($eventId, $livemode);
416 504
417 505 if (!$event || is_wp_error($event)) {
418 - $this->sendResponse(400, 'Event not found or error occurred.');
506 + $reason = is_wp_error($event)
507 + ? $event->get_error_code() . ': ' . $event->get_error_message()
508 + : 'Stripe returned an empty response.';
509 +
510 + // Warning, not error: fluent_cart_error_log() is a no-op unless
511 + // FLUENT_CART_DEV_MODE is on, and this is the only surviving record of
512 + // why a delivery failed.
513 + fluent_cart_warning_log(
514 + 'Stripe Webhook: could not fetch event ' . $eventId,
515 + $reason . ' (event mode: ' . (is_null($livemode) ? 'unknown' : ($livemode ? 'live' : 'test')) . ')',
516 + [
517 + 'module_name' => 'payment',
518 + 'log_type' => 'api',
519 + ]
520 + );
521 +
522 + $this->sendResponse(400, 'Event not found or error occurred. ' . $reason);
419 523 }
420 524
421 525 // get the order from the event, in case of renewal create one
422 - $order = (new Webhook())->processAndInsertOrderByEvent($event);
526 + $webhook = new Webhook();
527 + $order = $webhook->processAndInsertOrderByEvent($event);
423 528
424 529 if (!$order) {
425 - // This is already handled or not our event
426 - $this->sendResponse(200, 'Event not handled or not related to an order.');
530 + // Either we have no resolver for this event type, or the resolver ran and
531 + // nothing local matched. Both are a 200 — neither is retryable — but they
532 + // mean different things when reading the Stripe delivery log.
533 + $this->sendResponse(200, $webhook->getUnresolvedReason() ?: __('Event resolved to no order.', 'fluent-cart'));
427 534 }
428 535
429 536 if (is_wp_error($order)) {
430 537 $this->sendResponse(400, 'Order not found or error occurred. Error: '. $order->get_error_message());