Skip to content

Refund webhook handler listens for refund.updated but Stripe fires charge.refunded for card refunds #1067

@mrjbj

Description

@mrjbj

Bug Description

Refunds initiated via the "Refund Order" UI remain stuck at REFUND_PENDING indefinitely,
even though Stripe successfully processes the refund. The order never transitions to
REFUNDED.

Root Cause

IncomingWebhookHandler registers Event::REFUND_UPDATED (refund.updated) in its
$validEvents array and switch statement. However, for standard card refunds created via
$stripeClient->refunds->create(), Stripe fires charge.refunded — not refund.updated.

refund.updated is only fired for asynchronous refund status transitions (e.g., bank
transfer refunds going from pending → succeeded). For card refunds, which complete
synchronously, Stripe fires charge.refunded and never sends refund.updated.

As a result, the webhook event is never delivered to the endpoint (since it's not
subscribed), and even if charge.refunded were added to the Stripe webhook subscription,
the app would silently discard it at the $validEvents check.

Steps to Reproduce

  1. Complete a card payment for an order
  2. From the Orders page, click "Refund Order" and submit a refund
  3. Verify in Stripe Dashboard that the refund succeeds
  4. Observe the order remains at REFUND_PENDING in Hi.Events
  5. Check Stripe Dashboard → Developers → Events: charge.refunded was fired, but no
    refund.updated exists
  6. Check Stripe Dashboard → Webhooks → Event Deliveries: no refund.updated delivery

Expected Behavior

After Stripe processes the refund, the order should transition to REFUNDED (or
PARTIALLY_REFUNDED) and an order_refunds record should be created.

Suggested Fix

In
backend/app/Services/Application/Handlers/Order/Payment/Stripe/IncomingWebhookHandler.php:

  1. Add Event::CHARGE_REFUNDED to $validEvents
  2. Add a switch case that extracts the Refund object(s) from the Charge payload and passes
    them to the existing ChargeRefundUpdatedHandler

// In $validEvents array:
Event::CHARGE_REFUNDED,

// In switch statement:
case Event::CHARGE_REFUNDED:
$this->handleChargeRefunded($event->data->object);
break;

// New private method:
private function handleChargeRefunded(Charge $charge): void
{
$refunds = $charge->refunds->data ?? [];

  foreach ($refunds as $refund) {
      $this->refundEventHandlerService->handleEvent($refund);
  }

}

The existing idempotency check in ChargeRefundUpdatedHandler (lookup by refund_id)
prevents duplicate processing.

Users also need to add charge.refunded to their Stripe webhook endpoint's subscribed
events.

Environment

  • Stripe API version: 2025-07-30.basil (but this is not version-specific — charge.refunded
    has been the standard event for card refunds across all recent API versions)
  • Hi.Events: latest develop branch

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions