Skip to content

Commit

Permalink
Add handling for insufficient funds during refund processing (#10313)
Browse files Browse the repository at this point in the history
  • Loading branch information
deepakpathania authored Feb 7, 2025
1 parent ab7388e commit 60abecc
Show file tree
Hide file tree
Showing 6 changed files with 196 additions and 22 deletions.
2 changes: 1 addition & 1 deletion bin/run-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ if $WATCH_FLAG; then
else
echo "Running the tests..."

docker-compose exec -u www-data wordpress \
docker compose exec -u www-data wordpress \
/var/www/html/wp-content/plugins/woocommerce-payments/vendor/bin/phpunit \
--configuration /var/www/html/wp-content/plugins/woocommerce-payments/phpunit.xml.dist \
$*
Expand Down
4 changes: 4 additions & 0 deletions changelog/update-add-handling-for-low-refund-balance
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: update

Update handling for refund processing in case of insufficient funds.
72 changes: 53 additions & 19 deletions includes/admin/class-wc-rest-payments-refunds-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,41 +41,75 @@ public function register_routes() {
*
* @internal Not intended for usage in integrations or outside of WooCommerce Payments.
* @param WP_REST_Request $request Full data about the request.
* @return WP_REST_Response|WP_Error
*/
public function process_refund( $request ) {
$order_id = $request->get_param( 'order_id' );
$charge_id = $request->get_param( 'charge_id' );
$amount = $request->get_param( 'amount' );
$reason = $request->get_param( 'reason' );

$order = null;
if ( $order_id ) {
$order = wc_get_order( $order_id );
if ( $order ) {
$result = wc_create_refund(
[
'amount' => WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ),
'reason' => $reason,
'order_id' => $order_id,
'refund_payment' => true,
'restock_items' => true,
]
);

if ( false !== $order && $order instanceof WC_Order ) {
$result = $this->process_order_refund( $order, $amount, $reason );
if ( is_wp_error( $result ) || false === $result ) {
return rest_ensure_response(
new WP_Error(
'wcpay_refund_payment',
__( 'Failed to create refund', 'woocommerce-payments' )
)
);
}
return rest_ensure_response( $result );
}
}

try {
$refund_request = Refund_Charge::create( $charge_id );
$refund_request->set_charge( $charge_id );
$refund_request->set_amount( $amount );
$refund_request->set_reason( $reason );
$refund_request->set_source( 'transaction_details_no_order' );
$response = $refund_request->send();

return rest_ensure_response( $response );
return rest_ensure_response( $this->process_charge_refund( $charge_id, $amount, $reason ) );
} catch ( API_Exception $e ) {
if ( 'insufficient_balance_for_refund' === $e->get_error_code() && $order instanceof WC_Order ) {
WC_Payments::get_order_service()->handle_insufficient_balance_for_refund( $order, $amount );
}
return rest_ensure_response( new WP_Error( 'wcpay_refund_payment', $e->getMessage() ) );
}
}

/**
* Process refund for an order.
*
* @param WC_Order $order The order to refund.
* @param int $amount Refund amount.
* @param string $reason Refund reason.
* @return WC_Order_Refund|WP_Error|false
*/
private function process_order_refund( WC_Order $order, $amount, $reason ) {
return wc_create_refund(
[
'amount' => WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ),
'reason' => $reason,
'order_id' => $order->get_id(),
'refund_payment' => true,
'restock_items' => true,
]
);
}

/**
* Process refund for a charge.
*
* @param string $charge_id The charge to refund.
* @param int $amount Refund amount.
* @param string $reason Refund reason.
* @return array
*/
private function process_charge_refund( $charge_id, $amount, $reason ) {
$refund_request = Refund_Charge::create( $charge_id );
$refund_request->set_charge( $charge_id );
$refund_request->set_amount( $amount );
$refund_request->set_reason( $reason );
$refund_request->set_source( 'transaction_details_no_order' );
return $refund_request->send();
}
}
82 changes: 82 additions & 0 deletions includes/class-wc-payments-order-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -2127,4 +2127,86 @@ private function is_order_type_object( $order ): bool {
private function intent_has_card_payment_type( $intent_data ): bool {
return isset( $intent_data['payment_method_type'] ) && 'card' === $intent_data['payment_method_type'];
}

/**
* Countries where FROD balance is not supported.
*
* @var array
*/
const FROD_UNSUPPORTED_COUNTRIES = [ 'HK', 'SG', 'AE' ];

/**
* Handle insufficient balance for refund.
*
* @param WC_Order $order The order being refunded.
* @param int $amount The refund amount.
*/
public function handle_insufficient_balance_for_refund( WC_Order $order, $amount ) {
$account_country = WC_Payments::get_account_service()->get_account_country();

$formatted_amount = wc_price(
WC_Payments_Utils::interpret_stripe_amount( $amount, $order->get_currency() ),
[ 'currency' => $order->get_currency() ]
);

if ( $this->is_frod_supported( $account_country ) ) {
$order->add_order_note( $this->get_frod_support_note( $formatted_amount ) );
} else {
$order->add_order_note( $this->get_insufficient_balance_note( $formatted_amount ) );
}
}

/**
* Check if FROD is supported for the given country.
*
* @param string $country_code Two-letter country code.
* @return bool
*/
private function is_frod_supported( $country_code ) {
return ! in_array(
$country_code,
self::FROD_UNSUPPORTED_COUNTRIES,
true
);
}

/**
* Get the order note for FROD supported countries.
*
* @param string $formatted_amount The formatted refund amount.
* @return string
*/
private function get_frod_support_note( $formatted_amount ) {
$learn_more_url = 'https://woocommerce.com/document/woopayments/fees-and-debits/preventing-negative-balances/#adding-funds';
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %s: Formatted refund amount */
__( 'Refund of %s <strong>failed</strong> due to insufficient funds in your WooPayments balance. To prevent delays in refunding customers, please consider adding funds to your Future Refunds or Disputes (FROD) balance. <a>Learn more</a>.', 'woocommerce-payments' ),
[
'strong' => '<strong>',
'a' => '<a href="' . $learn_more_url . '" target="_blank" rel="noopener noreferrer">',
]
),
$formatted_amount
);
}

/**
* Get the order note for countries without FROD support.
*
* @param string $formatted_amount The formatted refund amount.
* @return string
*/
private function get_insufficient_balance_note( $formatted_amount ) {
return sprintf(
WC_Payments_Utils::esc_interpolated_html(
/* translators: %1$s: Formatted refund amount */
__( 'Refund of %1$s <strong>failed</strong> due to insufficient funds in your WooPayments balance.', 'woocommerce-payments' ),
[
'strong' => '<strong>',
]
),
$formatted_amount
);
}
}
13 changes: 13 additions & 0 deletions includes/class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,19 @@ private function process_webhook_refund_updated( $event_body ) {
$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() );
}
}

/**
Expand Down
45 changes: 43 additions & 2 deletions tests/unit/test-class-wc-payments-webhook-processing-service.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ 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' ] )
->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' ] )
->getMock();

$this->mock_db_wrapper = $this->getMockBuilder( WC_Payments_DB::class )
Expand All @@ -116,7 +116,17 @@ public function set_up() {

$this->mock_database_cache = $this->createMock( Database_Cache::class );

$this->webhook_processing_service = new WC_Payments_Webhook_Processing_Service( $this->mock_api_client, $this->mock_db_wrapper, $mock_wcpay_account, $this->mock_remote_note_service, $this->order_service, $this->mock_receipt_service, $this->mock_wcpay_gateway, $this->mock_customer_service, $this->mock_database_cache );
$this->webhook_processing_service = new WC_Payments_Webhook_Processing_Service(
$this->mock_api_client,
$this->mock_db_wrapper,
$this->createMock( WC_Payments_Account::class ),
$this->mock_remote_note_service,
$this->order_service,
$this->mock_receipt_service,
$this->mock_wcpay_gateway,
$this->mock_customer_service,
$this->mock_database_cache
);

// Build the event body data.
$event_object = [];
Expand Down Expand Up @@ -493,6 +503,37 @@ public function test_valid_failed_refund_update_webhook_with_unknown_charge_id()
$this->webhook_processing_service->process( $this->event_body );
}

/**
* Test a valid failed refund update webhook with insufficient funds.
*/
public function test_valid_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' => 'charge_id',
'id' => 'test_refund_id',
'amount' => 999,
'currency' => 'gbp',
'failure_reason' => 'insufficient_funds',
];

$this->mock_db_wrapper
->expects( $this->once() )
->method( 'order_from_charge_id' )
->with( '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
*/
Expand Down

0 comments on commit 60abecc

Please sign in to comment.