Skip to content

Commit fba4cf5

Browse files
authored
Add pending refund notes (#10600)
1 parent 50d309f commit fba4cf5

9 files changed

+496
-201
lines changed

changelog/fix-1195-pending-refund

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: fix
3+
4+
Handle pending refunds properly

includes/class-wc-payment-gateway-wcpay.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use WCPay\Constants\Intent_Status;
2121
use WCPay\Constants\Payment_Type;
2222
use WCPay\Constants\Payment_Method;
23+
use WCPay\Constants\Refund_Status;
2324
use WCPay\Exceptions\{Add_Payment_Method_Exception,
2425
Amount_Too_Small_Exception,
2526
API_Merchant_Exception,
@@ -2310,8 +2311,8 @@ static function ( $refund ) use ( $refund_amount ) {
23102311
// translators: %1$: order id.
23112312
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() ) );
23122313
}
2313-
// If the refund was successful, add a note to the order and update the refund status.
2314-
$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund['id'], $refund['balance_transaction'] ?? null );
2314+
// There is no error. Refund status can be either pending or succeeded, add a note to the order and update the refund status.
2315+
$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund['id'], $refund['balance_transaction'] ?? null, Refund_Status::PENDING === $refund['status'] );
23152316

23162317
return true;
23172318
}
@@ -2323,7 +2324,7 @@ static function ( $refund ) use ( $refund_amount ) {
23232324
* @return boolean
23242325
*/
23252326
public function has_refund_failed( $order ) {
2326-
return 'failed' === $this->order_service->get_wcpay_refund_status_for_order( $order );
2327+
return Refund_Status::FAILED === $this->order_service->get_wcpay_refund_status_for_order( $order );
23272328
}
23282329

23292330
/**

includes/class-wc-payments-order-service.php

Lines changed: 78 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use WCPay\Constants\Order_Status;
1010
use WCPay\Constants\Intent_Status;
1111
use WCPay\Constants\Payment_Method;
12+
use WCPay\Constants\Refund_Status;
1213
use WCPay\Exceptions\Order_Not_Found_Exception;
1314
use WCPay\Fraud_Prevention\Models\Rule;
1415
use WCPay\Logger;
@@ -1446,18 +1447,19 @@ public function create_refund_for_order( WC_Order $order, float $amount, string
14461447
* @param WC_Order_Refund $wc_refund The WC refund object.
14471448
* @param string $refund_id The refund ID.
14481449
* @param string|null $refund_balance_transaction_id The balance transaction ID of the refund.
1450+
* @param bool $is_pending Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
14491451
* @throws Order_Not_Found_Exception
14501452
* @throws Exception
14511453
*/
1452-
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 {
1453-
$note = $this->generate_payment_refunded_note( $wc_refund->get_amount(), $wc_refund->get_currency(), $refund_id, $wc_refund->get_reason(), $order );
1454+
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 {
1455+
$note = $this->generate_payment_created_refund_note( $wc_refund->get_amount(), $wc_refund->get_currency(), $refund_id, $wc_refund->get_reason(), $order, $is_pending );
14541456

14551457
if ( ! $this->order_note_exists( $order, $note ) ) {
14561458
$order->add_order_note( $note );
14571459
}
14581460

1459-
// Set refund metadata.
1460-
$this->set_wcpay_refund_status_for_order( $order, 'successful' );
1461+
// Use `successful` to maintain the backward compatibility with the previous WooPayments versions.
1462+
$this->set_wcpay_refund_status_for_order( $order, $is_pending ? Refund_Status::PENDING : 'successful' );
14611463
$this->set_wcpay_refund_id_for_refund( $wc_refund, $refund_id );
14621464
if ( isset( $refund_balance_transaction_id ) ) {
14631465
$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
14661468
$order->save();
14671469
}
14681470

1471+
/**
1472+
* Handle a failed refund by adding a note, updating metadata, and optionally deleting the refund.
1473+
*
1474+
* @param WC_Order $order The order to add the note to.
1475+
* @param string $refund_id The ID of the failed refund.
1476+
* @param int $amount The refund amount in cents.
1477+
* @param string $currency The currency code.
1478+
* @param WC_Order_Refund|null $wc_refund The WC refund object to delete if provided.
1479+
* @param bool $is_cancelled Whether this is a cancellation rather than a failure. Default false.
1480+
* @return void
1481+
*/
1482+
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 {
1483+
// Delete the refund if it exists.
1484+
if ( $wc_refund ) {
1485+
$wc_refund->delete();
1486+
}
1487+
1488+
$note = sprintf(
1489+
WC_Payments_Utils::esc_interpolated_html(
1490+
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
1491+
__( 'A refund of %1$s was <strong>%4$s</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
1492+
[
1493+
'strong' => '<strong>',
1494+
'code' => '<code>',
1495+
]
1496+
),
1497+
WC_Payments_Explicit_Price_Formatter::get_explicit_price(
1498+
wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
1499+
$order
1500+
),
1501+
'WooPayments',
1502+
$refund_id,
1503+
$is_cancelled ? __( 'cancelled', 'woocommerce-payments' ) : __( 'unsuccessful', 'woocommerce-payments' )
1504+
);
1505+
1506+
if ( $this->order_note_exists( $order, $note ) ) {
1507+
return;
1508+
}
1509+
1510+
// If order has been fully refunded.
1511+
if ( Order_Status::REFUNDED === $order->get_status() ) {
1512+
$order->update_status( Order_Status::FAILED );
1513+
}
1514+
1515+
$order->add_order_note( $note );
1516+
$this->set_wcpay_refund_status_for_order( $order, Refund_Status::FAILED );
1517+
$order->save();
1518+
}
1519+
14691520
/**
14701521
* Get content for the success order note.
14711522
*
@@ -1872,43 +1923,54 @@ private function generate_dispute_closed_note( $charge_id, $status, $is_inquiry
18721923
/**
18731924
* Generates the HTML note for a refunded payment.
18741925
*
1875-
* @param float $refunded_amount Amount refunded.
1876-
* @param string $refunded_currency Refund currency.
1877-
* @param string $wcpay_refund_id WCPay Refund ID.
1878-
* @param string $refund_reason Refund reason.
1879-
* @param WC_Order $order Order object.
1926+
* @param float $refunded_amount Amount refunded.
1927+
* @param string $refunded_currency Refund currency.
1928+
* @param string $wcpay_refund_id WCPay Refund ID.
1929+
* @param string $refund_reason Refund reason.
1930+
* @param WC_Order $order Order object.
1931+
* @param bool $is_pending Created refund status can be either pending or succeeded. Default false, i.e. succeeded.
1932+
*
18801933
* @return string HTML note.
18811934
*/
1882-
private function generate_payment_refunded_note( float $refunded_amount, string $refunded_currency, string $wcpay_refund_id, string $refund_reason, WC_Order $order ): string {
1935+
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 {
18831936
$multi_currency_instance = WC_Payments_Multi_Currency();
18841937
$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 );
18851938

1939+
$status_text = $is_pending ?
1940+
sprintf(
1941+
'<a href="https://woocommerce.com/document/woopayments/managing-money/#pending-refunds" target="_blank" rel="noopener noreferrer">%1$s</a>',
1942+
__( 'is pending', 'woocommerce-payments' )
1943+
)
1944+
: __( 'was successfully processed', 'woocommerce-payments' );
1945+
18861946
if ( empty( $refund_reason ) ) {
18871947
$note = sprintf(
18881948
WC_Payments_Utils::esc_interpolated_html(
1889-
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
1890-
__( 'A refund of %1$s was successfully processed using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
1949+
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund, %4: status text */
1950+
__( 'A refund of %1$s %4$s using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
18911951
[
18921952
'code' => '<code>',
18931953
]
18941954
),
18951955
$formatted_price,
18961956
'WooPayments',
1897-
$wcpay_refund_id
1957+
$wcpay_refund_id,
1958+
$status_text
18981959
);
18991960
} else {
19001961
$note = sprintf(
19011962
WC_Payments_Utils::esc_interpolated_html(
1902-
/* translators: %1: the successfully charged amount, %2: WooPayments, %3: reason, %4: refund id */
1903-
__( 'A refund of %1$s was successfully processed using %2$s. Reason: %3$s. (<code>%4$s</code>)', 'woocommerce-payments' ),
1963+
/* translators: %1: the refund amount, %2: WooPayments, %3: reason, %4: refund id, %5: status text */
1964+
__( 'A refund of %1$s %5$s using %2$s. Reason: %3$s. (<code>%4$s</code>)', 'woocommerce-payments' ),
19041965
[
19051966
'code' => '<code>',
19061967
]
19071968
),
19081969
$formatted_price,
19091970
'WooPayments',
19101971
$refund_reason,
1911-
$wcpay_refund_id
1972+
$wcpay_refund_id,
1973+
$status_text
19121974
);
19131975
}
19141976

includes/class-wc-payments-webhook-processing-service.php

Lines changed: 34 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use WCPay\Exceptions\Order_Not_Found_Exception;
1515
use WCPay\Exceptions\Rest_Request_Exception;
1616
use WCPay\Logger;
17+
use WCPay\Constants\Refund_Status;
1718

1819
if ( ! defined( 'ABSPATH' ) ) {
1920
exit; // Exit if accessed directly.
@@ -248,17 +249,15 @@ private function process_webhook_refund_updated( $event_body ) {
248249
$event_data = $this->read_webhook_property( $event_body, 'data' );
249250
$event_object = $this->read_webhook_property( $event_data, 'object' );
250251

251-
// First, check the reason for the update. We're only interested in a status of failed.
252-
$status = $this->read_webhook_property( $event_object, 'status' );
253-
if ( 'failed' !== $status ) {
254-
return;
255-
}
256-
257252
// Fetch the details of the failed refund so that we can find the associated order and write a note.
258-
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
259-
$refund_id = $this->read_webhook_property( $event_object, 'id' );
260-
$amount = $this->read_webhook_property( $event_object, 'amount' );
261-
$currency = $this->read_webhook_property( $event_object, 'currency' );
253+
$charge_id = $this->read_webhook_property( $event_object, 'charge' );
254+
$refund_id = $this->read_webhook_property( $event_object, 'id' );
255+
$amount = $this->read_webhook_property( $event_object, 'amount' );
256+
$currency = $this->read_webhook_property( $event_object, 'currency' );
257+
$status = $this->read_webhook_property( $event_object, 'status' );
258+
$balance_transaction = $this->has_webhook_property( $event_object, 'balance_transaction' )
259+
? $this->read_webhook_property( $event_object, 'balance_transaction' )
260+
: null;
262261

263262
// Look up the order related to this charge.
264263
$order = $this->wcpay_db->order_from_charge_id( $charge_id );
@@ -273,29 +272,9 @@ private function process_webhook_refund_updated( $event_body ) {
273272
);
274273
}
275274

276-
$note = sprintf(
277-
WC_Payments_Utils::esc_interpolated_html(
278-
/* translators: %1: the refund amount, %2: WooPayments, %3: ID of the refund */
279-
__( 'A refund of %1$s was <strong>unsuccessful</strong> using %2$s (<code>%3$s</code>).', 'woocommerce-payments' ),
280-
[
281-
'strong' => '<strong>',
282-
'code' => '<code>',
283-
]
284-
),
285-
WC_Payments_Explicit_Price_Formatter::get_explicit_price(
286-
wc_price( WC_Payments_Utils::interpret_stripe_amount( $amount, $currency ), [ 'currency' => strtoupper( $currency ) ] ),
287-
$order
288-
),
289-
'WooPayments',
290-
$refund_id
291-
);
292-
293-
if ( $this->order_service->order_note_exists( $order, $note ) ) {
294-
return;
295-
}
296-
275+
$matched_wc_refund = null;
297276
/**
298-
* Get refunds from order and delete refund if matches wcpay refund id.
277+
* Get the WC_Refund from the WCPay refund ID.
299278
*
300279
* @var $wc_refunds WC_Order_Refund[]
301280
* */
@@ -304,34 +283,33 @@ private function process_webhook_refund_updated( $event_body ) {
304283
foreach ( $wc_refunds as $wc_refund ) {
305284
$wcpay_refund_id = $this->order_service->get_wcpay_refund_id_for_order( $wc_refund );
306285
if ( $refund_id === $wcpay_refund_id ) {
307-
// Delete WC Refund.
308-
$wc_refund->delete();
286+
$matched_wc_refund = $wc_refund;
309287
break;
310288
}
311289
}
312290
}
313291

314-
// Update order status if order is fully refunded.
315-
$current_order_status = $order->get_status();
316-
if ( Order_Status::REFUNDED === $current_order_status ) {
317-
$order->update_status( Order_Status::FAILED );
318-
}
319-
320-
$order->add_order_note( $note );
321-
$this->order_service->set_wcpay_refund_status_for_order( $order, 'failed' );
322-
$order->save();
323-
324-
try {
325-
$failure_reason = $this->read_webhook_property( $event_object, 'failure_reason' );
326-
327-
if ( 'insufficient_funds' === $failure_reason ) {
328-
$this->order_service->handle_insufficient_balance_for_refund(
329-
$order,
330-
$amount
331-
);
332-
}
333-
} catch ( Exception $e ) {
334-
Logger::debug( 'Failed to handle insufficient balance for refund: ' . $e->getMessage() );
292+
// Refund update webhook events can be either failed, cancelled (basically it's also a failure but triggered by the merchant), succeeded only.
293+
switch ( $status ) {
294+
case Refund_Status::FAILED:
295+
$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund );
296+
if (
297+
$this->has_webhook_property( $event_object, 'failure_reason' )
298+
&& 'insufficient_funds' === $this->read_webhook_property( $event_object, 'failure_reason' )
299+
) {
300+
$this->order_service->handle_insufficient_balance_for_refund( $order, $amount );
301+
}
302+
break;
303+
case Refund_Status::CANCELED:
304+
$this->order_service->handle_failed_refund( $order, $refund_id, $amount, $currency, $matched_wc_refund, true );
305+
break;
306+
case Refund_Status::SUCCEEDED:
307+
if ( $matched_wc_refund ) {
308+
$this->order_service->add_note_and_metadata_for_created_refund( $order, $matched_wc_refund, $refund_id, $balance_transaction ?? null );
309+
}
310+
break;
311+
default:
312+
throw new Invalid_Webhook_Data_Exception( 'Invalid refund update status: ' . $status );
335313
}
336314
}
337315

@@ -883,7 +861,7 @@ private function process_webhook_refund_triggered_externally( array $event_body
883861

884862
$wc_refund = $this->order_service->create_refund_for_order( $order, $refunded_amount, $refund_reason, ( ! $is_partial_refund ? $order->get_items() : [] ) );
885863
// Process the refund in the order service.
886-
$this->order_service->add_note_and_metadata_for_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id );
864+
$this->order_service->add_note_and_metadata_for_created_refund( $order, $wc_refund, $refund_id, $refund_balance_transaction_id, Refund_Status::PENDING === $refund['status'] );
887865
}
888866

889867
/**

includes/class-wc-payments.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,7 @@ public static function init() {
483483
include_once __DIR__ . '/constants/class-payment-type.php';
484484
include_once __DIR__ . '/constants/class-payment-initiated-by.php';
485485
include_once __DIR__ . '/constants/class-intent-status.php';
486+
include_once __DIR__ . '/constants/class-refund-status.php';
486487
include_once __DIR__ . '/constants/class-payment-intent-status.php';
487488
include_once __DIR__ . '/constants/class-payment-capture-type.php';
488489
include_once __DIR__ . '/constants/class-payment-method.php';
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php
2+
/**
3+
* Class Refund_Status
4+
*
5+
* @package WooCommerce\Payments
6+
*/
7+
8+
namespace WCPay\Constants;
9+
10+
if ( ! defined( 'ABSPATH' ) ) {
11+
exit; // Exit if accessed directly.
12+
}
13+
14+
/**
15+
* This class gives a list of all the possible WCPay refund status constants.
16+
*
17+
* @psalm-immutable
18+
*/
19+
class Refund_Status extends Base_Constant {
20+
const PENDING = 'pending';
21+
const SUCCEEDED = 'succeeded';
22+
const FAILED = 'failed';
23+
const CANCELED = 'canceled';
24+
}

tests/unit/test-class-wc-payment-gateway-wcpay-process-refund.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ public function test_process_refund_should_work_without_payment_method_id_meta()
188188
'id' => 're_123456789',
189189
'amount' => $amount,
190190
'currency' => 'usd',
191+
'status' => 'succeeded',
191192
]
192193
);
193194
$request = $this->mock_wcpay_request( Refund_Charge::class );

0 commit comments

Comments
 (0)