diff --git a/changelog/add-10092-add-specific-type-and-min-supported-amount-in-error-response-for-the-payments-below-min-supported-amount b/changelog/add-10092-add-specific-type-and-min-supported-amount-in-error-response-for-the-payments-below-min-supported-amount new file mode 100644 index 00000000000..671ad26ad97 --- /dev/null +++ b/changelog/add-10092-add-specific-type-and-min-supported-amount-in-error-response-for-the-payments-below-min-supported-amount @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +Add wcpay_capture_error_amount_too_small error type and minimum amount details when capturing payments below the supported threshold. diff --git a/client/data/authorizations/actions.ts b/client/data/authorizations/actions.ts index ace7f3c6fed..67f7dda18de 100644 --- a/client/data/authorizations/actions.ts +++ b/client/data/authorizations/actions.ts @@ -18,13 +18,54 @@ import { } from 'wcpay/types/authorizations'; import { STORE_NAME } from '../constants'; import { ApiError } from 'wcpay/types/errors'; +import { formatCurrency } from 'multi-currency/utils/currency'; -const getErrorMessage = ( apiError: { +interface WCPayError { code?: string; message?: string; -} ): string => { + data?: { + status?: number; + extra_details?: { + minimum_amount?: number; + minimum_amount_currency?: string; + }; + }; +} + +const getErrorMessage = ( apiError: WCPayError ): string => { // Map specific error codes to user-friendly messages - const errorMessages: Record< string, string > = { + const getAmountTooSmallError = ( error: WCPayError ): string => { + if ( + ! error.data?.extra_details?.minimum_amount || + ! error.data?.extra_details?.minimum_amount_currency + ) { + return __( + 'The payment amount is too small to be processed.', + 'woocommerce-payments' + ); + } + + const currency = error.data.extra_details.minimum_amount_currency; + const amount = formatCurrency( + error.data.extra_details.minimum_amount, + currency + ); + + return sprintf( + /* translators: %1$s: minimum amount, %2$s: currency code */ + __( + 'The minimum amount that can be processed is %1$s %2$s.', + 'woocommerce-payments' + ), + amount, + currency.toUpperCase() + ); + }; + + const errorMessages: Record< + string, + string | ( ( error: WCPayError ) => string ) + > = { wcpay_missing_order: __( 'The order could not be found.', 'woocommerce-payments' @@ -53,10 +94,15 @@ const getErrorMessage = ( apiError: { 'An unexpected error occurred. Please try again later.', 'woocommerce-payments' ), + wcpay_capture_error_amount_too_small: getAmountTooSmallError, }; + const errorHandler = errorMessages[ apiError.code ?? '' ]; + if ( typeof errorHandler === 'function' ) { + return errorHandler( apiError ); + } return ( - errorMessages[ apiError.code ?? '' ] ?? + errorHandler ?? __( 'Unable to process the payment. Please try again later.', 'woocommerce-payments' @@ -231,6 +277,10 @@ export function* submitCaptureAuthorization( message?: string; data?: { status?: number; + extra_details?: { + minimum_amount?: number; + minimum_amount_currency?: string; + }; }; }; diff --git a/client/data/authorizations/test/actions.test.ts b/client/data/authorizations/test/actions.test.ts index 36527d1836a..a9c7c35605e 100644 --- a/client/data/authorizations/test/actions.test.ts +++ b/client/data/authorizations/test/actions.test.ts @@ -18,6 +18,25 @@ import { import authorizationsFixture from './authorizations.fixture.json'; import { STORE_NAME } from 'wcpay/data/constants'; +declare const global: { + wcpaySettings: { + zeroDecimalCurrencies: string[]; + connect: { + country: string; + }; + currencyData: { + [ key: string ]: { + code: string; + symbol: string; + symbolPosition: string; + thousandSeparator: string; + decimalSeparator: string; + precision: number; + }; + }; + }; +}; + describe( 'Authorizations actions', () => { describe( 'submitCaptureAuthorization', () => { const { @@ -168,6 +187,25 @@ describe( 'Authorizations actions', () => { } ); describe( 'error handling', () => { + beforeEach( () => { + global.wcpaySettings = { + zeroDecimalCurrencies: [], + connect: { + country: 'US', + }, + currencyData: { + USD: { + code: 'USD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + }, + }; + } ); + it( 'should create error notice with API error message', () => { const generator = submitCaptureAuthorization( 'pi_123', 123 ); @@ -225,6 +263,58 @@ describe( 'Authorizations actions', () => { ); } ); + it( 'should create error notice with amount too small error details', () => { + const generator = submitCaptureAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error for amount too small + const apiError = { + code: 'wcpay_capture_error_amount_too_small', + data: { + status: 400, + extra_details: { + minimum_amount: 50, + minimum_amount_currency: 'USD', + }, + }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #123. The minimum amount that can be processed is $0.50 USD.' + ) + ); + } ); + + it( 'should create error notice with amount too small when amount details are missing', () => { + const generator = submitCaptureAuthorization( 'pi_123', 123 ); + + // Skip initial dispatch calls + generator.next(); + generator.next(); + + // Mock API error for amount too small + const apiError = { + code: 'wcpay_capture_error_amount_too_small', + data: { + status: 400, + }, + }; + + expect( generator.throw( apiError ).value ).toEqual( + controls.dispatch( + 'core/notices', + 'createErrorNotice', + 'There has been an error capturing the payment for order #123. The payment amount is too small to be processed.' + ) + ); + } ); + it( 'should create error notice with fallback message when API error has no message', () => { const generator = submitCaptureAuthorization( 'pi_123', 123 ); diff --git a/docs/rest-api/source/includes/wp-api-v3/order.md b/docs/rest-api/source/includes/wp-api-v3/order.md index 6a6527c1023..fc3ff4dd7ca 100644 --- a/docs/rest-api/source/includes/wp-api-v3/order.md +++ b/docs/rest-api/source/includes/wp-api-v3/order.md @@ -68,6 +68,7 @@ Capture the funds of an in-person payment intent. Given an intent ID and an orde - `wcpay_refunded_order_uncapturable` - Payment cannot be captured for partially or fully refunded orders - `wcpay_payment_uncapturable` - The payment cannot be captured if intent status is not one of 'processing', 'requires_capture', or 'succeeded' - `wcpay_capture_error` - Unknown error +- `wcpay_capture_error_amount_too_small` - The payment cannot be captured because the amount is too small ### HTTP request @@ -124,6 +125,7 @@ Capture the funds of an existing uncaptured payment intent that was marked to be - `wcpay_payment_uncapturable` - The payment cannot be captured if intent status is not one of 'processing', 'requires_capture', or 'succeeded' - `wcpay_intent_order_mismatch` - Payment cannot be captured because the order id does not match - `wcpay_capture_error` - Unknown error +- `wcpay_capture_error_amount_too_small` - The payment cannot be captured because the amount is too small ### HTTP request diff --git a/includes/admin/class-wc-rest-payments-orders-controller.php b/includes/admin/class-wc-rest-payments-orders-controller.php index 04c86f54197..e56961eb514 100644 --- a/includes/admin/class-wc-rest-payments-orders-controller.php +++ b/includes/admin/class-wc-rest-payments-orders-controller.php @@ -204,7 +204,9 @@ public function capture_terminal_payment( WP_REST_Request $request ) { $result = $is_intent_captured ? $result_for_captured_intent : $this->gateway->capture_charge( $order, false, $intent_metadata ); if ( Intent_Status::SUCCEEDED !== $result['status'] ) { - $http_code = $result['http_code'] ?? 502; + $http_code = $result['http_code'] ?? 502; + $error_code = $result['error_code'] ?? null; + $extra_details = $result['extra_details'] ?? []; return new WP_Error( 'wcpay_capture_error', sprintf( @@ -212,7 +214,11 @@ public function capture_terminal_payment( WP_REST_Request $request ) { __( 'Payment capture failed to complete with the following message: %s', 'woocommerce-payments' ), $result['message'] ?? __( 'Unknown error', 'woocommerce-payments' ) ), - [ 'status' => $http_code ] + [ + 'status' => $http_code, + 'extra_details' => $extra_details, + 'error_type' => $error_code, + ] ); } // Store receipt generation URL for mobile applications in order meta-data. @@ -304,6 +310,8 @@ public function capture_authorization( WP_REST_Request $request ) { $result = $this->gateway->capture_charge( $order, true, $intent_metadata ); if ( Intent_Status::SUCCEEDED !== $result['status'] ) { + $error_code = $result['error_code'] ?? null; + $extra_details = $result['extra_details'] ?? []; return new WP_Error( 'wcpay_capture_error', sprintf( @@ -311,7 +319,11 @@ public function capture_authorization( WP_REST_Request $request ) { __( 'Payment capture failed to complete with the following message: %s', 'woocommerce-payments' ), $result['message'] ?? __( 'Unknown error', 'woocommerce-payments' ) ), - [ 'status' => $result['http_code'] ?? 502 ] + [ + 'status' => $result['http_code'] ?? 502, + 'extra_details' => $extra_details, + 'error_type' => $error_code, + ] ); } diff --git a/includes/class-wc-payment-gateway-wcpay.php b/includes/class-wc-payment-gateway-wcpay.php index d1be21241b9..ecd5a46dd34 100644 --- a/includes/class-wc-payment-gateway-wcpay.php +++ b/includes/class-wc-payment-gateway-wcpay.php @@ -3355,6 +3355,7 @@ public function capture_charge( $order, $include_level3 = true, $intent_metadata $status = null; $error_message = null; $http_code = null; + $error_code = null; try { $intent_id = $order->get_transaction_id(); @@ -3377,6 +3378,22 @@ public function capture_charge( $order, $include_level3 = true, $intent_metadata try { $error_message = $e->getMessage(); $http_code = $e->get_http_code(); + $error_code = $e->get_error_code(); + $extra_details = []; + + if ( $e instanceof Amount_Too_Small_Exception ) { + $extra_details = [ + 'minimum_amount' => $e->get_minimum_amount(), + 'minimum_amount_currency' => strtoupper( $e->get_currency() ), + ]; + $minimum_amount_details = sprintf( + /* translators: %1$s: minimum amount, %2$s: currency */ + __( 'The minimum amount to capture is %1$s %2$s.', 'woocommerce-payments' ), + WC_Payments_Utils::interpret_stripe_amount( $e->get_minimum_amount(), $e->get_currency() ), + strtoupper( $e->get_currency() ) + ); + $error_message = $error_message . ' ' . $minimum_amount_details; + } $request = Get_Intention::create( $intent_id ); $request->set_hook_args( $order ); @@ -3392,6 +3409,7 @@ public function capture_charge( $order, $include_level3 = true, $intent_metadata $status = null; $error_message = $e->getMessage(); $http_code = $e->get_http_code(); + $error_code = $e->get_error_code(); } } @@ -3418,10 +3436,12 @@ public function capture_charge( $order, $include_level3 = true, $intent_metadata } return [ - 'status' => $status ?? 'failed', - 'id' => ! empty( $intent ) ? $intent->get_id() : null, - 'message' => $error_message, - 'http_code' => $http_code, + 'status' => $status ?? 'failed', + 'id' => ! empty( $intent ) ? $intent->get_id() : null, + 'message' => $error_message, + 'http_code' => $http_code, + 'error_code' => $error_code, + 'extra_details' => $extra_details ?? [], ]; } diff --git a/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php b/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php index 48c94e75869..cd90665d850 100644 --- a/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php +++ b/tests/unit/admin/test-class-wc-rest-payments-orders-controller.php @@ -1998,4 +1998,56 @@ public function test_capture_terminal_payment_with_subscription_product_returns_ $response = $this->controller->capture_terminal_payment( $request ); $this->assertSame( 200, $response->status ); } + + public function test_capture_terminal_payment_error_amount_too_small() { + $order = $this->create_mock_order(); + $mock_intent = WC_Helper_Intention::create_intention( + [ + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'metadata' => [ + 'order_id' => $order->get_id(), + ], + ] + ); + + $request = $this->mock_wcpay_request( Get_Intention::class, 1, $this->mock_intent_id ); + + $request->expects( $this->once() ) + ->method( 'format_response' ) + ->willReturn( $mock_intent ); + + $this->mock_gateway + ->expects( $this->once() ) + ->method( 'capture_charge' ) + ->with( $this->isInstanceOf( WC_Order::class ) ) + ->willReturn( + [ + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'id' => $this->mock_intent_id, + 'http_code' => 400, + 'error_code' => 'amount_too_small', + 'extra_details' => [ + 'minimum_amount' => 50, + 'minimum_amount_currency' => 'USD', + ], + ] + ); + + $request = new WP_REST_Request( 'POST' ); + $request->set_body_params( + [ + 'order_id' => $order->get_id(), + 'payment_intent_id' => $this->mock_intent_id, + ] + ); + + $response = $this->controller->capture_terminal_payment( $request ); + $this->assertInstanceOf( 'WP_Error', $response ); + $this->assertSame( 'wcpay_capture_error', $response->get_error_code() ); + $this->assertStringContainsString( 'Payment capture failed to complete', $response->get_error_message() ); + $this->assertSame( 400, $response->get_error_data()['status'] ); + $this->assertSame( 50, $response->get_error_data()['extra_details']['minimum_amount'] ); + $this->assertSame( 'USD', $response->get_error_data()['extra_details']['minimum_amount_currency'] ); + $this->assertSame( 'amount_too_small', $response->get_error_data()['error_type'] ); + } } diff --git a/tests/unit/test-class-wc-payment-gateway-wcpay.php b/tests/unit/test-class-wc-payment-gateway-wcpay.php index 1827041a1fc..e4102d7d8c1 100644 --- a/tests/unit/test-class-wc-payment-gateway-wcpay.php +++ b/tests/unit/test-class-wc-payment-gateway-wcpay.php @@ -1488,10 +1488,12 @@ public function test_capture_charge_success() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => Intent_Status::SUCCEEDED, - 'id' => $intent_id, - 'message' => null, - 'http_code' => 200, + 'status' => Intent_Status::SUCCEEDED, + 'id' => $intent_id, + 'message' => null, + 'http_code' => 200, + 'error_code' => null, + 'extra_details' => [], ], $result ); @@ -1542,10 +1544,12 @@ public function test_capture_charge_success_non_usd() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => Intent_Status::SUCCEEDED, - 'id' => $intent_id, - 'message' => null, - 'http_code' => 200, + 'status' => Intent_Status::SUCCEEDED, + 'id' => $intent_id, + 'message' => null, + 'http_code' => 200, + 'error_code' => null, + 'extra_details' => [], ], $result ); @@ -1588,10 +1592,12 @@ public function test_capture_charge_failure() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => Intent_Status::REQUIRES_CAPTURE, - 'id' => $intent_id, - 'message' => null, - 'http_code' => 502, + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'id' => $intent_id, + 'message' => null, + 'http_code' => 502, + 'error_code' => null, + 'extra_details' => [], ], $result ); @@ -1642,10 +1648,12 @@ public function test_capture_charge_failure_non_usd() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => Intent_Status::REQUIRES_CAPTURE, - 'id' => $intent_id, - 'message' => null, - 'http_code' => 502, + 'status' => Intent_Status::REQUIRES_CAPTURE, + 'id' => $intent_id, + 'message' => null, + 'http_code' => 502, + 'error_code' => null, + 'extra_details' => [], ], $result ); @@ -1694,10 +1702,12 @@ public function test_capture_charge_api_failure() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => 'failed', - 'id' => $intent_id, - 'message' => 'test exception', - 'http_code' => 500, + 'status' => 'failed', + 'id' => $intent_id, + 'message' => 'test exception', + 'http_code' => 500, + 'error_code' => 'server_error', + 'extra_details' => [], ], $result ); @@ -1755,10 +1765,12 @@ public function test_capture_charge_api_failure_non_usd() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => 'failed', - 'id' => $intent_id, - 'message' => 'test exception', - 'http_code' => 500, + 'status' => 'failed', + 'id' => $intent_id, + 'message' => 'test exception', + 'http_code' => 500, + 'error_code' => 'server_error', + 'extra_details' => [], ], $result ); @@ -1808,10 +1820,12 @@ public function test_capture_charge_expired() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => 'failed', - 'id' => $intent_id, - 'message' => 'test exception', - 'http_code' => 500, + 'status' => 'failed', + 'id' => $intent_id, + 'message' => 'test exception', + 'http_code' => 500, + 'error_code' => 'server_error', + 'extra_details' => [], ], $result ); @@ -1863,10 +1877,12 @@ public function test_capture_charge_metadata() { // Assert the returned data contains fields required by the REST endpoint. $this->assertSame( [ - 'status' => Intent_Status::SUCCEEDED, - 'id' => $intent_id, - 'message' => null, - 'http_code' => 200, + 'status' => Intent_Status::SUCCEEDED, + 'id' => $intent_id, + 'message' => null, + 'http_code' => 200, + 'error_code' => null, + 'extra_details' => [], ], $result ); @@ -1914,10 +1930,12 @@ public function test_capture_charge_without_level3() { // Assert the returned data contains fields required by the REST endpoint. $this->assertEquals( [ - 'status' => Intent_Status::SUCCEEDED, - 'id' => $intent_id, - 'message' => null, - 'http_code' => 200, + 'status' => Intent_Status::SUCCEEDED, + 'id' => $intent_id, + 'message' => null, + 'http_code' => 200, + 'error_code' => null, + 'extra_details' => [], ], $result );