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 +77 -1 1.6.2 → 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)
@@ -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)) {
@@ -414,8 +489,9 @@
414 489 'checkout.session.completed',
415 490 'customer.subscription.deleted',
416 491 'customer.subscription.updated',
417 492 'setup_intent.succeeded', // recovers zero-payable system-subscription vaulting if the AJAX confirm is lost
493 + 'invoice.payment_failed',
418 494 ];
419 495
420 496 $eventType = $data->type;
421 497 if (!in_array($eventType, $acceptedEvents)) {