← 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()); |