Repository: Automattic/woocommerce-payments
Pull request #10600: Add pending refund notes
Author: login: htdat
Labels: name: pr: ready to merge
Created: 2025-03-20T10:49:56Z | Merged: 2025-03-27T02:55:06Z

Fixes #1195

#### Changes proposed in this Pull Request

Changes based on this spike https://github.com/Automattic/woocommerce-payments/issues/1195#issuecomment-2677422321: 

- Modify `add_note_and_metadata_for_refund`: change its name and add one param `$is_pending` to `add_note_and_metadata_for_created_refund` so that it can handle both `pending` and `succeeded` note. 
- No need to change on the timeline refund, i.e. [WC_REST_Payments_Refunds_Controller::process_refund](https://github.com/Automattic/woocommerce-payments/blob/fd6b5401620300ad04636a53016dc01a7dc43fb7/includes/admin/class-wc-rest-payments-refunds-controller.php#L46), because it's eventually tunneld to gateway `process_refund` with `wc_create_refund`.
- Update webhook handling so that it can handle both `pending` and `succeeded`. The most notable change is on `process_webhook_refund_updated` where I moved the handled the failed refund into `Order_Service`. 

<!--
Title: A descriptive, yet concise, title.
-->

<!--
Description: Write a brief summary about this PR. As you compose your summary, consider each of these questions and address them if appropriate. Why is this change needed? What does this change do? Were there other solutions you considered? Why did you choose to pursue this solution? Describe any trade-offs you might have had to make.
-->

<!--
Questions for PR author:
- How can this code break?
- What are we doing to make sure this code doesn't break?
-->

<!--
Images or gifs: Include before and after screenshots or gifs/videos when it makes sense.
-->

#### Testing instructions

* Use https://docs.stripe.com/testing?testing-method=card-numbers#refunds to test for the relevant flows. (If testing with a local dev server, make sure to enable webhook listening). 
* Trigger pending -> succeeded refund status in all flows: from Stripe dash, WP Admin order admin page, and Transaction Timeline. 
* Trigger succeeded -> failed refund status in the same flows.
* Expect: all respective pending, succeeded, failed notes are added into the order.

-------------------


- [x] Run `npm run changelog` to add a changelog file, choose `patch` to leave it empty if the change is not significant. You can add multiple changelog files in one PR by running this command a few times. 
- [x] Covered with tests (or have a good reason not to test in description ☝️)
- [x] Tested on mobile (or does not apply)

**Post merge**

<!--
Make sure you edit the page for the current release when adding testing instructions.
We often create a blank page ahead of time for the next release.
If this PR need not be QA tested, edit to 'QA Testing Not Applicable'
-->

> [!IMPORTANT]
> Add documents for this new link https://woocommerce.com/document/woopayments/managing-money/#pending-refunds 
> _Update: asked here p1743136317673289-slack-C03EE4CPPK5_

- [x] Link to testing instructions from [release testing doc](https://github.com/Automattic/woocommerce-payments/wiki/Release-testing-instructions) following [these instructions](https://github.com/Automattic/woocommerce-payments/wiki/How-to-write-good-manual-testing-scenarios) : _Add link here / 'QA Testing Not Applicable'_
- [x] Add or update [critical flows](https://github.com/Automattic/woocommerce-payments/wiki/Critical-flows) and [testing instructions for critical flows](https://github.com/Automattic/woocommerce-payments/wiki/Testing-instructions-for-critical-flows), if applicable.
- [ ] Add what's changed (description, screenshot, demo videos etc.) to the release announcement post, if applicable.

Linked issue #1195: Incorrect handling of pending refund
Once the order refund is issued with WC Pay it might hang in `pending` state several reasons, e.g. insufficient funds on the balance to process a refund. This case is not handled properly by the plugin.

Current behavior:
* order status changed to refunded.
* order notes confirm the refund was successful.
* funds not transferred to the shopper.

Expected behavior:
* order status changed to something else. Need to figure this out
* new note should be added to the order.

Reference ticket: 3629486-zd-woothemes
Linked issue comments: user: login: vbelolapotkov
created_at: 2021-01-12T06:23:28Z
body:  @LevinMedia your help is needed here:

1. What do you think should be the order status if refund is stuck in pending phase?
2. Do you have an idea of a good messaging for the refund pending order note.
3. Do you think any additional actions needed, e.g. triggering inbox notification or email to let merchant know. This might be needed because I assume refund transitions to the pending state asynchronously and can happen after merchant leaves the order page.
author_association: COLLABORATOR
user: login: vbelolapotkov
created_at: 2021-01-12T06:25:43Z
body: The issue looks close to https://github.com/Automattic/woocommerce-payments/issues/312 so solution might be very similar.
author_association: COLLABORATOR
user: login: AashikP
created_at: 2021-01-13T03:28:29Z
body: Another report in 3624575-zen
author_association: NONE
user: login: siraht
created_at: 2021-01-14T20:49:11Z
body: My thoughts on your inquiries @vbelolapotkov , since I have been trying to deal with this issue as an end user for the past two weeks


- What do you think should be the order status if refund is stuck in pending phase?

"Refund pending" or "Refund is pending" seems good enough, provided there is additional info given elsewhere

- Do you have an idea of a good messaging for the refund pending order note.

"Currently, your refund is pending completion. There are a few reasons why the refund may be pending. Our records show [dynamic response based on status of refund], but you can find more information here: [URL to refund info]"

- Do you think any additional actions needed, e.g. triggering inbox notification or email to let merchant know. This might be needed because I assume refund transitions to the pending state asynchronously and can happen after merchant leaves the order page.

As a merchant, I'd like the option to utilize all notification channels, if desired. An email and inbox notification seem like minimums for me.

If it wasn't my own purchase (testing functionality of my site) that I was waiting 2+ weeks for a refund on, I could have never noticed it not going through, and could have a pissed customer breathing down the back of my neck.
author_association: NONE
user: login: felipevelzani
created_at: 2022-05-15T14:36:40Z
body: 5173930-zd-woothemes
author_association: NONE
user: login: nicdwilson
created_at: 2022-05-26T21:47:32Z
body: I believe the following series of order notes from [5173930-zd](https://href.li/?5173930-zd-woothemes) clearly indicates the issue for some merchants
<img width="521" alt="Markup 2022-05-27 at 09 45 31" src="https://user-images.githubusercontent.com/3624025/170585452-8ca9a989-27af-4e73-b440-e92779cf3b5c.png">


author_association: NONE
user: login: jessy-p
created_at: 2023-05-09T11:22:27Z
body: I am moving this from Engineering Review queue to Product review, because there are only design questions on this. 
author_association: CONTRIBUTOR
user: login: elizaan36
created_at: 2023-05-17T02:36:42Z
body: hey all, When the refund is pending due to insufficient funds, does that mean WCPay keeps trying to refund the payment until it's successful? I imagine it would be easier or more straightforward to say the refund has failed due to insufficient funds (or other reason) and the order status doesn't change.

@aheckler @vbelolapotkov 
author_association: NONE
user: login: aheckler
created_at: 2023-05-20T14:42:17Z
body: I mentioned this in the other issue for this same problem, but I'm pretty sure what happens is Stripe gives the transaction the `Pending refund` status, and then when the account balance becomes positive again (and more than the refund amount) the refund will automatically be issued.
author_association: MEMBER
user: login: elizaan36
created_at: 2023-05-26T20:10:20Z
body: Mentioned [here](https://github.com/Automattic/woocommerce-payments/issues/5986#issuecomment-1564888523) ideally we could match the `Pending refund` status that Stripe provides with additional context in the order note if possible.
author_association: NONE
user: login: mgascam
created_at: 2025-02-03T09:45:53Z
body: Let's document how we are currently handling this scenario for the current sprint

Internal context: 483-gh-Automattic/transact-gateway#issuecomment-2569131179
author_association: CONTRIBUTOR
user: login: htdat
created_at: 2025-02-24T04:39:34Z
body: I am going to document how we are handling the `pending` refund status as well as my next step recommendation. 

## Current coverage matrix (tl;dr)

| Scenario | Admin Refund | Timeline Refund | Refund Updates Webhook | External Refund Webhook |
|----------|-------------|-----------------|----------------------|----------------------|
| Sync or async path | Sync | Sync | Async | Async |
| API Failure Response | ✅ Failure note | ✅ Failure note | N/A (1) | N/A (1) |
| - Insufficient Balance API Error | ❌ Not handled | ✅ Special note | N/A (1)| N/A (1)|
| Refund Status: Succeeded | ✅ Success note | ✅ Success note | ❌ Success note | ✅ Success note |
| Refund Status: Failed | N/A (2) | N/A (2) | ✅ Failure note | N/A (2) |
| Refund Status: Pending | ❌ Pending note | ❌ Pending note | N/A (3) | ❌ Pending note |

__**Notes regarding N/A entries in this table__
(1) Not relevant as the scenarios happen in the sync paths only, while webhook events are in the async paths.
(2) Refund status `failed` is not returned in the sync Stripe API request to create refunds. And therefore the external created refund webhook should not have this status too. 
(3) Refund status `pending` is not returned in the refund update, it can be either `failed` or `succeeded` only. See https://docs.stripe.com/testing?testing-method=card-numbers#refunds
## Relevant flows in detail

### 1. Admin Order Refund Flow
[WC_Payment_Gateway_WCPay::process_refund](https://github.com/Automattic/woocommerce-payments/blob/fd6b5401620300ad04636a53016dc01a7dc43fb7/includes/class-wc-payment-gateway-wcpay.php#L2181)
- Triggered from WP Admin when merchant clicks "Refund" button
- Handles API response success/failure
- On API success: Adds note about successful refund
  - No explicit handling between `pending` and `succeeded` refund statuses.
- On API failure: Adds note about failed refund attempt

### 2. Transaction Timeline Refund
[WC_REST_Payments_Refunds_Controller::process_refund](https://github.com/Automattic/woocommerce-payments/blob/fd6b5401620300ad04636a53016dc01a7dc43fb7/includes/admin/class-wc-rest-payments-refunds-controller.php#L46)
- Triggered from Transaction Timeline UI via REST API
- Handles API response success/failure
- On API success: Adds note about successful refund
  - No explicit handling between `pending` and `succeeded` refund statuses.
- On API failure: Adds note about failed refund attempt
  - Special handling for `insufficient_balance_for_refund` API error (recently implemented in https://github.com/Automattic/woocommerce-payments/pull/10313)

### 3. Webhook Refund Updates
[process_webhook_refund_updated](https://github.com/Automattic/woocommerce-payments/blob/fd6b5401620300ad04636a53016dc01a7dc43fb7/includes/class-wc-payments-webhook-processing-service.php#L247)
- Triggered by Stripe webhook events for refund status changes
- Currently only processes 'failed' refund status
- When refund status is failed:
  - Adds note about refund failure
  - Deletes corresponding WC refund record
  - Updates order status to `failed` if order was fully refunded
  - Special handling for insufficient_funds reason (recently implemented in https://github.com/Automattic/woocommerce-payments/pull/10313)

### 4. External Refund Webhook
[process_webhook_refund_triggered_externally](https://github.com/Automattic/woocommerce-payments/blob/fd6b5401620300ad04636a53016dc01a7dc43fb7/includes/class-wc-payments-webhook-processing-service.php#L825)
- Triggered when refund is created from Stripe Dashboard
- Only processes 'succeeded' refund status
- For successful refunds:
  - Adds note about external refund
  - Handles both partial and full refunds
  - Updates order items for full refunds
- Does not handle pending or failed refund status
- Skips processing if refund was already processed

## Next steps

Fix all items with icon ❌ on the matrix table above:

- Implement the missing `Insufficient Balance API Error` for the admin refund flow - issue created https://github.com/Automattic/woocommerce-payments/issues/10425
- Differentiate `pending` notes for the refund status `pending` in all flows.
author_association: MEMBER
user: login: RadoslavGeorgiev
created_at: 2025-03-04T09:41:18Z
body: Note: This issue remains open to track the implementation of the last point of the previous comment:

> Differentiate `pending` notes for the refund status `pending` in all flows.
author_association: CONTRIBUTOR
user: login: LevinMedia
created_at: 2025-03-27T13:48:18Z
body: Thanks @RadoslavGeorgiev and @vbelolapotkov 🙇😃
author_association: CONTRIBUTOR

Diff:
diff --git a/changelog/fix-1195-pending-refund b/changelog/fix-1195-pending-refund
new file mode 100644
index 00000000000..4fa85762aff
--- /dev/null
+++ b/changelog/fix-1195-pending-refund
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Handle pending refunds properly
diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php
index 761dcae591a..7ebeb41ef7f 100644
--- a/includes/class-wc-payment-gateway-wcpay.php
+++ b/includes/class-wc-payment-gateway-wcpay.php
@@ -20,6 +20,7 @@
 use WCPay\Constants\Intent_Status;
 use WCPay\Constants\Payment_Type;
 use WCPay\Constants\Payment_Method;
+use WCPay\Constants\Refund_Status;
 use WCPay\Exceptions\{Add_Payment_Method_Exception,
 	Amount_Too_Small_Exception,
 	API_Merchant_Exception,
@@ -2310,8 +2311,8 @@ static function ( $refund ) use ( $refund_amount ) {
 			// translators: %1$: order id.
 			return new WP_Error( 'wcpay_edit_order_refund_not_found', sprintf( __( 'A refund cannot be found for order: %1$s', 'woocommerce-payments' ), $order->get_id() ) );
 		}
-		// If the refund was successful, add a note to the order and update the refund status.
-		$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund['id'], $refund['balance_transaction'] ?? null );
+		// There is no error. Refund status can be either pending or succeeded, add a note to the order and update the refund status.
+		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund['id'], $refund['balance_transaction'] ?? null, Refund_Status::PENDING === $refund['status'] );
 
 		return true;
 	}
@@ -2323,7 +2324,7 @@ static function ( $refund ) use ( $refund_amount ) {
 	 * @return boolean
 	 */
 	public function has_refund_failed( $order ) {
-		return 'failed' === $this->order_service->get_wcpay_refund_status_for_order( $order );
+		return Refund_Status::FAILED === $this->order_service->get_wcpay_refund_status_for_order( $order );
 	}
 
 	/**
diff --git a/includes/class-wc-payments-order-service.php b/includes/class-wc-payments-order-service.php
index 1a2dd532c63..23b6a9cc2a7 100644
--- a/includes/class-wc-payments-order-service.php
+++ b/includes/class-wc-payments-order-service.php
@@ -9,6 +9,7 @@
 use WCPay\Constants\Order_Status;
 use WCPay\Constants\Intent_Status;
 use WCPay\Constants\Payment_Method;
+use WCPay\Constants\Refund_Status;
 use WCPay\Exceptions\Order_Not_Found_Exception;
 use WCPay\Fraud_Prevention\Models\Rule;
 use WCPay\Logger;
@@ -1446,18 +1447,19 @@ public function create_refund_for_order( WC_Order $order, float $amount, string
 	 * @param WC_Order_Refund $wc_refund The WC refund object.
 	 * @param string          $refund_id The refund ID.
 	 * @param string|null     $refund_balance_transaction_id The balance transaction ID of the refund.
+	 * @param bool            $is_pending Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
 	 * @throws Order_Not_Found_Exception
 	 * @throws Exception
 	 */
-	public function add_note_and_metadata_for_refund( WC_Order $order, WC_Order_Refund $wc_refund, string $refund_id, ?string $refund_balance_transaction_id ): void {
-		$note = $this->generate_payment_refunded_note( $wc_refund->get_amount(), $wc_refund->get_currency(), $refund_id, $wc_refund->get_reason(), $order );
+	public function add_note_and_metadata_for_created_refund( WC_Order $order, WC_Order_Refund $wc_refund, string $refund_id, ?string $refund_balance_transaction_id, bool $is_pending = false ): void {
+		$note = $this->generate_payment_created_refund_note( $wc_refund->get_amount(), $wc_refund->get_currency(), $refund_id, $wc_refund->get_reason(), $order, $is_pending );
 
 		if ( ! $this->order_note_exists( $order, $note ) ) {
 			$order->add_order_note( $note );
 		}
 
-		// Set refund metadata.
-		$this->set_wcpay_refund_status_for_order( $order, 'successful' );
+		// Use `successful` to maintain the backward compatibility with the previous WooPayments versions.
+		$this->set_wcpay_refund_status_for_order( $order, $is_pending ? Refund_Status::PENDING : 'successful' );
 		$this->set_wcpay_refund_id_for_refund( $wc_refund, $refund_id );
 		if ( isset( $refund_balance_transaction_id ) ) {
 			$this->set_wcpay_refund_transaction_id_for_order( $wc_refund, $refund_balance_transaction_id );
@@ -1466,6 +1468,55 @@ public function add_note_and_metadata_for_refund( WC_Order $order, WC_Order_Refu
 		$order->save();
 	}
 
+	/**
+	 * Handle a failed refund by adding a note, updating metadata, and optionally deleting the refund.
+	 *
+	 * @param WC_Order             $order The order to add the note to.
+	 * @param string               $refund_id The ID of the failed refund.
+	 * @param int                  $amount The refund amount in cents.
+	 * @param string               $currency The currency code.
+	 * @param WC_Order_Refund|null $wc_refund The WC refund object to delete if provided.
+	 * @param bool                 $is_cancelled Whether this is a cancellation rather than a failure. Default false.
+	 * @return void
+	 */
+	public function handle_failed_refund( WC_Order $order, string $refund_id, int $amount, string $currency, ?WC_Order_Refund $wc_refund = null, bool $is_cancelled = false ): void {
+		// Delete the refund if it exists.
+		if ( $wc_refund ) {
+			$wc_refund->delete();
+		}
+
+		$note = sprintf(
+			WC_Payments_Utils::esc_interpolated_html(
+				/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
+				__( 'A refund of %1$s was <strong>%4$s</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
+				[
+					'strong' => '<strong>',
+					'code'   => '<code>',
+				]
+			),
+			WC_Payments_Explicit_Price_Formatter::get_explicit_price(
+				wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
+				$order
+			),
+			'WooPayments',
+			$refund_id,
+			$is_cancelled ? __( 'cancelled', 'woocommerce-payments' ) : __( 'unsuccessful', 'woocommerce-payments' )
+		);
+
+		if ( $this->order_note_exists( $order, $note ) ) {
+			return;
+		}
+
+		// If order has been fully refunded.
+		if ( Order_Status::REFUNDED === $order->get_status() ) {
+			$order->update_status( Order_Status::FAILED );
+		}
+
+		$order->add_order_note( $note );
+		$this->set_wcpay_refund_status_for_order( $order, Refund_Status::FAILED );
+		$order->save();
+	}
+
 	/**
 	 * Get content for the success order note.
 	 *
@@ -1872,35 +1923,45 @@ private function generate_dispute_closed_note( $charge_id, $status, $is_inquiry
 	/**
 	 * Generates the HTML note for a refunded payment.
 	 *
-	 * @param float    $refunded_amount Amount refunded.
-	 * @param string   $refunded_currency Refund currency.
-	 * @param string   $wcpay_refund_id WCPay Refund ID.
-	 * @param string   $refund_reason Refund reason.
-	 * @param WC_Order $order Order object.
+	 * @param float    $refunded_amount  Amount refunded.
+	 * @param string   $refunded_currency  Refund currency.
+	 * @param string   $wcpay_refund_id  WCPay Refund ID.
+	 * @param string   $refund_reason  Refund reason.
+	 * @param WC_Order $order  Order object.
+	 * @param bool     $is_pending  Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
+	 *
 	 * @return string HTML note.
 	 */
-	private function generate_payment_refunded_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order ): string {
+	private function generate_payment_created_refund_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order, bool $is_pending ): string {
 		$multi_currency_instance = WC_Payments_Multi_Currency();
 		$formatted_price         = WC_Payments_Explicit_Price_Formatter::get_explicit_price( $multi_currency_instance->get_backend_formatted_wc_price( $refunded_amount, [ 'currency' => strtoupper( $refunded_currency ) ] ), $order );
 
+		$status_text = $is_pending ?
+			sprintf(
+				'<a href="https://woocommerce.com/document/woopayments/managing-money/#pending-refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
+				__( 'is pending', 'woocommerce-payments' )
+			)
+			: __( 'was successfully processed', 'woocommerce-payments' );
+
 		if ( empty( $refund_reason ) ) {
 			$note = sprintf(
 				WC_Payments_Utils::esc_interpolated_html(
-				/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
-					__( 'A refund of %1$s was successfully processed using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
+				/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund, %4: status text */
+					__( 'A refund of %1$s %4$s using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
 					[
 						'code' => '<code>',
 					]
 				),
 				$formatted_price,
 				'WooPayments',
-				$wcpay_refund_id
+				$wcpay_refund_id,
+				$status_text
 			);
 		} else {
 			$note = sprintf(
 				WC_Payments_Utils::esc_interpolated_html(
-				/* translators: %1: the successfully charged amount, %2: WooPayments, %3: reason, %4: refund id */
-					__( 'A refund of %1$s was successfully processed using %2$s. Reason: %3$s. (<code>%4$s</code>)', 'woocommerce-payments' ),
+				/* translators: %1: the refund amount, %2: WooPayments, %3: reason, %4: refund id, %5: status text */
+					__( 'A refund of %1$s %5$s using %2$s. Reason: %3$s. (<code>%4$s</code>)', 'woocommerce-payments' ),
 					[
 						'code' => '<code>',
 					]
@@ -1908,7 +1969,8 @@ private function generate_payment_refunded_note( float $refunded_amount, string
 				$formatted_price,
 				'WooPayments',
 				$refund_reason,
-				$wcpay_refund_id
+				$wcpay_refund_id,
+				$status_text
 			);
 		}
 
diff --git a/includes/class-wc-payments-webhook-processing-service.php b/includes/class-wc-payments-webhook-processing-service.php
index 402a6593be2..3ce4258b21e 100644
--- a/includes/class-wc-payments-webhook-processing-service.php
+++ b/includes/class-wc-payments-webhook-processing-service.php
@@ -14,6 +14,7 @@
 use WCPay\Exceptions\Order_Not_Found_Exception;
 use WCPay\Exceptions\Rest_Request_Exception;
 use WCPay\Logger;
+use WCPay\Constants\Refund_Status;
 
 if ( ! defined( 'ABSPATH' ) ) {
 	exit; // Exit if accessed directly.
@@ -248,17 +249,15 @@ private function process_webhook_refund_updated( $event_body ) {
 		$event_data   = $this->read_webhook_property( $event_body, 'data' );
 		$event_object = $this->read_webhook_property( $event_data, 'object' );
 
-		// First, check the reason for the update. We're only interested in a status of failed.
-		$status = $this->read_webhook_property( $event_object, 'status' );
-		if ( 'failed' !== $status ) {
-			return;
-		}
-
 		// Fetch the details of the failed refund so that we can find the associated order and write a note.
-		$charge_id = $this->read_webhook_property( $event_object, 'charge' );
-		$refund_id = $this->read_webhook_property( $event_object, 'id' );
-		$amount    = $this->read_webhook_property( $event_object, 'amount' );
-		$currency  = $this->read_webhook_property( $event_object, 'currency' );
+		$charge_id           = $this->read_webhook_property( $event_object, 'charge' );
+		$refund_id           = $this->read_webhook_property( $event_object, 'id' );
+		$amount              = $this->read_webhook_property( $event_object, 'amount' );
+		$currency            = $this->read_webhook_property( $event_object, 'currency' );
+		$status              = $this->read_webhook_property( $event_object, 'status' );
+		$balance_transaction = $this->has_webhook_property( $event_object, 'balance_transaction' )
+			? $this->read_webhook_property( $event_object, 'balance_transaction' )
+			: null;
 
 		// Look up the order related to this charge.
 		$order = $this->wcpay_db->order_from_charge_id( $charge_id );
@@ -273,29 +272,9 @@ private function process_webhook_refund_updated( $event_body ) {
 			);
 		}
 
-		$note = sprintf(
-			WC_Payments_Utils::esc_interpolated_html(
-			/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
-				__( 'A refund of %1$s was <strong>unsuccessful</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
-				[
-					'strong' => '<strong>',
-					'code'   => '<code>',
-				]
-			),
-			WC_Payments_Explicit_Price_Formatter::get_explicit_price(
-				wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
-				$order
-			),
-			'WooPayments',
-			$refund_id
-		);
-
-		if ( $this->order_service->order_note_exists( $order, $note ) ) {
-			return;
-		}
-
+		$matched_wc_refund = null;
 		/**
-		 * Get refunds from order and delete refund if matches wcpay refund id.
+		 * Get the WC_Refund from the WCPay refund ID.
 		 *
 		 * @var $wc_refunds WC_Order_Refund[]
 		 * */
@@ -304,34 +283,33 @@ private function process_webhook_refund_updated( $event_body ) {
 			foreach ( $wc_refunds as $wc_refund ) {
 				$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
 				if ( $refund_id === $wcpay_refund_id ) {
-					// Delete WC Refund.
-					$wc_refund->delete();
+					$matched_wc_refund = $wc_refund;
 					break;
 				}
 			}
 		}
 
-		// Update order status if order is fully refunded.
-		$current_order_status = $order->get_status();
-		if ( Order_Status::REFUNDED === $current_order_status ) {
-			$order->update_status( Order_Status::FAILED );
-		}
-
-		$order->add_order_note( $note );
-		$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
-		$order->save();
-
-		try {
-			$failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' );
-
-			if ( 'insufficient_funds' === $failure_reason ) {
-				$this->order_service->handle_insufficient_balance_for_refund(
-					$order,
-					$amount
-				);
-			}
-		} catch ( Exception $e ) {
-			Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() );
+		// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
+		switch ( $status ) {
+			case Refund_Status::FAILED:
+				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund );
+				if (
+					$this->has_webhook_property( $event_object, 'failure_reason' )
+					&& 'insufficient_funds' === $this->read_webhook_property( $event_object, 'failure_reason' )
+				) {
+					$this->order_service->handle_insufficient_balance_for_refund( $order, $amount );
+				}
+				break;
+			case Refund_Status::CANCELED:
+				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund, true );
+				break;
+			case Refund_Status::SUCCEEDED:
+				if ( $matched_wc_refund ) {
+					$this->order_service->add_note_and_metadata_for_created_refund( $order, $matched_wc_refund, $refund_id, $balance_transaction ?? null );
+				}
+				break;
+			default:
+				throw new Invalid_Webhook_Data_Exception( 'Invalid refund update status: ' . $status );
 		}
 	}
 
@@ -883,7 +861,7 @@ private function process_webhook_refund_triggered_externally( array $event_body
 
 		$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, ( ! $is_partial_refund ? $order->get_items() : [] ) );
 		// Process the refund in the order service.
-		$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
+		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id, Refund_Status::PENDING === $refund['status'] );
 	}
 
 	/**
diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php
index 41759213ddf..3e9ea9c6fba 100644
--- a/includes/class-wc-payments.php
+++ b/includes/class-wc-payments.php
@@ -481,6 +481,7 @@ public static function init() {
 		include_once __DIR__ . '/constants/class-payment-type.php';
 		include_once __DIR__ . '/constants/class-payment-initiated-by.php';
 		include_once __DIR__ . '/constants/class-intent-status.php';
+		include_once __DIR__ . '/constants/class-refund-status.php';
 		include_once __DIR__ . '/constants/class-payment-intent-status.php';
 		include_once __DIR__ . '/constants/class-payment-capture-type.php';
 		include_once __DIR__ . '/constants/class-payment-method.php';
diff --git a/includes/constants/class-refund-status.php b/includes/constants/class-refund-status.php
new file mode 100644
index 00000000000..a0a16d8fa3c
--- /dev/null
+++ b/includes/constants/class-refund-status.php
@@ -0,0 +1,24 @@
+<?php
+/**
+ * Class Refund_Status
+ *
+ * @package WooCommerce\Payments
+ */
+
+namespace WCPay\Constants;
+
+if ( ! defined( 'ABSPATH' ) ) {
+	exit; // Exit if accessed directly.
+}
+
+/**
+ * This class gives a list of all the possible WCPay refund status constants.
+ *
+ * @psalm-immutable
+ */
+class Refund_Status extends Base_Constant {
+	const PENDING   = 'pending';
+	const SUCCEEDED = 'succeeded';
+	const FAILED    = 'failed';
+	const CANCELED  = 'canceled';
+}
diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay-process-refund.php b/tests/unit/test-class-wc-payment-gateway-wcpay-process-refund.php
index f3a0aefa442..6db39741281 100644
--- a/tests/unit/test-class-wc-payment-gateway-wcpay-process-refund.php
+++ b/tests/unit/test-class-wc-payment-gateway-wcpay-process-refund.php
@@ -188,6 +188,7 @@ public function test_process_refund_should_work_without_payment_method_id_meta()
 				'id'       => 're_123456789',
 				'amount'   => $amount,
 				'currency' => 'usd',
+				'status'   => 'succeeded',
 			]
 		);
 		$request  = $this->mock_wcpay_request( Refund_Charge::class );
diff --git a/tests/unit/test-class-wc-payments-order-service.php b/tests/unit/test-class-wc-payments-order-service.php
index 1ab8593f879..3e507472fdf 100644
--- a/tests/unit/test-class-wc-payments-order-service.php
+++ b/tests/unit/test-class-wc-payments-order-service.php
@@ -10,6 +10,7 @@
 use WCPay\Constants\Intent_Status;
 use WCPay\Constants\Payment_Method;
 use WCPay\Fraud_Prevention\Models\Rule;
+use WCPay\Constants\Refund_Status;
 
 /**
  * WC_Payments_Order_Service unit tests.
@@ -1394,7 +1395,7 @@ public function test_attach_transaction_fee_to_order_null_fee() {
 		$this->order_service->attach_transaction_fee_to_order( $mock_order, new WC_Payments_API_Charge( 'ch_mock', 1500, new DateTime(), null, null, null, null, null, [], [], 'eur' ) );
 	}
 
-	public function test_add_note_and_metadata_for_refund_fully_refunded(): void {
+	public function test_add_note_and_metadata_for_created_refund_successful_fully_refunded(): void {
 		$order = WC_Helper_Order::create_order();
 		$order->save();
 
@@ -1405,12 +1406,13 @@ public function test_add_note_and_metadata_for_refund_fully_refunded(): void {
 
 		$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
 
-		$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
+		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
 
 		$order_note = wc_get_order_notes( [ 'order_id' => $order->get_id() ] )[0]->content;
 		$this->assertStringContainsString( $refunded_amount, $order_note, 'Order note does not contain expected refund amount' );
 		$this->assertStringContainsString( $refund_id, $order_note, 'Order note does not contain expected refund id' );
 		$this->assertStringContainsString( $refund_reason, $order_note, 'Order note does not contain expected refund reason' );
+		$this->assertStringContainsString( 'was successfully processed', $order_note, 'Order note should indicate successful processing' );
 
 		$this->assertSame( 'successful', $order->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_STATUS_META_KEY, true ) );
 		$this->assertSame( $refund_id, $wc_refund->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_ID_META_KEY, true ) );
@@ -1419,7 +1421,7 @@ public function test_add_note_and_metadata_for_refund_fully_refunded(): void {
 		WC_Helper_Order::delete_order( $order->get_id() );
 	}
 
-	public function test_add_note_and_metadata_for_refund_partially_refunded(): void {
+	public function test_add_note_and_metadata_for_created_refund_successful_partially_refunded(): void {
 		$order = WC_Helper_Order::create_order();
 		$order->save();
 
@@ -1429,7 +1431,7 @@ public function test_add_note_and_metadata_for_refund_partially_refunded(): void
 		$refund_balance_transaction_id = 'txn_1J2a3B4c5D6e7F8g9H0';
 		$wc_refund                     = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
 
-		$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
+		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
 
 		$this->assertSame( Order_Status::PENDING, $order->get_status() );
 
@@ -1437,6 +1439,7 @@ public function test_add_note_and_metadata_for_refund_partially_refunded(): void
 		$this->assertStringContainsString( $refunded_amount, $order_note, 'Order note does not contain expected refund amount' );
 		$this->assertStringContainsString( $refund_id, $order_note, 'Order note does not contain expected refund id' );
 		$this->assertStringContainsString( $refund_reason, $order_note, 'Order note does not contain expected refund reason' );
+		$this->assertStringContainsString( 'was successfully processed', $order_note, 'Order note should indicate successful processing' );
 
 		$this->assertSame( 'successful', $order->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_STATUS_META_KEY, true ) );
 		$this->assertSame( $refund_id, $wc_refund->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_ID_META_KEY, true ) );
@@ -1445,6 +1448,57 @@ public function test_add_note_and_metadata_for_refund_partially_refunded(): void
 		WC_Helper_Order::delete_order( $order->get_id() );
 	}
 
+	public function test_add_note_and_metadata_for_created_refund_pending(): void {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$refunded_amount               = 50;
+		$refund_id                     = 're_1J2a3B4c5D6e7F8g9H0';
+		$refund_reason                 = 'Test refund';
+		$refund_balance_transaction_id = 'txn_1J2a3B4c5D6e7F8g9H0';
+
+		$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
+
+		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id, true );
+
+		$order_note = wc_get_order_notes( [ 'order_id' => $order->get_id() ] )[0]->content;
+		$this->assertStringContainsString( $refunded_amount, $order_note, 'Order note does not contain expected refund amount' );
+		$this->assertStringContainsString( $refund_id, $order_note, 'Order note does not contain expected refund id' );
+		$this->assertStringContainsString( $refund_reason, $order_note, 'Order note does not contain expected refund reason' );
+		$this->assertStringContainsString( 'is pending', $order_note, 'Order note should indicate pending status' );
+		$this->assertStringContainsString( 'https://woocommerce.com/document/woopayments/managing-money/#pending-refunds', $order_note, 'Order note should contain link to pending refunds documentation' );
+
+		$this->assertSame( Refund_Status::PENDING, $order->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_STATUS_META_KEY, true ) );
+		$this->assertSame( $refund_id, $wc_refund->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_ID_META_KEY, true ) );
+		$this->assertSame( $refund_balance_transaction_id, $order->get_refunds()[0]->get_meta( WC_Payments_Order_Service::WCPAY_REFUND_TRANSACTION_ID_META_KEY, true ) );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
+
+	public function test_add_note_and_metadata_for_created_refund_no_duplicate_notes(): void {
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$refunded_amount               = 50;
+		$refund_id                     = 're_1J2a3B4c5D6e7F8g9H0';
+		$refund_reason                 = 'Test refund';
+		$refund_balance_transaction_id = 'txn_1J2a3B4c5D6e7F8g9H0';
+
+		$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, $order->get_items() );
+
+		// Add note first time.
+		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
+		$initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+		// Add note second time.
+		$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
+		$final_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+		$this->assertSame( $initial_notes_count, $final_notes_count, 'Duplicate notes should not be added' );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
+
 	public function test_process_captured_payment() {
 		$order = WC_Helper_Order::create_order();
 		$order->save();
@@ -1473,4 +1527,165 @@ public function test_process_captured_payment() {
 		$notes_2 = wc_get_order_notes( [ 'order_id' => $order->get_id() ] );
 		$this->assertEquals( count( $notes ), count( $notes_2 ) );
 	}
+
+	/**
+	 * Tests handling of failed refunds.
+	 *
+	 * @dataProvider provider_handle_failed_refund
+	 */
+	public function test_handle_failed_refund( string $initial_order_status, bool $has_refund, bool $expect_status_change ): void {
+		// Arrange: Create order and optionally add a refund.
+		$order     = WC_Helper_Order::create_order();
+		$wc_refund = null;
+		if ( $has_refund ) {
+			$wc_refund = $this->order_service->create_refund_for_order( $order, $order->get_total(), 'Test refund reason', $order->get_items() );
+		}
+		$order->set_status( $initial_order_status );
+		$order->save();
+
+		$refund_id = 're_123456789';
+		$amount    = 1000; // $10.00
+		$currency  = 'usd';
+
+		// Act: Handle the failed refund.
+		$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $wc_refund );
+
+		// Assert: Check order status was updated if needed.
+		if ( $expect_status_change ) {
+			$this->assertTrue( $order->has_status( Order_Status::FAILED ) );
+		} else {
+			$this->assertTrue( $order->has_status( $initial_order_status ) );
+		}
+
+		// Assert: Check refund status was set to failed.
+		$this->assertSame( Refund_Status::FAILED, $this->order_service->get_wcpay_refund_status_for_order( $order ) );
+
+		// Assert: Check order note was added.
+		$notes = wc_get_order_notes( [ 'order_id' => $order->get_id() ] );
+		$this->assertStringContainsString( 'unsuccessful', $notes[0]->content );
+		$this->assertStringContainsString( $refund_id, $notes[0]->content );
+
+		// Assert: If refund existed, it was deleted.
+		if ( $has_refund ) {
+			$this->assertEmpty( $order->get_refunds() );
+		}
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
+
+	public function provider_handle_failed_refund(): array {
+		return [
+			'Order not refunded - no status change'       => [
+				'initial_order_status' => Order_Status::PROCESSING,
+				'has_refund'           => false,
+				'expect_status_change' => false,
+			],
+			'Order fully refunded - status changes to failed' => [
+				'initial_order_status' => Order_Status::REFUNDED,
+				'has_refund'           => true,
+				'expect_status_change' => true,
+			],
+			'Order partially refunded - no status change' => [
+				'initial_order_status' => Order_Status::PROCESSING,
+				'has_refund'           => true,
+				'expect_status_change' => false,
+			],
+		];
+	}
+
+	/**
+	 * Tests that handle_failed_refund doesn't add duplicate notes.
+	 */
+	public function test_handle_failed_refund_no_duplicate_notes(): void {
+		// Arrange: Create order and handle failed refund twice.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$refund_id = 're_123456789';
+		$amount    = 1000;
+		$currency  = 'usd';
+
+		$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency );
+		$initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+		$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency );
+		$final_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+		// Assert: No duplicate notes were added.
+		$this->assertSame( $initial_notes_count, $final_notes_count );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
+
+	/**
+	 * Tests that handle_failed_refund adds the correct note for cancelled refunds.
+	 */
+	public function test_handle_failed_refund_cancelled(): void {
+		// Arrange: Create order and handle cancelled refund.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$refund_id = 're_123456789';
+		$amount    = 1000;
+		$currency  = 'usd';
+
+		// Act: Handle the cancelled refund.
+		$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+
+		// Assert: Check order note was added with cancelled status.
+		$notes = wc_get_order_notes( [ 'order_id' => $order->get_id() ] );
+		$this->assertStringContainsString( 'cancelled', $notes[0]->content );
+		$this->assertStringContainsString( $refund_id, $notes[0]->content );
+
+		// Assert: Check refund status was set to failed.
+		$this->assertSame( Refund_Status::FAILED, $this->order_service->get_wcpay_refund_status_for_order( $order ) );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
+
+	/**
+	 * Tests that handle_failed_refund doesn't add duplicate notes for cancelled refunds.
+	 */
+	public function test_handle_failed_refund_cancelled_no_duplicate_notes(): void {
+		// Arrange: Create order and handle cancelled refund twice.
+		$order = WC_Helper_Order::create_order();
+		$order->save();
+
+		$refund_id = 're_123456789';
+		$amount    = 1000;
+		$currency  = 'usd';
+
+		$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+		$initial_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+		$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+		$final_notes_count = count( wc_get_order_notes( [ 'order_id' => $order->get_id() ] ) );
+
+		// Assert: No duplicate notes were added.
+		$this->assertSame( $initial_notes_count, $final_notes_count );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
+
+	/**
+	 * Tests that handle_failed_refund updates order status to failed when fully refunded.
+	 */
+	public function test_handle_failed_refund_cancelled_updates_order_status(): void {
+		// Arrange: Create order and set it to refunded status.
+		$order = WC_Helper_Order::create_order();
+		$order->set_status( Order_Status::REFUNDED );
+		$order->save();
+
+		$refund_id = 're_123456789';
+		$amount    = 1000;
+		$currency  = 'usd';
+
+		// Act: Handle the cancelled refund.
+		$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, null, true );
+
+		// Assert: Order status was updated to failed.
+		$this->assertTrue( $order->has_status( Order_Status::FAILED ) );
+
+		WC_Helper_Order::delete_order( $order->get_id() );
+	}
 }
diff --git a/tests/unit/test-class-wc-payments-webhook-processing-service.php b/tests/unit/test-class-wc-payments-webhook-processing-service.php
index c9b21cde846..399e7d59173 100644
--- a/tests/unit/test-class-wc-payments-webhook-processing-service.php
+++ b/tests/unit/test-class-wc-payments-webhook-processing-service.php
@@ -9,6 +9,7 @@
 use WCPay\Constants\Order_Status;
 use WCPay\Constants\Intent_Status;
 use WCPay\Constants\Payment_Method;
+use WCPay\Constants\Refund_Status;
 use WCPay\Database_Cache;
 use WCPay\Exceptions\Invalid_Payment_Method_Exception;
 use WCPay\Exceptions\Invalid_Webhook_Data_Exception;
@@ -41,7 +42,7 @@ class WC_Payments_Webhook_Processing_Service_Test extends WCPAY_UnitTestCase {
 	private $mock_remote_note_service;
 
 	/**
-	 * @var WC_Payments_Order_Service
+	 * @var WC_Payments_Order_Service|MockObject
 	 */
 	private $order_service;
 
@@ -98,7 +99,16 @@ public function set_up() {
 
 		$this->order_service = $this->getMockBuilder( 'WC_Payments_Order_Service' )
 			->setConstructorArgs( [ $this->createMock( WC_Payments_API_Client::class ) ] )
-			->setMethods( [ 'get_wcpay_refund_id_for_order', 'add_note_and_metadata_for_refund', 'create_refund_for_order', 'mark_terminal_payment_failed', 'handle_insufficient_balance_for_refund' ] )
+			->setMethods(
+				[
+					'get_wcpay_refund_id_for_order',
+					'add_note_and_metadata_for_created_refund',
+					'create_refund_for_order',
+					'mark_terminal_payment_failed',
+					'handle_insufficient_balance_for_refund',
+					'handle_failed_refund',
+				]
+			)
 			->getMock();
 
 		$this->mock_db_wrapper = $this->getMockBuilder( WC_Payments_DB::class )
@@ -142,7 +152,6 @@ public function set_up() {
 			->expects( $this->any() )
 			->method( 'get_id' )
 			->willReturn( 1234 );
-
 		WC_Payments::mode()->live();
 	}
 
@@ -259,9 +268,9 @@ public function test_webhook_with_no_data_property() {
 	}
 
 	/**
-	 * Test a valid refund sets failed meta.
+	 * Test a failed refund without matched WC Refunds.
 	 */
-	public function test_valid_failed_refund_webhook_sets_failed_meta() {
+	public function test_failed_refund_update_webhook_without_matched_wc_refund() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
@@ -273,36 +282,25 @@ public function test_valid_failed_refund_webhook_sets_failed_meta() {
 			'currency' => 'gbp',
 		];
 
-		$this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
-
-		$this->mock_order
-			->expects( $this->once() )
-			->method( 'add_order_note' )
-			->with(
-				'A refund of <span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&pound;</span>9.99</bdi></span> was <strong>unsuccessful</strong> using WooPayments (<code>test_refund_id</code>).'
-			);
-
-		// The expects condition here is the real test; we expect that the 'update_meta_data' function
-		// is called with the appropriate values.
-		$this->mock_order
-			->expects( $this->once() )
-			->method( 'update_meta_data' )
-			->with( '_wcpay_refund_status', 'failed' );
-
 		$this->mock_db_wrapper
 			->expects( $this->once() )
 			->method( 'order_from_charge_id' )
 			->with( 'test_charge_id' )
 			->willReturn( $this->mock_order );
 
+		$this->order_service
+			->expects( $this->once() )
+			->method( 'handle_failed_refund' )
+			->with( $this->mock_order, 'test_refund_id', 999, 'gbp', null );
+
 		// Run the test.
 		$this->webhook_processing_service->process( $this->event_body );
 	}
 
 	/**
-	 * Test a vaild refund failure deletes WooCommerce Refund.
+	 * Test a failed refund with matched WC Refunds.
 	 */
-	public function test_valid_failed_refund_webhook_deletes_wc_refund() {
+	public function test_failed_refund_update_webhook_with_matched_wc_refund() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
@@ -340,130 +338,159 @@ public function test_valid_failed_refund_webhook_deletes_wc_refund() {
 			->with( 'test_charge_id' )
 			->willReturn( $this->mock_order );
 
-		$mock_refund_1
-			->expects( $this->never() )
-			->method( 'delete' );
-
-		$mock_refund_2
+		$this->order_service
 			->expects( $this->once() )
-			->method( 'delete' );
+			->method( 'handle_failed_refund' )
+			->with( $this->mock_order, 'test_refund_id', 999, 'usd', $mock_refund_2 );
 
 		// Run the test.
 		$this->webhook_processing_service->process( $this->event_body );
 	}
 
 	/**
-	 * Test a valid refund does not set failed meta.
+	 * Test a refund update with status `cancelled`
 	 */
-	public function test_non_failed_refund_update_webhook_does_not_set_failed_meta() {
+	public function test_cancelled_refund_update_webhook_with_matched_wc_refund() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
 		$this->event_body['data']['object'] = [
-			'status' => 'success',
+			'status'   => Refund_Status::CANCELED,
+			'charge'   => 'test_charge_id',
+			'id'       => 'test_refund_id',
+			'amount'   => 999,
+			'currency' => 'usd',
 		];
 
+		$mock_refund_1 = $this->createMock( WC_Order_Refund::class );
+		$mock_refund_2 = $this->createMock( WC_Order_Refund::class );
+		$this->order_service
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_wcpay_refund_id_for_order' )
+			->withConsecutive(
+				[ $mock_refund_1 ],
+				[ $mock_refund_2 ]
+			)->willReturnOnConsecutiveCalls(
+				'another_test_refund_id',
+				'test_refund_id'
+			);
+
+		$this->mock_order->method( 'get_refunds' )->willReturn(
+			[
+				$mock_refund_1,
+				$mock_refund_2,
+			]
+		);
+
 		$this->mock_db_wrapper
-			->expects( $this->never() )
-			->method( 'order_from_charge_id' );
+			->expects( $this->once() )
+			->method( 'order_from_charge_id' )
+			->with( 'test_charge_id' )
+			->willReturn( $this->mock_order );
 
-		// The expects condition here is the real test; we expect that the 'update_meta_data' function
-		// is never called to update the meta data.
-		$this->mock_order
-			->expects( $this->never() )
-			->method( 'update_meta_data' );
+		$this->order_service
+			->expects( $this->once() )
+			->method( 'handle_failed_refund' )
+			->with( $this->mock_order, 'test_refund_id', 999, 'usd', $mock_refund_2, true );
 
 		// Run the test.
 		$this->webhook_processing_service->process( $this->event_body );
 	}
 
 	/**
-	 * Test a valid failed refund update webhook.
+	 * Test a failed refund update webhook with insufficient funds.
 	 */
-	public function test_valid_failed_refund_update_webhook() {
+	public function test_failed_refund_update_webhook_with_insufficient_funds() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
 		$this->event_body['data']['object'] = [
-			'status'   => 'failed',
-			'charge'   => 'test_charge_id',
-			'id'       => 'test_refund_id',
-			'amount'   => 999,
-			'currency' => 'gbp',
+			'status'         => 'failed',
+			'charge'         => 'charge_id',
+			'id'             => 'test_refund_id',
+			'amount'         => 999,
+			'currency'       => 'gbp',
+			'failure_reason' => 'insufficient_funds',
 		];
 
-		$this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
-
-		$this->mock_order
-			->expects( $this->once() )
-			->method( 'add_order_note' )
-			->with(
-				'A refund of <span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&pound;</span>9.99</bdi></span> was <strong>unsuccessful</strong> using WooPayments (<code>test_refund_id</code>).'
-			);
-
 		$this->mock_db_wrapper
 			->expects( $this->once() )
 			->method( 'order_from_charge_id' )
-			->with( 'test_charge_id' )
+			->with( 'charge_id' )
 			->willReturn( $this->mock_order );
 
-		// Run the test.
+		$this->order_service
+			->expects( $this->once() )
+			->method( 'handle_insufficient_balance_for_refund' )
+			->with( $this->mock_order, 999 );
+
 		$this->webhook_processing_service->process( $this->event_body );
 	}
 
 	/**
-	 * Test a valid failed refund update webhook for non-USD.
+	 * Test a refund does not set failed meta.
 	 */
-	public function test_valid_failed_refund_update_webhook_non_usd() {
+	public function test_succeeded_refund_update_webhook_without_matched_wc_refund() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
 		$this->event_body['data']['object'] = [
-			'status'   => 'failed',
+			'status'   => Refund_Status::SUCCEEDED,
 			'charge'   => 'test_charge_id',
 			'id'       => 'test_refund_id',
 			'amount'   => 999,
-			'currency' => 'eur',
+			'currency' => 'usd',
 		];
 
-		$this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
-
-		$this->mock_order
-			->expects( $this->once() )
-			->method( 'add_order_note' )
-			->with( 'A refund of <span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&euro;</span>9.99</bdi></span> was <strong>unsuccessful</strong> using WooPayments (<code>test_refund_id</code>).' );
-
 		$this->mock_db_wrapper
 			->expects( $this->once() )
 			->method( 'order_from_charge_id' )
 			->with( 'test_charge_id' )
 			->willReturn( $this->mock_order );
 
+		$this->order_service
+			->expects( $this->never() )
+			->method( 'add_note_and_metadata_for_created_refund' );
+
 		// Run the test.
 		$this->webhook_processing_service->process( $this->event_body );
 	}
 
 	/**
-	 * Test a valid failed refund update webhook for zero decimal currency.
+	 * Test a valid failed refund update webhook.
 	 */
-	public function test_valid_failed_refund_update_webhook_zero_decimal_currency() {
+	public function test_succeeded_refund_update_webhook_with_matched_wc_refund() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
 		$this->event_body['data']['object'] = [
-			'status'   => 'failed',
-			'charge'   => 'test_charge_id',
-			'id'       => 'test_refund_id',
-			'amount'   => 999,
-			'currency' => 'jpy',
+			'status'              => 'succeeded',
+			'charge'              => 'test_charge_id',
+			'id'                  => 'test_refund_id',
+			'amount'              => 999,
+			'currency'            => 'usd',
+			'balance_transaction' => 'txn_balance_transaction',
 		];
 
-		$this->mock_order->method( 'get_currency' )->willReturn( 'GBP' );
+		$mock_refund_1 = $this->createMock( WC_Order_Refund::class );
+		$mock_refund_2 = $this->createMock( WC_Order_Refund::class );
+		$this->order_service
+			->expects( $this->exactly( 2 ) )
+			->method( 'get_wcpay_refund_id_for_order' )
+			->withConsecutive(
+				[ $mock_refund_1 ],
+				[ $mock_refund_2 ]
+			)->willReturnOnConsecutiveCalls(
+				'another_test_refund_id',
+				'test_refund_id'
+			);
 
-		$this->mock_order
-			->expects( $this->once() )
-			->method( 'add_order_note' )
-			->with( 'A refund of <span class="woocommerce-Price-amount amount"><bdi><span class="woocommerce-Price-currencySymbol">&yen;</span>999.00</bdi></span> was <strong>unsuccessful</strong> using WooPayments (<code>test_refund_id</code>).' );
+		$this->mock_order->method( 'get_refunds' )->willReturn(
+			[
+				$mock_refund_1,
+				$mock_refund_2,
+			]
+		);
 
 		$this->mock_db_wrapper
 			->expects( $this->once() )
@@ -471,14 +498,19 @@ public function test_valid_failed_refund_update_webhook_zero_decimal_currency()
 			->with( 'test_charge_id' )
 			->willReturn( $this->mock_order );
 
+		$this->order_service
+			->expects( $this->once() )
+			->method( 'add_note_and_metadata_for_created_refund' )
+			->with( $this->mock_order, $mock_refund_2, 'test_refund_id', 'txn_balance_transaction' );
+
 		// Run the test.
 		$this->webhook_processing_service->process( $this->event_body );
 	}
 
 	/**
-	 * Test a valid failed refund update webhook with an unknown charge ID.
+	 * Test a failed refund update webhook with an unknown charge ID.
 	 */
-	public function test_valid_failed_refund_update_webhook_with_unknown_charge_id() {
+	public function test_failed_refund_update_webhook_with_unknown_charge_id() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
@@ -504,53 +536,28 @@ public function test_valid_failed_refund_update_webhook_with_unknown_charge_id()
 	}
 
 	/**
-	 * Test a valid failed refund update webhook with insufficient funds.
+	 * Test an invalid status refund update webhook
 	 */
-	public function test_valid_failed_refund_update_webhook_with_insufficient_funds() {
+	public function test_invalid_status_refund_update_webhook_throws_exceptions() {
 		// Setup test request data.
 		$this->event_body['type']           = 'charge.refund.updated';
 		$this->event_body['livemode']       = true;
 		$this->event_body['data']['object'] = [
-			'status'         => 'failed',
-			'charge'         => 'charge_id',
-			'id'             => 'test_refund_id',
-			'amount'         => 999,
-			'currency'       => 'gbp',
-			'failure_reason' => 'insufficient_funds',
+			'status'   => 'invalid_status',
+			'charge'   => 'test_charge_id',
+			'id'       => 'test_refund_id',
+			'amount'   => 999,
+			'currency' => 'gbp',
 		];
 
 		$this->mock_db_wrapper
 			->expects( $this->once() )
 			->method( 'order_from_charge_id' )
-			->with( 'charge_id' )
+			->with( 'test_charge_id' )
 			->willReturn( $this->mock_order );
 
-		$this->order_service
-			->expects( $this->once() )
-			->method( 'handle_insufficient_balance_for_refund' )
-			->with( $this->mock_order, 999 );
-
-		$this->webhook_processing_service->process( $this->event_body );
-	}
-
-
-	/**
-	 * Test a valid non-failed refund update webhook
-	 */
-	public function test_non_failed_refund_update_webhook() {
-		// Setup test request data.
-		$this->event_body['type']           = 'charge.refund.updated';
-		$this->event_body['livemode']       = true;
-		$this->event_body['data']['object'] = [
-			'status' => 'updated',
-			'charge' => 'test_charge_id',
-			'id'     => 'test_refund_id',
-			'amount' => 999,
-		];
-
-		$this->mock_db_wrapper
-			->expects( $this->never() )
-			->method( 'order_from_charge_id' );
+		$this->expectException( Invalid_Webhook_Data_Exception::class );
+		$this->expectExceptionMessage( 'Invalid refund update status: invalid_status' );
 
 		// Run the test.
 		$this->webhook_processing_service->process( $this->event_body );
@@ -1579,6 +1586,7 @@ public function test_process_full_refund_succeeded(): void {
 				'data' => [
 					[
 						'id'                  => 'test_refund_id',
+						'status'              => Refund_Status::SUCCEEDED,
 						'amount'              => 1800,
 						'currency'            => 'usd',
 						'reason'              => 'requested_by_customer',
@@ -1620,7 +1628,7 @@ public function test_process_full_refund_succeeded(): void {
 
 		$this->order_service
 			->expects( $this->once() )
-			->method( 'add_note_and_metadata_for_refund' )
+			->method( 'add_note_and_metadata_for_created_refund' )
 			->with( $this->mock_order, $mock_refund, 'test_refund_id', 'txn_123' );
 
 		$this->webhook_processing_service->process( $this->event_body );
@@ -1635,6 +1643,7 @@ public function test_process_partial_refund_succeeded(): void {
 				'data' => [
 					[
 						'id'                  => 'test_refund_id',
+						'status'              => Refund_Status::SUCCEEDED,
 						'amount'              => 900,
 						'currency'            => 'usd',
 						'reason'              => 'requested_by_customer',
@@ -1667,7 +1676,7 @@ public function test_process_partial_refund_succeeded(): void {
 
 		$this->order_service
 			->expects( $this->once() )
-			->method( 'add_note_and_metadata_for_refund' )
+			->method( 'add_note_and_metadata_for_created_refund' )
 			->with( $this->mock_order, $mock_refund, 'test_refund_id', 'txn_123' );
 
 		$this->webhook_processing_service->process( $this->event_body );
@@ -1728,7 +1737,7 @@ public function test_process_refund_ignores_processed_event(): void {
 
 		$this->order_service
 			->expects( $this->never() )
-			->method( 'add_note_and_metadata_for_refund' );
+			->method( 'add_note_and_metadata_for_created_refund' );
 
 		$this->webhook_processing_service->process( $this->event_body );
 	}
@@ -1742,7 +1751,7 @@ public function test_process_refund_ignores_event(): void {
 
 		$this->order_service
 			->expects( $this->never() )
-			->method( 'add_note_and_metadata_for_refund' );
+			->method( 'add_note_and_metadata_for_created_refund' );
 
 		$this->webhook_processing_service->process( $this->event_body );
 	}
@@ -1773,7 +1782,7 @@ public function test_process_refund_ignores_failed_refund_event(): void {
 
 		$this->order_service
 			->expects( $this->never() )
-			->method( 'add_note_and_metadata_for_refund' );
+			->method( 'add_note_and_metadata_for_created_refund' );
 	}
 
 	public function test_process_refund_throws_when_order_not_found(): void {


Reviews:
user: login: mgascam
body: I think we are missing to include the new constant file for the refund status:

```
diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php
index 41759213d..321b1f1b4 100644
--- a/includes/class-wc-payments.php
+++ b/includes/class-wc-payments.php
@@ -473,6 +473,7 @@ class WC_Payments {
                include_once __DIR__ . '/exceptions/class-invalid-address-exception.php';
                include_once __DIR__ . '/constants/class-base-constant.php';
                include_once __DIR__ . '/constants/class-country-code.php';
+               include_once __DIR__ . '/constants/class-refund-status.php';
                include_once __DIR__ . '/constants/class-country-test-cards.php';
                include_once __DIR__ . '/constants/class-currency-code.php';
                include_once __DIR__ . '/constants/class-fraud-meta-box-type.php';
``` 
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T12:34:27Z
user: login: mgascam
body: Thanks for addressing my comments @htdat. The code changes look good to me. I appreciate the streamlined process as well of the addition of the refund status constant. I left a comment regarding the public docs url to double-check with you. I also saw an opportunity to enhance the refund failure reason in the order notes, but we can tackle it in a follow up issue.

Manual tests worked as intended for me. I tested both the success and failure flows from the Stripe dash, the Order admin and the timeline and did not find any issues. Great work! 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-26T10:39:48Z
user: login: htdat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T02:45:32Z
user: login: htdat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-27T02:46:57Z
user: login: mgascam
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T15:59:38Z
user: login: htdat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T04:04:55Z
user: login: htdat
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-28T04:34:11Z

Review comments:
diff_hunk: @@ -304,34 +283,33 @@ private function process_webhook_refund_updated( $event_body ) {
 			foreach ( $wc_refunds as $wc_refund ) {
 				$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
 				if ( $refund_id === $wcpay_refund_id ) {
-					// Delete WC Refund.
-					$wc_refund->delete();
+					$matched_wc_refund = $wc_refund;
 					break;
 				}
 			}
 		}
 
-		// Update order status if order is fully refunded.
-		$current_order_status = $order->get_status();
-		if ( Order_Status::REFUNDED === $current_order_status ) {
-			$order->update_status( Order_Status::FAILED );
-		}
-
-		$order->add_order_note( $note );
-		$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
-		$order->save();
-
-		try {
-			$failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' );
-
-			if ( 'insufficient_funds' === $failure_reason ) {
-				$this->order_service->handle_insufficient_balance_for_refund(
-					$order,
-					$amount
-				);
-			}
-		} catch ( Exception $e ) {
-			Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() );
+		// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
+		switch ( $status ) {
+			case Refund_Status::FAILED:
+				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund );
+				if (
path: includes/class-wc-payments-webhook-processing-service.php
user: login: mgascam
body: I think there's an opportunity to handle more `failure_reason` codes as listed in the Stripe [docs](https://docs.stripe.com/api/refunds/object#refund_object-failure_reason) (i.e. `expired_or_canceled_card`). IMO it will be useful for the merchant. But I think we can tackle it on a follow up issue, since we will probably need to refactor how we are handling the failed refund failure codes to use a holistic approach. What do you think? 
Note: the failure reason `expired_or_canceled_card` is already visible in the timeline view
created_at: 2025-03-26T10:16:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1872,43 +1923,54 @@ private function generate_dispute_closed_note( $charge_id, $status, $is_inquiry
 	/**
 	 * Generates the HTML note for a refunded payment.
 	 *
-	 * @param float    $refunded_amount Amount refunded.
-	 * @param string   $refunded_currency Refund currency.
-	 * @param string   $wcpay_refund_id WCPay Refund ID.
-	 * @param string   $refund_reason Refund reason.
-	 * @param WC_Order $order Order object.
+	 * @param float    $refunded_amount  Amount refunded.
+	 * @param string   $refunded_currency  Refund currency.
+	 * @param string   $wcpay_refund_id  WCPay Refund ID.
+	 * @param string   $refund_reason  Refund reason.
+	 * @param WC_Order $order  Order object.
+	 * @param bool     $is_pending  Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
+	 *
 	 * @return string HTML note.
 	 */
-	private function generate_payment_refunded_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order ): string {
+	private function generate_payment_created_refund_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order, bool $is_pending ): string {
 		$multi_currency_instance = WC_Payments_Multi_Currency();
 		$formatted_price         = WC_Payments_Explicit_Price_Formatter::get_explicit_price( $multi_currency_instance->get_backend_formatted_wc_price( $refunded_amount, [ 'currency' => strtoupper( $refunded_currency ) ] ), $order );
 
+		$status_text = $is_pending ?
+			sprintf(
+				'<a href="https://woocommerce.com/document/woopayments/managing-money/#pending-refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
path: includes/class-wc-payments-order-service.php
user: login: mgascam
body: ```suggestion
				'<a href="https://woocommerce.com/document/woopayments/managing-money/#refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
```
I could not find a "pending refunds" section in the current docs so I was wondering if maybe we plan to add this section to the docs. If not, then maybe we could direct them to the current `#refunds` section of the public docs.
created_at: 2025-03-26T10:31:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,34 +283,33 @@ private function process_webhook_refund_updated( $event_body ) {
 			foreach ( $wc_refunds as $wc_refund ) {
 				$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
 				if ( $refund_id === $wcpay_refund_id ) {
-					// Delete WC Refund.
-					$wc_refund->delete();
+					$matched_wc_refund = $wc_refund;
 					break;
 				}
 			}
 		}
 
-		// Update order status if order is fully refunded.
-		$current_order_status = $order->get_status();
-		if ( Order_Status::REFUNDED === $current_order_status ) {
-			$order->update_status( Order_Status::FAILED );
-		}
-
-		$order->add_order_note( $note );
-		$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
-		$order->save();
-
-		try {
-			$failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' );
-
-			if ( 'insufficient_funds' === $failure_reason ) {
-				$this->order_service->handle_insufficient_balance_for_refund(
-					$order,
-					$amount
-				);
-			}
-		} catch ( Exception $e ) {
-			Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() );
+		// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
+		switch ( $status ) {
+			case Refund_Status::FAILED:
+				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund );
+				if (
path: includes/class-wc-payments-webhook-processing-service.php
user: login: htdat
body: This is a good point! I agreed that this deserves a seperate issue. 
created_at: 2025-03-27T02:45:32Z
author_association: MEMBER
diff_hunk: @@ -1872,43 +1923,54 @@ private function generate_dispute_closed_note( $charge_id, $status, $is_inquiry
 	/**
 	 * Generates the HTML note for a refunded payment.
 	 *
-	 * @param float    $refunded_amount Amount refunded.
-	 * @param string   $refunded_currency Refund currency.
-	 * @param string   $wcpay_refund_id WCPay Refund ID.
-	 * @param string   $refund_reason Refund reason.
-	 * @param WC_Order $order Order object.
+	 * @param float    $refunded_amount  Amount refunded.
+	 * @param string   $refunded_currency  Refund currency.
+	 * @param string   $wcpay_refund_id  WCPay Refund ID.
+	 * @param string   $refund_reason  Refund reason.
+	 * @param WC_Order $order  Order object.
+	 * @param bool     $is_pending  Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
+	 *
 	 * @return string HTML note.
 	 */
-	private function generate_payment_refunded_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order ): string {
+	private function generate_payment_created_refund_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order, bool $is_pending ): string {
 		$multi_currency_instance = WC_Payments_Multi_Currency();
 		$formatted_price         = WC_Payments_Explicit_Price_Formatter::get_explicit_price( $multi_currency_instance->get_backend_formatted_wc_price( $refunded_amount, [ 'currency' => strtoupper( $refunded_currency ) ] ), $order );
 
+		$status_text = $is_pending ?
+			sprintf(
+				'<a href="https://woocommerce.com/document/woopayments/managing-money/#pending-refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
path: includes/class-wc-payments-order-service.php
user: login: htdat
body: Yes, it's a part of the post-merge section of this PR. I will contact with PayOps to do this.

> Add documents for this new link https://woocommerce.com/document/woopayments/managing-money/#pending-refunds  
created_at: 2025-03-27T02:46:57Z
author_association: MEMBER
diff_hunk: @@ -304,34 +283,33 @@ private function process_webhook_refund_updated( $event_body ) {
 			foreach ( $wc_refunds as $wc_refund ) {
 				$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
 				if ( $refund_id === $wcpay_refund_id ) {
-					// Delete WC Refund.
-					$wc_refund->delete();
+					$matched_wc_refund = $wc_refund;
 					break;
 				}
 			}
 		}
 
-		// Update order status if order is fully refunded.
-		$current_order_status = $order->get_status();
-		if ( Order_Status::REFUNDED === $current_order_status ) {
-			$order->update_status( Order_Status::FAILED );
-		}
-
-		$order->add_order_note( $note );
-		$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
-		$order->save();
-
-		try {
-			$failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' );
-
-			if ( 'insufficient_funds' === $failure_reason ) {
-				$this->order_service->handle_insufficient_balance_for_refund(
-					$order,
-					$amount
-				);
-			}
-		} catch ( Exception $e ) {
-			Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() );
+		// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
+		switch ( $status ) {
+			case Refund_Status::FAILED:
+				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund );
+				if (
path: includes/class-wc-payments-webhook-processing-service.php
user: login: mgascam
body: I opened https://github.com/Automattic/woocommerce-payments/issues/10645 to tackle it
created_at: 2025-03-27T15:59:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -304,34 +283,33 @@ private function process_webhook_refund_updated( $event_body ) {
 			foreach ( $wc_refunds as $wc_refund ) {
 				$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
 				if ( $refund_id === $wcpay_refund_id ) {
-					// Delete WC Refund.
-					$wc_refund->delete();
+					$matched_wc_refund = $wc_refund;
 					break;
 				}
 			}
 		}
 
-		// Update order status if order is fully refunded.
-		$current_order_status = $order->get_status();
-		if ( Order_Status::REFUNDED === $current_order_status ) {
-			$order->update_status( Order_Status::FAILED );
-		}
-
-		$order->add_order_note( $note );
-		$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
-		$order->save();
-
-		try {
-			$failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' );
-
-			if ( 'insufficient_funds' === $failure_reason ) {
-				$this->order_service->handle_insufficient_balance_for_refund(
-					$order,
-					$amount
-				);
-			}
-		} catch ( Exception $e ) {
-			Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() );
+		// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
+		switch ( $status ) {
+			case Refund_Status::FAILED:
+				$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund );
+				if (
path: includes/class-wc-payments-webhook-processing-service.php
user: login: htdat
body: Thanks @mgascam for creating the issue.
created_at: 2025-03-28T04:04:55Z
author_association: MEMBER
diff_hunk: @@ -1872,43 +1923,54 @@ private function generate_dispute_closed_note( $charge_id, $status, $is_inquiry
 	/**
 	 * Generates the HTML note for a refunded payment.
 	 *
-	 * @param float    $refunded_amount Amount refunded.
-	 * @param string   $refunded_currency Refund currency.
-	 * @param string   $wcpay_refund_id WCPay Refund ID.
-	 * @param string   $refund_reason Refund reason.
-	 * @param WC_Order $order Order object.
+	 * @param float    $refunded_amount  Amount refunded.
+	 * @param string   $refunded_currency  Refund currency.
+	 * @param string   $wcpay_refund_id  WCPay Refund ID.
+	 * @param string   $refund_reason  Refund reason.
+	 * @param WC_Order $order  Order object.
+	 * @param bool     $is_pending  Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
+	 *
 	 * @return string HTML note.
 	 */
-	private function generate_payment_refunded_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order ): string {
+	private function generate_payment_created_refund_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order, bool $is_pending ): string {
 		$multi_currency_instance = WC_Payments_Multi_Currency();
 		$formatted_price         = WC_Payments_Explicit_Price_Formatter::get_explicit_price( $multi_currency_instance->get_backend_formatted_wc_price( $refunded_amount, [ 'currency' => strtoupper( $refunded_currency ) ] ), $order );
 
+		$status_text = $is_pending ?
+			sprintf(
+				'<a href="https://woocommerce.com/document/woopayments/managing-money/#pending-refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
path: includes/class-wc-payments-order-service.php
user: login: htdat
body: Asked here p1743136317673289-slack-C03EE4CPPK5
created_at: 2025-03-28T04:34:11Z
author_association: MEMBER

PR comments:
user: login: botwoo
created_at: 2025-03-20T10:53:44Z
body: ### Test the build 

#### Option 1. Jetpack Beta

- Install and activate [Jetpack Beta](https://jetpack.com/download-jetpack-beta/).
- Use this build by searching for PR number `10600` or branch name `fix/1195-pending-refund` in your-test.site/wp-admin/admin.php?page=jetpack-beta&plugin=woocommerce-payments          

#### Option 2. Jurassic Ninja - available for logged-in A12s

:rocket: [Launch a JN site with this branch](https://jurassic.ninja/create/?jetpack-beta&shortlived&nojetpack&woocommerce&woocommerce-payments-dev-tools&branches.woocommerce-payments=fix/1195-pending-refund) :rocket:

:information_source: Install this [Tampermonkey script](https://github.com/Automattic/woocommerce-payments/tree/develop/bin/wcpay-live-branches) to get more options.

---

Build info: 

- Latest commit: d27e3007a05bfd1ef62e7f551681f64e74fc4dde
- Build time: 2025-03-26 04:27:10 UTC

_Note: the build is updated when a new commit is pushed to this PR._
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-03-20T10:56:43Z
body: **Size Change:** 0 B 

**Total Size:** 1.27 MB



<details><summary>ℹ️ <strong>View Unchanged</strong></summary>

| Filename | Size |
| :--- | :---: |
| `release/woocommerce-payments/assets/css/admin.css` | 1.41 kB |
| `release/woocommerce-payments/assets/css/admin.rtl.css` | 1.42 kB |
| `release/woocommerce-payments/assets/css/success.css` | 1.05 kB |
| `release/woocommerce-payments/assets/css/success.rtl.css` | 1.05 kB |
| `release/woocommerce-payments/dist/blocks-checkout-rtl.css` | 2.74 kB |
| `release/woocommerce-payments/dist/blocks-checkout.css` | 2.74 kB |
| `release/woocommerce-payments/dist/blocks-checkout.js` | 56.2 kB |
| `release/woocommerce-payments/dist/cart-block.js` | 17.3 kB |
| `release/woocommerce-payments/dist/cart.js` | 5.73 kB |
| `release/woocommerce-payments/dist/checkout-rtl.css` | 1.28 kB |
| `release/woocommerce-payments/dist/checkout.css` | 1.28 kB |
| `release/woocommerce-payments/dist/checkout.js` | 34.7 kB |
| `release/woocommerce-payments/dist/express-checkout-rtl.css` | 236 B |
| `release/woocommerce-payments/dist/express-checkout.css` | 236 B |
| `release/woocommerce-payments/dist/express-checkout.js` | 15.9 kB |
| `release/woocommerce-payments/dist/frontend-tracks.js` | 854 B |
| `release/woocommerce-payments/dist/index-rtl.css` | 19.1 kB |
| `release/woocommerce-payments/dist/index.css` | 19.1 kB |
| `release/woocommerce-payments/dist/index.js` | 239 kB |
| `release/woocommerce-payments/dist/multi-currency-analytics.js` | 1.08 kB |
| `release/woocommerce-payments/dist/multi-currency-rtl.css` | 4.29 kB |
| `release/woocommerce-payments/dist/multi-currency-switcher-block.js` | 60.8 kB |
| `release/woocommerce-payments/dist/multi-currency.css` | 4.29 kB |
| `release/woocommerce-payments/dist/multi-currency.js` | 58.2 kB |
| `release/woocommerce-payments/dist/order-rtl.css` | 740 B |
| `release/woocommerce-payments/dist/order.css` | 740 B |
| `release/woocommerce-payments/dist/order.js` | 42.3 kB |
| `release/woocommerce-payments/dist/payment-gateways-rtl.css` | 1.34 kB |
| `release/woocommerce-payments/dist/payment-gateways.css` | 1.34 kB |
| `release/woocommerce-payments/dist/payment-gateways.js` | 39.1 kB |
| `release/woocommerce-payments/dist/plugins-page-rtl.css` | 375 B |
| `release/woocommerce-payments/dist/plugins-page.css` | 375 B |
| `release/woocommerce-payments/dist/plugins-page.js` | 20.1 kB |
| `release/woocommerce-payments/dist/product-details-rtl.css` | 433 B |
| `release/woocommerce-payments/dist/product-details.css` | 436 B |
| `release/woocommerce-payments/dist/product-details.js` | 12.7 kB |
| `release/woocommerce-payments/dist/settings-rtl.css` | 11.4 kB |
| `release/woocommerce-payments/dist/settings.css` | 11.3 kB |
| `release/woocommerce-payments/dist/settings.js` | 223 kB |
| `release/woocommerce-payments/dist/subscription-edit-page.js` | 703 B |
| `release/woocommerce-payments/dist/subscription-product-onboarding-modal-rtl.css` | 524 B |
| `release/woocommerce-payments/dist/subscription-product-onboarding-modal.css` | 524 B |
| `release/woocommerce-payments/dist/subscription-product-onboarding-modal.js` | 20.2 kB |
| `release/woocommerce-payments/dist/subscription-product-onboarding-toast.js` | 730 B |
| `release/woocommerce-payments/dist/subscriptions-empty-state-rtl.css` | 120 B |
| `release/woocommerce-payments/dist/subscriptions-empty-state.css` | 120 B |
| `release/woocommerce-payments/dist/subscriptions-empty-state.js` | 19.3 kB |
| `release/woocommerce-payments/dist/success.js` | 6.03 kB |
| `release/woocommerce-payments/dist/tokenized-express-checkout-rtl.css` | 236 B |
| `release/woocommerce-payments/dist/tokenized-express-checkout.css` | 236 B |
| `release/woocommerce-payments/dist/tokenized-express-checkout.js` | 16.9 kB |
| `release/woocommerce-payments/dist/tos-rtl.css` | 235 B |
| `release/woocommerce-payments/dist/tos.css` | 235 B |
| `release/woocommerce-payments/dist/tos.js` | 21.8 kB |
| `release/woocommerce-payments/dist/woopay-direct-checkout.js` | 6.13 kB |
| `release/woocommerce-payments/dist/woopay-express-button.js` | 23.5 kB |
| `release/woocommerce-payments/dist/woopay-rtl.css` | 4.31 kB |
| `release/woocommerce-payments/dist/woopay.css` | 4.28 kB |
| `release/woocommerce-payments/dist/woopay.js` | 71.1 kB |
| `release/woocommerce-payments/includes/subscriptions/assets/css/plugin-page.css` | 625 B |
| `release/woocommerce-payments/includes/subscriptions/assets/js/plugin-page.js` | 814 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-assets/build/i18n-loader.js` | 2.46 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-assets/build/jetpack-script-data.js` | 957 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-assets/src/js/i18n-loader.js` | 1.02 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-assets/src/js/script-data.js` | 69 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/babel.config.js` | 163 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/identity-crisis.css` | 2.47 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/identity-crisis.js` | 14.3 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/identity-crisis.rtl.css` | 2.47 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-connection.css` | 10.1 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-connection.js` | 29.7 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-connection.rtl.css` | 10.1 kB |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-admin-create-user.css` | 198 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-admin-create-user.js` | 280 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-admin-create-user.rtl.css` | 198 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-login.css` | 625 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-login.js` | 333 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-login.rtl.css` | 626 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-sso-users.js` | 417 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/jetpack-users-connection.js` | 161 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/tracks-ajax.js` | 521 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/dist/tracks-callables.js` | 585 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-admin-create-user.css` | 215 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-admin-create-user.js` | 521 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-login.css` | 721 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-login.js` | 412 B |
| `release/woocommerce-payments/vendor/automattic/jetpack-connection/src/sso/jetpack-sso-users.js` | 625 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/about.css` | 1.04 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/admin-empty-state.css` | 294 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/admin-order-statuses.css` | 408 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/admin.css` | 3.59 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/checkout.css` | 301 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/modal.css` | 746 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/view-subscription.css` | 574 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/css/wcs-upgrade.css` | 414 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/admin-pointers.js` | 543 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/admin.js` | 9.4 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/jstz.js` | 6.78 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/jstz.min.js` | 3.84 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/meta-boxes-coupon.js` | 545 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/meta-boxes-subscription.js` | 2.52 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/moment.js` | 22.2 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/moment.min.js` | 11.7 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/payment-method-restrictions.js` | 1.29 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/admin/wcs-meta-boxes-order.js` | 507 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/payment-methods.js` | 358 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/single-product.js` | 428 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/view-subscription.js` | 1.38 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/frontend/wcs-cart.js` | 782 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/modal.js` | 1.09 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/assets/js/wcs-upgrade.js` | 1.26 kB |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/build/index.css` | 391 B |
| `release/woocommerce-payments/vendor/woocommerce/subscriptions-core/build/index.js` | 3.04 kB |

</details>



<a href="https://github.com/preactjs/compressed-size-action"><sub>compressed-size-action</sub></a>
author_association: CONTRIBUTOR
user: login: htdat
created_at: 2025-03-21T10:18:33Z
body: FYI ready for the initial review. The unit tests are still in progress, but I would love to have initial feedback meanwhile.
author_association: MEMBER
user: login: mgascam
created_at: 2025-03-24T12:25:49Z
body: @htdat I noticed the following error while testing. It saw it when I tried to visit the order details page, after a checkout:
```
Fatal error: Uncaught Error: Class "WCPay\Constants\Refund_Status" not found in /var/www/html/wp-content/plugins/woocommerce-payments/includes/class-wc-payment-gateway-wcpay.php:2327 Stack trace: #0 /var/www/html/wp-content/plugins/woocommerce-payments/includes/admin/class-wc-payments-admin.php(833): WC_Payment_Gateway_WCPay->has_refund_failed(Object(Automattic\WooCommerce\Admin\Overrides\Order)) #1 /var/www/html/wp-includes/class-wp-hook.php(324): WC_Payments_Admin->enqueue_payments_scripts('woocommerce_pag...') #2 /var/www/html/wp-includes/class-wp-hook.php(348): WP_Hook->apply_filters(NULL, Array) #3 /var/www/html/wp-includes/plugin.php(517): WP_Hook->do_action(Array) #4 /var/www/html/wp-admin/admin-header.php(118): do_action('admin_enqueue_s...', 'woocommerce_pag...') #5 /var/www/html/wp-admin/admin.php(239): require_once('/var/www/html/w...') #6 {main} thrown in /var/www/html/wp-content/plugins/woocommerce-payments/includes/class-wc-payment-gateway-wcpay.php on line 2327
``` 
I'm a bit confused, since the class should be available, given the call to `use WCPay\Constants\Refund_Status;`. Also we are already using other constants in the same file 🤔 Sharing in case it rings a bell.
author_association: CONTRIBUTOR
user: login: htdat
created_at: 2025-03-25T10:17:42Z
body: @mgascam - yeah, I missed adding the file into the include list. The PR is ready to review. I've added tests covering for the webhook processing change; but I will still need to add tests for changes in Order Service. 

In the meantime, you can review the code too :D 

**_Edit_: with the most recent commit, all tests have been created and updated for changes.**
author_association: MEMBER
user: login: mgascam
created_at: 2025-03-25T19:49:08Z
body: Hi @htdat I wanted to share one scenario I found while testing the PR. I did the checkout with the Asynchronous success card. The thing is my test account did not have balance to process the refund, so it was pending and I canceled it in the Stripe dashboard. This is the payment for your reference: https://dashboard.stripe.com/test/connect/accounts/acct_1PxRhsR4vt8oOW8F/payments/pi_3R6dCwR4vt8oOW8F1rjgCeW9. If you have a look, you can see that the last triggered event was `refund.failed`. The status of this event is "canceled", but I don't think we are handling it. As a result, things are out of sync and the last order note in my test store is:
<img width="282" alt="Screenshot 2025-03-25 at 20 46 40" src="https://github.com/user-attachments/assets/69f12d4b-13c8-42ee-8136-4fb79a9ae94c" />

Sorry It's a bit late for me to debug it and confirm my theory. I'll do it tomorrow but just wanted to share with you in case you can also have a look  🙏 
author_association: CONTRIBUTOR
user: login: htdat
created_at: 2025-03-26T04:28:35Z
body: Thanks @mgascam. I handled that `canceled` status here in this commit 0028acff0a1c07a20f9d78242c884fec030f3315 

Basically, the `canceled` status is only possible when refunding from Stripe Dash. It's not possible from WooPayments client side as we have not supported the `canceled` endpoint for refunds yet. Given these facts, the chance to see the `canceled` status is very low but anyway, we could support it without much effort.
author_association: MEMBER