Skip to content

Commit

Permalink
Add specific type and min supported amount in error response for the …
Browse files Browse the repository at this point in the history
…payments below min supported amount (#10112)
  • Loading branch information
mgascam authored and dmvrtx committed Jan 31, 2025
1 parent 0d76074 commit 412cb14
Show file tree
Hide file tree
Showing 8 changed files with 295 additions and 47 deletions.
Original file line number Diff line number Diff line change
@@ -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.
58 changes: 54 additions & 4 deletions client/data/authorizations/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -231,6 +277,10 @@ export function* submitCaptureAuthorization(
message?: string;
data?: {
status?: number;
extra_details?: {
minimum_amount?: number;
minimum_amount_currency?: string;
};
};
};

Expand Down
90 changes: 90 additions & 0 deletions client/data/authorizations/test/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 );

Expand Down Expand Up @@ -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 );

Expand Down
2 changes: 2 additions & 0 deletions docs/rest-api/source/includes/wp-api-v3/order.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
18 changes: 15 additions & 3 deletions includes/admin/class-wc-rest-payments-orders-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -204,15 +204,21 @@ 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(
// translators: %s: the error message.
__( '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.
Expand Down Expand Up @@ -304,14 +310,20 @@ 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(
// translators: %s: the error message.
__( '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,
]
);
}

Expand Down
28 changes: 24 additions & 4 deletions includes/class-wc-payment-gateway-wcpay.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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 );
Expand All @@ -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();
}
}

Expand All @@ -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 ?? [],
];
}

Expand Down
52 changes: 52 additions & 0 deletions tests/unit/admin/test-class-wc-rest-payments-orders-controller.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'] );
}
}
Loading

0 comments on commit 412cb14

Please sign in to comment.