PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 +103 -6 1.5.4 → 1.6.5 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)
@@ -50,9 +53,9 @@
50 53
51 54 if (empty($refunds)) {
52 55 $chargeId = Arr::get($charge, 'id', '');
53 56 if ($chargeId) {
54 - $refundsResponse = (new API())->getStripeObject('charges/' . $chargeId . '/refunds');
57 + $refundsResponse = (new API())->getStripeObject('charges/' . $chargeId . '/refunds', [], StripeHelper::modeFromLivemode(isset($event->livemode) ? (bool)$event->livemode : null));
55 58 if (!is_wp_error($refundsResponse)) {
56 59 $refunds = Arr::get($refundsResponse, 'data', []);
57 60 }
58 61 }
@@ -397,8 +400,80 @@
397 400
398 401 return $currentSubscription->reSyncFromRemote();
399 402 }
400 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 +
401 476 public function verifyAndProcess()
402 477 {
403 478 $data = (new API())->verifyIPN();
404 479 if (is_wp_error($data)) {
@@ -413,8 +488,10 @@
413 488 'charge.dispute.closed',
414 489 'checkout.session.completed',
415 490 'customer.subscription.deleted',
416 491 'customer.subscription.updated',
492 + 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
493 + 'invoice.payment_failed',
417 494 ];
418 495
419 496 $eventType = $data->type;
420 497 if (!in_array($eventType, $acceptedEvents)) {
@@ -421,20 +498,40 @@
421 498 $this->sendResponse(200, 'Event type not accepted.');
422 499 }
423 500
424 501 $eventId = $data->id;
425 - $event = (new API())->getEvent($eventId);
502 + $livemode = isset($data->livemode) ? (bool)$data->livemode : null;
503 + $event = (new API())->getEvent($eventId, $livemode);
426 504
427 505 if (!$event || is_wp_error($event)) {
428 - $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);
429 523 }
430 524
431 525 // get the order from the event, in case of renewal create one
432 - $order = (new Webhook())->processAndInsertOrderByEvent($event);
526 + $webhook = new Webhook();
527 + $order = $webhook->processAndInsertOrderByEvent($event);
433 528
434 529 if (!$order) {
435 - // This is already handled or not our event
436 - $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'));
437 534 }
438 535
439 536 if (is_wp_error($order)) {
440 537 $this->sendResponse(400, 'Order not found or error occurred. Error: '. $order->get_error_message());