← All changes
|
app/Modules/PaymentMethods/StripeGateway/Webhook/IPN.php
+77
-1
1.6.3
→
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)) { |
| @@ -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)) { |